Compare commits

..
11 Commits
42 changed files with 1648 additions and 6139 deletions
-2
View File
@@ -9,9 +9,7 @@ script/
build/ build/
*.log *.log
*.toml *.toml
!config/*.toml
build.sh build.sh
catalog.txt catalog.txt
combined.txt combined.txt
test/run/ test/run/
test/run-mtls/
-40
View File
@@ -1,40 +0,0 @@
# Builder pin and go.mod directive are the same patch release deliberately;
# an older builder reports the mismatch only after downloading the module graph.
ARG GO_VERSION=1.27.1
FROM docker.io/library/golang:${GO_VERSION}-alpine AS build
# Git supplies Go's VCS build information; only /out/logwisp crosses stages.
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG VERSION=dev
ARG REVISION=unknown
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath \
-ldflags="-s -w -X logwisp/internal/version.Version=${VERSION} -X logwisp/internal/version.GitCommit=${REVISION}" \
-o /out/logwisp ./cmd/logwisp
FROM scratch
ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="logwisp" \
org.opencontainers.image.description="Log transport: sources, flow, sinks" \
org.opencontainers.image.source="https://github.com/lixenwraith/logwisp" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.licenses="BSD-3-Clause"
COPY --from=build /out/logwisp /logwisp
# Numeric identity is required in scratch and satisfies a restricted pod spec.
USER 65532:65532
ENTRYPOINT ["/logwisp"]
+1 -1
View File
@@ -33,7 +33,7 @@ install: build
# Uninstall the binary # Uninstall the binary
uninstall: uninstall:
rm -f $(BINDIR)/$(BINARY_NAME) rm -f $(BINDIR)/$(BINARY_PATH)
# Clean build artifacts # Clean build artifacts
clean: clean:
+45 -97
View File
@@ -6,7 +6,7 @@
<td> <td>
<h1>LogWisp</h1> <h1>LogWisp</h1>
<p> <p>
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.27.1-00ADD8?style=flat&logo=go" alt="Go"></a> <a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat&logo=go" alt="Go"></a>
<a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License"></a> <a href="https://opensource.org/licenses/BSD-3-Clause"><img src="https://img.shields.io/badge/License-BSD_3--Clause-blue.svg" alt="License"></a>
<a href="doc/"><img src="https://img.shields.io/badge/Docs-Available-green.svg" alt="Documentation"></a> <a href="doc/"><img src="https://img.shields.io/badge/Docs-Available-green.svg" alt="Documentation"></a>
</p> </p>
@@ -16,130 +16,78 @@
# LogWisp # LogWisp
A pipeline-based log transport and processing system written in Go. LogWisp A high-performance, pipeline-based log transport and processing system built in Go. LogWisp provides flexible log collection, filtering, formatting, and distribution with enterprise-grade security and reliability features.
collects log entries from files, stdin, or other LogWisp nodes; rate-limits,
filters, and formats them; and distributes them to files, consoles, live network
streams, or downstream LogWisp nodes.
## Features ## Features
### Pipeline ### Core Capabilities
- **Pipeline Architecture**: Independent processing pipelines with source(s) → filter → format → sink(s) flow
- **Multiple Input Sources**: Directory monitoring, stdin, HTTP, TCP
- **Flexible Output Sinks**: Console, file, HTTP SSE, TCP streaming, HTTP/TCP forwarding
- **Real-time Processing**: Sub-millisecond latency with configurable buffering
- **Hot Configuration Reload**: Update pipelines without service restart
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently ### Data Processing
in one process - **Pattern-based Filtering**: Chainable include/exclude filters with regex support
- **Fan-in and fan-out**: many sources and many sinks per pipeline - **Multiple Formatters**: Raw, JSON, and template-based text formatting
- **Never blocks**: a stalled sink drops its own events and is counted, rather - **Rate Limiting**: Pipeline rate control
than stalling the pipeline or its sibling sinks
- **Hot reload** via `SIGHUP`/`SIGUSR1` or a config file watch, with the new
configuration validated before the old service is torn down
### Inputs ### Security & Reliability
- **Authentication**: mTLS support for HTTPS
- **TLS Encryption**: TLS 1.2/1.3 support for HTTP connections
- **Access Control**: IP whitelisting/blacklisting, connection limits
- **Automatic Reconnection**: Resilient client connections with exponential backoff
- **File Rotation**: Size-based rotation with retention policies
`file` (directory tail with rotation detection and JSON line parsing), ### Operational Features
`console` (stdin), `random` (synthetic generator), `null`, and the chain ingest - **Status Monitoring**: Real-time statistics and health endpoints
listeners `tcp_chain` and `http_chain`. - **Signal Handling**: Graceful shutdown and configuration reload via signals
- **Background Mode**: Daemon operation with proper signal handling
### Outputs - **Quiet Mode**: Silent operation for automated deployments
`console`, `file` (rotating with retention), `http` (Server-Sent Events plus a
JSON status endpoint), `tcp` (broadcast server), `null`, and the chain
forwarders `tcp_chain` and `http_chain`.
### Processing
- **Filters**: chainable include/exclude RE2 patterns with `or`/`and` logic
- **Formatters**: `raw`, `txt`, and `json` with selectable sanitizer policies
- **Rate limiting**: token bucket with an optional per-entry size cap
- **Heartbeats**: flow-level keep-alive entries that reach every sink
### Chaining
Multi-node topologies over a versioned protocol. Chain links carry the
**structured entry**, not the formatted text, so a relay can filter and reformat
as if the entries were local. Entries keep a `node` label identifying their
origin across any number of hops. Chain sinks reconnect automatically with
exponential backoff and jitter.
### Transport security and authentication
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike
- Mutual TLS: listeners can require and verify client certificates; dialers can
present a client identity
- Authorization by certificate identity: an `auth` block admits named peers
(exact or RE2) rather than everything the CA issued, gates the `http` sink's
stream and status endpoints, and lets a dialer pin the server it talks to
- Node binding: a chain source can label entries from the sender's certificate
instead of from what the sender claims, so origin attribution is not forgeable
See [Security](doc/security.md) for configuration and the exact boundary, and
the [mTLS authentication design](doc/mtls-auth-plan.md) for the rationale and
what is deliberately left out. Password, token, and SCRAM authentication were
removed during the restructure and are not currently available.
## Documentation ## Documentation
| Document | Contents | Available in `doc/` directory.
|----------|----------|
| [Installation](doc/installation.md) | Building, installing, running as a service |
| [Architecture](doc/architecture.md) | Component model, data flow, concurrency, back-pressure |
| [Configuration](doc/configuration.md) | TOML structure, precedence, environment and CLI overrides |
| [Sources](doc/sources.md) | Every input plugin and its options |
| [Sinks](doc/sinks.md) | Every output plugin and its options |
| [Filters](doc/filters.md) | Pattern-based inclusion and exclusion |
| [Formatters](doc/formatters.md) | Output shaping and sanitization |
| [Chaining](doc/chaining.md) | Multi-node topologies and the chain wire protocol |
| [Networking](doc/networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](doc/security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication](doc/mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [CLI](doc/cli.md) | Flags, signals, exit codes |
| [Operations](doc/operations.md) | Running, monitoring, tuning, troubleshooting |
A fully annotated configuration covering every option ships as - [Installation Guide](doc/installation.md) - Platform setup and service configuration
[`config/logwisp.toml`](config/logwisp.toml). - [Architecture Overview](doc/architecture.md) - System design and component interaction
- [Configuration Reference](doc/configuration.md) - TOML structure and configuration methods
- [Input Sources](doc/sources.md) - Available source types and configurations
- [Output Sinks](doc/sinks.md) - Sink types and output options
- [Filters](doc/filters.md) - Pattern-based log filtering
- [Formatters](doc/formatters.md) - Log formatting and transformation
- [Security](doc/security.md) - mTLS configurations and access control
- [Networking](doc/networking.md) - TLS, rate limiting, and network features
- [Command Line Interface](doc/cli.md) - CLI flags and subcommands
- [Operations Guide](doc/operations.md) - Running and maintaining LogWisp
## Quick Start ## Quick Start
```bash Install LogWisp and create a basic configuration:
make
```
```toml ```toml
# logwisp.toml
[[pipelines]] [[pipelines]]
name = "default" name = "default"
[pipelines.flow.format] [[pipelines.sources]]
type = "json" type = "directory"
sanitizer_policy = "json" [pipelines.sources.directory]
path = "./"
[[pipelines.plugin_sources]]
id = "app_logs"
type = "file"
[pipelines.plugin_sources.config]
directory = "/var/log/myapp"
pattern = "*.log" pattern = "*.log"
[[pipelines.plugin_sinks]] [[pipelines.sinks]]
id = "stdout"
type = "console" type = "console"
[pipelines.plugin_sinks.config] [pipelines.sinks.console]
target = "stdout" target = "stdout"
``` ```
```bash Run with: `logwisp -c config.toml`
logwisp -c logwisp.toml
```
Running with no configuration file starts a self-demonstrating pipeline: a
synthetic generator writing JSON to stdout.
## System Requirements ## System Requirements
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+) - **Operating Systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
- **Architecture**: amd64 - **Architecture**: amd64
- **Go**: 1.27.1+ to build from source - **Go Version**: 1.25+ (for building from source)
Network sources and sinks bind and dial over IPv4 only.
## License ## License
-367
View File
@@ -1,367 +0,0 @@
###############################################################################
### LogWisp Configuration
### Default location: ~/.config/logwisp/logwisp.toml
### Precedence: CLI flags > Environment > File > Defaults
###
### Commented values are the built-in defaults unless marked "example".
### Uncommenting a default is a no-op.
###
### NOTE: password/token/SCRAM authentication, network access control (ACL),
### http/tcp ingest sources, and http_client/tcp_client sinks were removed in
### the restructure and are not available in this version. TLS and mTLS ARE
### available on every network source and sink; see the [...tls] blocks below,
### and the [...auth] blocks to authorize peers by certificate identity.
###
### Environment overrides are currently read WITHOUT the LOGWISP_ prefix
### (QUIET, LOGGING_LEVEL, ...). LOGWISP_CONFIG_FILE and LOGWISP_CONFIG_DIR
### are the exceptions and do carry it. Array-indexed paths such as
### pipelines.0.name cannot be set from the CLI or environment at all.
###############################################################################
###############################################################################
### Global Settings
###############################################################################
quiet = false # Suppress console output
status_reporter = true # Periodic status logging (30s, DEBUG level)
auto_reload = false # Config auto-reload on file change
###############################################################################
### Logging (LogWisp's internal operational logging)
###############################################################################
[logging]
output = "stdout" # file|stdout|stderr|split|all|none
level = "info" # debug|info|warn|error
# format = "txt" # raw|txt|json
# sanitization = "" # raw|json|txt|shell (empty = logger default)
# [logging.file] # Used when output is "file" or "all"
# directory = "./log"
# name = "logwisp"
# max_size_mb = 100
# max_total_size_mb = 1000
# retention_hours = 168.0 # 7 days
## Validated but NOT applied: the console destination comes from `output` above.
# [logging.console]
# target = "stdout" # stdout|stderr|split
###############################################################################
### Pipelines
### Each pipeline: plugin_sources -> flow (rate_limit|filters|format) -> plugin_sinks
### Names must be unique. 1+ source and 1+ sink required.
###############################################################################
[[pipelines]]
name = "default"
###============================================================================
### Flow (processing between sources and sinks)
###============================================================================
## policy="pass" short-circuits the size check too: enforcing a size cap needs
## rate > 0 AND policy = "drop".
# [pipelines.flow.rate_limit]
# rate = 0.0 # Entries/second (0 = limiter disabled)
# burst = 0.0 # Burst capacity (defaults to rate)
# policy = "pass" # pass|drop
# max_entry_size_bytes = 0 # 0 = unlimited
## Filters: sequential include/exclude chain, matched against "<source> <level> <message>"
# [[pipelines.flow.filters]]
# type = "include" # include|exclude
# logic = "or" # or|and
# patterns = [".*ERROR.*", ".*WARN.*"] # example; RE2 syntax
# [pipelines.flow.format]
# type = "raw" # raw|json|txt
# flags = 0 # Formatter flags (0 = defaults per type)
# timestamp_format = "" # Go time layout (formatter default if empty)
# sanitizer_policy = "" # raw|json|txt|shell (defaults per type)
## Flow-level heartbeat (fan-out to all sinks, traverses chain links)
# [pipelines.flow.heartbeat]
# enabled = false
# interval_ms = 1000 # Minimum 100
# include_timestamp = false
# include_stats = false
# format = "txt" # txt|json|raw ("comment" is rejected)
###============================================================================
### TLS (shared shape; applies to every network source and sink)
###
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
### cert_file + key_file are REQUIRED; client_auth + client_ca_file enable mTLS.
### Dialers (tcp_chain/http_chain sinks):
### ca_file + server_name verify the server; cert_file + key_file present a
### client identity for mTLS.
###
### enabled = false Master switch
### cert_file = "" Local certificate
### key_file = "" Private key for cert_file (set together)
### client_auth = false Listener: require and verify a client certificate
### client_ca_file = "" Listener: CA bundle verifying client certs
### ca_file = "" Dialer: CA bundle verifying the server (empty = system)
### server_name = "" Dialer: SNI / name to verify (empty = configured host)
### insecure_skip_verify = false Dialer: disable verification (never in prod)
### min_version = "1.3" "1.2" or "1.3". No max_version, no cipher_suites.
###
### TLS alone is a CA membership check: ANY certificate the CA signed is
### accepted. Add an [...auth] block to decide WHICH of them may connect.
###============================================================================
###============================================================================
### AUTH (shared shape; sits beside [...tls] on every network source and sink)
###
### Listeners (tcp/http sinks, tcp_chain/http_chain sources):
### authorize the client certificate. type = "mtls" REQUIRES tls.enabled and
### tls.client_auth. On the http sink it gates stream_path AND status_path.
### Chain sources additionally bind the node label to the identity.
### Dialers (tcp_chain/http_chain sinks):
### pin the server identity. type = "mtls" REQUIRES tls.enabled and forbids
### tls.insecure_skip_verify.
###
### type = "none" none | mtls
### identity = "cn" cn | san_dns | san_uri | san_email
### allow = [] Exact identities. Empty allow AND allow_patterns
### admits any identity the CA vouches for (logged WARN).
### allow_patterns = [] RE2 patterns; anchor them yourself (^...$)
### node_binding = "" Chain sources only, default "force" under mtls:
### none - trust_node governs, as before
### assert - declared label must equal the identity;
### per-entry node labels still follow trust_node
### force - label AND every entry take the identity
### Overrides trust_node. See doc/security.md.
###============================================================================
###============================================================================
### Sources (1+ required)
###============================================================================
## Null source (testing)
# [[pipelines.plugin_sources]]
# id = "null_in"
# type = "null"
[[pipelines.plugin_sources]]
id = "default_source"
type = "file"
[pipelines.plugin_sources.config]
directory = "./" # Directory to monitor (required, not recursive)
pattern = "*.log" # Glob pattern (* and ? only)
## Tailing an already-open file polls at a fixed 100ms, regardless of this value.
check_interval_ms = 100 # Directory rescan interval (min 10)
## raw = true never parses a line; with format type "raw" the file is relayed byte for byte.
raw = false # Keep the whole line as the message
from = "end" # "end" or "start" of a newly discovered file
## Console source (stdin, single instance per pipeline)
# [[pipelines.plugin_sources]]
# id = "console_in"
# type = "console"
# [pipelines.plugin_sources.config]
# buffer_size = 1000
## Random source (testing; special=true exercises sanitizer policies)
# [[pipelines.plugin_sources]]
# id = "random_in"
# type = "random"
# [pipelines.plugin_sources.config]
# interval_ms = 500
# jitter_ms = 0 # Clamped to interval_ms
# format = "txt" # raw|txt|json
# length = 20
# special = false
## TCP chain source (stdlib listener; receives NDJSON from upstream tcp_chain sinks)
## Topology: jail [file source -> tcp_chain sink] -> host [tcp_chain source -> aggregate sink]
# [[pipelines.plugin_sources]]
# id = "chain_in"
# type = "tcp_chain"
# [pipelines.plugin_sources.config]
# host = "0.0.0.0" # IPv4 only
# port = 9440 # Required
# buffer_size = 1000
# max_connections = 0 # 0 = unlimited
# read_timeout_ms = 0 # idle deadline, 0 = none
# hello_timeout_ms = 10000 # Protocol preamble deadline
# trust_node = true # false: label entries by remote address
# [pipelines.plugin_sources.config.tls]
# enabled = true # example: mTLS listener
# cert_file = "/etc/logwisp/tls/server.crt"
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# min_version = "1.3"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
## HTTP chain source (stdlib listener; receives NDJSON batches from upstream http_chain sinks)
# [[pipelines.plugin_sources]]
# id = "hchain_in"
# type = "http_chain"
# [pipelines.plugin_sources.config]
# host = "0.0.0.0"
# port = 9441 # Required
# ingest_path = "/ingest" # Must start with "/"
# buffer_size = 1000
# max_body_bytes = 8388608 # per-request cap (8 MiB)
# read_timeout_ms = 30000
# trust_node = true # false: label entries by remote address
# [pipelines.plugin_sources.config.tls]
# enabled = true # example: mTLS listener
# cert_file = "/etc/logwisp/tls/server.crt"
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sources.config.auth] # authorize senders by certificate
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
# node_binding = "force" # none | assert | force; overrides trust_node
###============================================================================
### Sinks (1+ required, fan-out)
###============================================================================
## Null sink (testing)
# [[pipelines.plugin_sinks]]
# id = "null_out"
# type = "null"
[[pipelines.plugin_sinks]]
id = "default_sink"
type = "console"
[pipelines.plugin_sinks.config]
target = "stdout" # stdout|stderr ("split" NOT supported)
# buffer_size = 1000
## File sink (rotating)
# [[pipelines.plugin_sinks]]
# id = "file_out"
# type = "file"
# [pipelines.plugin_sinks.config]
# directory = "./logs" # Required
# name = "output" # Required
# max_size_mb = 100
# max_total_size_mb = 1000
# min_disk_free_mb = 0 # 0 = no floor (only negatives become 100)
# retention_hours = 168.0
# buffer_size = 1000
# flush_interval_ms = 100
## HTTP sink (SSE streaming server + JSON status endpoint; IPv4 clients only)
## Both endpoints are UNAUTHENTICATED and the stream sends
## Access-Control-Allow-Origin: *. Bind to a trusted interface.
# [[pipelines.plugin_sinks]]
# id = "http_out"
# type = "http"
# [pipelines.plugin_sinks.config]
# host = "0.0.0.0"
# port = 8081 # Required
# stream_path = "/stream" # Must start with "/"
# status_path = "/status" # Must differ from stream_path
# buffer_size = 1000 # Sink input queue
# client_buffer_size = 256 # Per-client send queue
# write_timeout_ms = 0 # Per-event deadline, 0 = none
# max_connections = 0 # 0 = unlimited
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt"
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = false
# client_ca_file = ""
# [pipelines.plugin_sinks.config.auth] # gates BOTH stream_path and status_path
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## TCP sink (streaming server, IPv4 clients only)
# [[pipelines.plugin_sinks]]
# id = "tcp_out"
# type = "tcp"
# [pipelines.plugin_sinks.config]
# host = "0.0.0.0"
# port = 9090 # Required
# buffer_size = 1000
# client_buffer_size = 256
# write_timeout_ms = 5000 # Missed deadline disconnects the client
# keep_alive = true
# keep_alive_period_ms = 30000
# max_connections = 0
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example
# cert_file = "/etc/logwisp/tls/server.crt"
# key_file = "/etc/logwisp/tls/server.key"
# client_auth = true
# client_ca_file = "/etc/logwisp/tls/client-ca.crt"
# [pipelines.plugin_sinks.config.auth] # authorize stream readers
# type = "none" # none | mtls; mtls requires client_auth
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## TCP chain sink (stdlib client; forwards to downstream tcp_chain source)
## Do NOT point a chain sink at a chain source in the SAME pipeline: entries
## loop back in and amplify without bound.
# [[pipelines.plugin_sinks]]
# id = "chain_out"
# type = "tcp_chain"
# [pipelines.plugin_sinks.config]
# host = "10.0.0.1" # Required
# port = 9440 # Required
# node = "" # origin label, default hostname; preserved across hops
# buffer_size = 1000
# dial_timeout_ms = 5000
# write_timeout_ms = 5000
# backoff_min_ms = 500
# backoff_max_ms = 30000
# keep_alive = true
# keep_alive_period_ms = 30000
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example: mTLS dialer
# ca_file = "/etc/logwisp/tls/ca.crt"
# server_name = ""
# insecure_skip_verify = false
# cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key"
# min_version = "1.3"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
## HTTP chain sink (stdlib client; batched NDJSON POST to downstream http_chain source)
# [[pipelines.plugin_sinks]]
# id = "hchain_out"
# type = "http_chain"
# [pipelines.plugin_sinks.config]
# host = "10.0.0.1" # Required
# port = 9441 # Required
# ingest_path = "/ingest"
# node = "" # origin label, default hostname; preserved across hops
# buffer_size = 1000
# max_batch_count = 100
# max_batch_bytes = 1048576
# flush_interval_ms = 1000
# request_timeout_ms = 10000 # Covers dial + write + response
# backoff_min_ms = 500
# backoff_max_ms = 30000
# [pipelines.plugin_sinks.config.tls]
# enabled = true # example: mTLS dialer
# ca_file = "/etc/logwisp/tls/ca.crt"
# cert_file = "/etc/logwisp/tls/client.crt"
# key_file = "/etc/logwisp/tls/client.key"
# [pipelines.plugin_sinks.config.auth] # pin the downstream server's identity
# type = "none" # none | mtls; mtls requires tls.enabled
# identity = "cn" # cn | san_dns | san_uri | san_email
# allow = [] # exact identities; empty = any the CA issued
# allow_patterns = [] # RE2, anchor them yourself
+41 -76
View File
@@ -1,108 +1,73 @@
# LogWisp Documentation # LogWisp
LogWisp is a pipeline-based log transport and processing system written in Go. A pipeline-based log transport and processing system built in Go. LogWisp provides flexible log collection, filtering, formatting, and distribution with security and reliability features.
It collects log entries from files, stdin, or other LogWisp nodes; rate-limits,
filters, and formats them; and distributes them to files, consoles, live network
streams, or downstream LogWisp nodes.
## Documentation Map ## Features
| Document | Contents | ### Core Capabilities
|----------|----------| - **Pipeline Architecture**: Independent processing pipelines with source(s) → filter → format → sink(s) flow
| [Installation](installation.md) | Building, installing, and running as a service | - **Multiple Input Sources**: File monitoring, console (stdin), random log generation, null
| [Architecture](architecture.md) | Component model, data flow, concurrency, back-pressure | - **Flexible Output Sinks**: Console, file, HTTP SSE, TCP streaming, null
| [Configuration](configuration.md) | TOML structure, precedence, environment and CLI overrides | - **Real-time Processing**: Sub-millisecond latency with configurable buffering
| [Sources](sources.md) | Every input plugin and its options | - **Hot Configuration Reload**: Update pipelines without service restart
| [Sinks](sinks.md) | Every output plugin and its options | - **Session Management**: Built-in session tracking for multiple client connections
| [Filters](filters.md) | Pattern-based inclusion and exclusion |
| [Formatters](formatters.md) | Output shaping and sanitization |
| [Chaining](chaining.md) | Multi-node topologies and the chain wire protocol |
| [Networking](networking.md) | Listeners, dialers, timeouts, connection limits |
| [Security](security.md) | TLS, mTLS, and peer authorization; threat model and current limits |
| [mTLS Authentication](mtls-auth-plan.md) | Design and rationale for certificate-based authorization |
| [CLI](cli.md) | Flags, signals, exit codes |
| [Operations](operations.md) | Running, monitoring, tuning, troubleshooting |
A fully annotated configuration covering every option lives at ### Data Processing
[`config/logwisp.toml`](../config/logwisp.toml). - **Pattern-based Filtering**: Chainable include/exclude filters with regex support
- **Multiple Formatters**: Raw, JSON, and text formatting with integrated sanitizer policies
- **Rate Limiting**: Pipeline rate controls
- **Heartbeat Generation**: Flow-level heartbeat events for keep-alives
## Capabilities ### Security & Reliability
- **File Rotation**: Size-based rotation with retention policies
- **Status Monitoring**: Real-time statistics and health endpoints
- **Signal Handling**: Graceful shutdown and configuration reload via signals
- **Background Mode**: Daemon operation with proper signal handling
- **Quiet Mode**: Silent operation for automated deployments
### Pipeline ## Documentation
- Independent named pipelines, each `sources → flow → sinks` - [Installation Guide](installation.md) - Platform setup and service configuration
- Fan-in (many sources per pipeline) and fan-out (many sinks per pipeline) - [Architecture Overview](architecture.md) - System design and component interaction
- Non-blocking sink dispatch: a stalled sink drops its own events and never - [Configuration Reference](configuration.md) - TOML structure and configuration methods
stalls the pipeline or its sibling sinks - [Input Sources](sources.md) - Available source types and configurations
- Hot reload of pipeline configuration via `SIGHUP`/`SIGUSR1` or a file watch - [Output Sinks](sinks.md) - Sink types and output options
- [Filters](filters.md) - Pattern-based log filtering
### Inputs - [Formatters](formatters.md) - Log formatting and transformation
- [Networking & Security](networking.md) - Network features (Note: TLS and Auth are currently placeholders in the new architecture)
`file` (directory tail with rotation detection), `console` (stdin), - [Command Line Interface](cli.md) - CLI flags and subcommands
`random` (synthetic generator), `null`, and the chain ingest listeners - [Operations Guide](operations.md) - Running and maintaining LogWisp
`tcp_chain` and `http_chain`.
### Outputs
`console`, `file` (rotating), `http` (Server-Sent Events plus a JSON status
endpoint), `tcp` (broadcast server), `null`, and the chain forwarders
`tcp_chain` and `http_chain`.
### Processing
- Token-bucket rate limiting with an optional per-entry size cap
- Chainable include/exclude regex filters with `or`/`and` logic
- `raw`, `txt`, and `json` formatting with selectable sanitizer policies
- Optional flow-level heartbeat entries
### Transport security and authentication
- TLS 1.2/1.3 on every network source and sink, listener and dialer alike
- Mutual TLS: listeners can require and verify client certificates; dialers can
present a client identity
- Authorization by certificate identity, per listener: named peers rather than
everything the CA issued, with the `http` sink's endpoints gated too
- Node binding, so a chain source labels entries from the sender's certificate
rather than from what the sender claims
See [Security](security.md) for what each layer does and does not give you.
## Quick Start ## Quick Start
Install LogWisp and create a basic configuration:
```toml ```toml
[[pipelines]] [[pipelines]]
name = "default" name = "default"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
id = "app_logs" id = "default_source"
type = "file" type = "file"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
directory = "/var/log/myapp" directory = "./"
pattern = "*.log" pattern = "*.log"
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "stdout" id = "default_sink"
type = "console" type = "console"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout"
``` ```
```bash Run with: `logwisp -c config.toml`
logwisp -c config.toml
```
## System Requirements ## System Requirements
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+) - **Operating Systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
- **Architecture**: amd64 - **Architecture**: amd64
- **Go**: 1.26+ to build from source - **Go Version**: 1.25+ (for building from source)
Network sources and sinks bind and dial over IPv4 only.
## License ## License
BSD 3-Clause. BSD 3-Clause License
+136 -163
View File
@@ -1,198 +1,171 @@
# Architecture Overview # Architecture Overview
LogWisp moves log entries through independent pipelines. Everything else — LogWisp implements a pipeline-based architecture for flexible log processing and distribution.
plugins, sessions, TLS, statistics — hangs off that spine.
## Component Hierarchy ## Core Concepts
### Pipeline Model
Each pipeline operates independently with a source → filter → format → sink flow. Multiple pipelines can run concurrently within a single LogWisp instance, each processing different log streams with unique configurations.
### Component Hierarchy
``` ```
main Service (Main Process)
── Service ── Pipeline 1
├── Pipeline "app" ├── Plugin Sources (1 or more)
│ ├── Registry instance tracking, single-instance enforcement │ ├── Flow
│ ├── Session Manager per-pipeline connection/session bookkeeping │ ├── Heartbeat Generator (optional)
│ ├── Sources[] plugin instances, keyed by id │ ├── Rate Limiter (optional)
│ ├── Flow │ ├── Filter Chain (optional)
│ │ ── Rate Limiter optional, token bucket │ │ ── Formatter (optional)
│ │ ├── Filter Chain optional, ordered │ └── Plugin Sinks (1 or more)
│ │ ├── Formatter raw | txt | json, with sanitizer ├── Pipeline 2
│ │ └── Heartbeat optional generator │ └── [Similar structure]
│ └── Sinks[] plugin instances, keyed by id └── Status Reporter (optional)
├── Pipeline "audit"
│ └── ...
└── Status Reporter optional, 30s interval
``` ```
Package map:
| Package | Responsibility |
|---------|----------------|
| `cmd/logwisp` | Entry point, help, logger bootstrap, signal loop, status reporter |
| `internal/config` | Typed config schema, loading, top-level validation |
| `internal/service` | Owns the pipeline set; start, stop, shutdown, global stats |
| `internal/pipeline` | Pipeline runtime and per-pipeline plugin registry |
| `internal/flow` | Rate limiter, filter chain invocation, formatting, heartbeat |
| `internal/filter` | Regex filter and filter chain |
| `internal/format` | Adapter over `lixenwraith/log` formatter + sanitizer |
| `internal/source/*` | Source plugins |
| `internal/sink/*` | Sink plugins |
| `internal/plugin` | Global factory registry populated by plugin `init()` |
| `internal/chain` | Chain wire protocol: hello preamble, entry codec, backoff |
| `internal/tlsx` | The single seam between `TLSOptions` and `crypto/tls` |
| `internal/session` | Session manager and per-instance proxy |
| `internal/core` | Shared types (`LogEntry`, `TransportEvent`), capabilities, constants |
| `internal/tokenbucket` | Rate limiter primitive |
| `internal/sanitize` | Standalone hex-escaping helpers |
## Plugin Registration
Every plugin registers itself in an `init()` function, and
`cmd/logwisp/bootstrap.go` blank-imports each package to trigger those
`init()`s. Adding a plugin therefore means writing the package, calling
`plugin.RegisterSource` / `plugin.RegisterSink`, and adding one blank import.
Registration may attach metadata. The `console` source declares
`MaxInstances: 1`, because a process has only one stdin; the per-pipeline
registry rejects a second instance of any such type.
## Data Flow ## Data Flow
### Entry lifecycle ### Processing Stages
1. **Source** produces a `core.LogEntry` and publishes it to every subscriber 1. **Source Stage**: Plugin sources monitor inputs and generate log entries
channel it has handed out. Publication is non-blocking: a full subscriber 2. **Flow - Rate Limiting**: Optional pipeline-level rate control
channel increments the source's `dropped_entries` counter. 3. **Flow - Filtering**: Pattern-based inclusion/exclusion
2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at 4. **Flow - Formatting**: Transform entries to desired output format with sanitization
any stage ends the entry's life and increments `flow.total_dropped`. 5. **Distribution**: Fan-out to multiple plugin sinks
3. The formatter output becomes a `core.TransportEvent`, which carries both the
formatted `Payload` and the original structured `Entry`.
4. **Dispatch** sends the event to every sink's input channel with a
non-blocking send.
`LogEntry` fields: ### Entry Lifecycle
| Field | Purpose | Log entries flow through the pipeline as `core.LogEntry` structures containing:
|-------|---------| - **Time**: Entry timestamp
| `Time` | Entry timestamp | - **Level**: Log level (DEBUG, INFO, WARN, ERROR)
| `Node` | Origin node label for chained topologies; stamped at the first hop, preserved by relays | - **Source**: Origin identifier
| `Source` | Origin identifier within the node (filename, plugin id, …) | - **Message**: Log content
| `Level` | `DEBUG`/`INFO`/`WARN`/`ERROR`/`TRACE`, when detected | - **Fields**: Additional metadata (JSON)
| `Message` | Log content | - **RawSize**: Original entry size
| `Fields` | Optional structured metadata as raw JSON |
| `RawSize` | Original byte size, used by the entry-size cap |
Carrying `Entry` alongside `Payload` is what makes chain sinks ### Buffering Strategy
format-independent: a `tcp_chain` or `http_chain` sink re-serializes the
structured entry rather than shipping whatever text the local formatter chose.
### Back-pressure and drops Each component maintains internal buffers to handle burst traffic:
- Sources: Configurable buffer size (default 1000 entries)
- Sinks: Independent buffers per sink
- Network components: Additional TCP/HTTP buffers
There is exactly one drop policy and it is not configurable: **never block**. *"Sink dispatch uses non-blocking sends. When a sink's input buffer is full, the event is dropped for that sink only and counted in pipeline statistics (`total_dropped_by_sink`). The policy is uniform and not configurable; a slow sink does not stall the pipeline or other sinks."*
| Stage | Full-buffer behaviour | Counter | ## Component Types
|-------|----------------------|---------|
| Source → subscriber | Drop the entry | source `dropped_entries` |
| Flow | Drop on rate limit, filter, or format error | `flow.total_dropped` |
| Pipeline → sink | Drop for that sink only | pipeline `total_dropped_by_sink` |
| TCP/HTTP sink → client queue | Drop for that client only | sink `dropped_writes` |
The `tcp_chain` sink is the one deliberate exception. It holds a line across ### Sources (Input)
reconnects until it is written or the process shuts down, so a downstream
outage propagates backwards as a full input buffer and surfaces as - **File Source**: File system directory monitoring with rotation detection
`total_dropped_by_sink` on the pipeline rather than as silent data loss inside - **Console Source**: Standard input processing (stdin)
the sink. The `http_chain` sink retries a batch with backoff, and drops it only - **Random Source**: Generates random log entries for testing
on a non-retryable response or on shutdown (`dropped_batches`). - **Null Source**: Discards logs, used for testing
### Sinks (Output)
- **Console Sink**: stdout/stderr output
- **File Sink**: Rotating file writer
- **HTTP Sink**: Server-Sent Events (SSE) streaming
- **TCP Sink**: TCP server for client connections
- **Null Sink**: Discards all received events
### Processing Components
- **Rate Limiter**: Token bucket algorithm for flow control
- **Filter Chain**: Sequential pattern matching
- **Formatters**: Raw, JSON, or text transformation with sanitizer policies
## Concurrency Model ## Concurrency Model
- One goroutine per source drains that source's subscription and feeds the flow. ### Goroutine Architecture
- The flow's formatter holds a mutex; the underlying formatter reuses an
internal buffer and is not goroutine-safe.
- Each network sink runs one broadcast/broker goroutine plus, per connection, a
writer goroutine (and for TCP, a reader goroutine that exists only to detect
disconnects and refresh session activity).
- Chain sinks run a single run-loop goroutine that exclusively owns the
connection or the pending batch, so no locking is needed around either.
- Statistics are atomics; configuration and registries use RW mutexes;
shutdown is context cancellation plus wait groups.
### Shutdown ordering - Each source runs in dedicated goroutines for monitoring
- Sinks operate independently with their own processing loops
- Network listeners use optimized event loops (gnet for TCP)
- Pipeline processing uses channel-based communication
`Pipeline.Stop` is deliberately ordered so in-flight data drains: ### Synchronization
1. Stop all sources concurrently; each closes its subscriber channels. - Atomic counters for statistics
2. Wait for the run loop, which ends when every subscription channel closes. - Read-write mutexes for configuration access
3. Stop all sinks concurrently. - Context-based cancellation for graceful shutdown
- Wait groups for coordinated startup/shutdown
## Network Architecture ## Network Architecture
All listeners bind `tcp4` and all dialers dial `tcp4`. IPv6 clients cannot ### Connection Patterns
connect; this is deliberate, not an oversight.
| Plugin | Role | Protocol | **Chaining Design**:
|--------|------|----------| - Future plan
| `tcp` sink | Listener | Raw broadcast of formatted payloads |
| `http` sink | Listener | HTTP/1.1 SSE; HTTP/2 negotiated via ALPN when TLS is on |
| `tcp_chain` source | Listener | Chain protocol, persistent NDJSON stream |
| `http_chain` source | Listener | Chain protocol, NDJSON batches over POST |
| `tcp_chain` sink | Dialer | Chain protocol, persistent stream, auto-reconnect |
| `http_chain` sink | Dialer | Chain protocol, batched POST with retry |
TLS is built in exactly one place, `internal/tlsx`, which exposes **Monitoring Design**:
`Server(opts)` for listeners and `Client(opts, host)` for dialers. See - TCP Sink: Debugging interface
[Security](security.md). - HTTP Sink: Browser-based live monitoring
## Sessions ### Protocol Support
Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy` - HTTP/1.1 and HTTP/2 for HTTP connections
scoped to their instance id, so one plugin cannot see or remove another's - Raw TCP connections
sessions. A session records the remote address, creation and last-activity
timestamps, and metadata — including `tls` and `tls_peer_cn` for TLS peers, and
`auth_method` / `auth_identity` for authorized ones.
Idle sessions are reaped every 5 minutes against a 30-minute idle limit. The
HTTP sink's broker treats a vanished session as an eviction signal and closes
the corresponding SSE client.
Authorization decisions do not read session metadata — they are made from the
handshake by `internal/authz`, at the point of connection or request, and their
outcome is *recorded* in the session. That ordering matters: a session exists
only for a peer that was already admitted. See the
[mTLS authentication design](mtls-auth-plan.md).
## Configuration Reload
Reload (signal or file watch) rebuilds the entire service:
1. Re-read the config through the config manager.
2. Build a **new** service from it. If construction fails, the old service keeps
running untouched.
3. Shut the old service down, start the new one, and restart the status
reporter if it is enabled.
Because this is a full rebuild, listening sockets close and reopen and all
clients are disconnected. Application logging is configured once at startup and
is **not** re-applied on reload.
## Resource Management ## Resource Management
- Every buffer is bounded; the drop-not-block policy keeps memory flat under ### Memory Management
load.
- Network sinks and chain sources accept a `max_connections` cap. Admission is
a load-then-check, so a burst can over-admit by roughly one connection.
- Chain listeners bound a single line at `core.MaxLogEntryBytes` (1 MiB); an
oversized line is a protocol violation and terminates the connection.
- The `http_chain` source caps each request body at `max_body_bytes`.
- File sinks rotate on size, cap total rotated size, and honour a retention
window.
## Performance Notes - Bounded buffers prevent unbounded growth
- Automatic garbage collection via Go runtime
- Connection limits prevent resource exhaustion
- In-memory entry processing is sub-millisecond; the formatter mutex is the ### File Management
only shared serialization point in the hot path.
- File tailing detects new content within roughly 100 ms (fixed poll), while - Automatic rotation based on size thresholds
`check_interval_ms` governs how quickly a *newly created* file is noticed. - Retention policies for old log files
- `http_chain` trades latency for efficiency: entries wait up to - Minimum disk space checks before writing
`flush_interval_ms` (default 1 s) before a batch is sent.
- Scale out with more pipelines per process, more sinks per pipeline, or more ### Connection Management
nodes chained together.
- HTTP sink silenty drops IPv6 connections (deliberate IPv4-only enforcement)
*Note:* Placeholder; below features are removed in restructuring and will be added in the future release.
- Per-IP connection limits
- Global connection caps
- Automatic reconnection with exponential backoff
- Keep-alive for persistent connections
## Reliability Features
### Fault Tolerance
- Panic recovery in pipeline processing
- Independent pipeline operation
- Sink failure isolation
### Data Integrity
- Entry validation at ingestion
- Size limits for entries and batches
- Duplicate detection in file monitoring
- Position tracking for file reads
## Performance Characteristics
### Throughput
- Pipeline rate limiting: Configurable (default 1000 entries/second)
- Network throughput: Limited by network and sink capacity
- File monitoring: Sub-second detection (default 100ms interval)
### Latency
- Entry processing: Sub-millisecond in-memory
- Network forwarding: Depends on batch configuration
- File detection: Configurable check interval
### Scalability
- Horizontal: Multiple LogWisp instances with different configurations
- Vertical: Multiple pipelines per instance
- Fan-out: Multiple sinks per pipeline
- Fan-in: Multiple sources per pipeline
-236
View File
@@ -1,236 +0,0 @@
# Chaining
Chaining links LogWisp nodes together. An edge node forwards its entries to a
relay or collector, which can filter, reformat, fan out, or forward them again.
Unlike the `tcp` and `http` sinks — which emit *formatted text* for humans and
generic clients — chain links carry the **structured entry**, so downstream
nodes can filter and reformat as if the entries were local.
## Topology
```
edge-01 relay consumers
┌───────────────┐ ┌────────────────────┐ ┌──────────────┐
│ file source │ │ tcp_chain source │ │ browser (SSE)│
│ ↓ │ TCP/TLS │ ↓ │ ───► │ nc / telnet │
│ tcp_chain sink├────────────►│ flow │ │ archive file │
└───────────────┘ :15801 │ ↓ │ └──────────────┘
│ http sink, tcp sink│
edge-02 │ file sink │
┌───────────────┐ │ http_chain sink ───┼──► upstream collector
│ file source │ HTTP/TLS │ │
│ ↓ ├────────────►│ http_chain source │
│ http_chain sink│ :15802 └────────────────────┘
└───────────────┘
```
Both chain sources can feed a single pipeline (fan-in) whose sinks then fan the
merged stream out. `test/chain-test.sh` builds the two-independent-pipelines
variant; `test/chain-aggregate-test.sh` builds the fan-in variant.
## Node Identity
Chained entries carry a `node` label identifying where they originated.
- A chain **sink** stamps `node` on any entry that does not already have one.
The label comes from the `node` option, defaulting to `os.Hostname()`.
- A chain **source** either honours the sender's label or overrides it,
according to `trust_node`:
| `trust_node` | Behaviour |
|--------------|-----------|
| `true` (default) | Keep the label the sender declared; fall back to the remote address when absent |
| `false` | Always overwrite with the sender's remote address |
Relays preserve `node`, so a label survives any number of hops and identifies
the original producer rather than the last relay.
Under mTLS the source can instead bind the label to the sender's certificate,
which overrides `trust_node` entirely:
| `auth.node_binding` | Connection label | Per-entry `node` field |
|---------------------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the certificate identity, or the peer is rejected | `trust_node` governs |
| `force` (default under `mtls`) | The certificate identity | Overwritten with the identity |
Pick `force` at an ingest boundary you do not trust — it is the only setting
where a compromised edge cannot mislabel its entries, including through the
per-entry `node` field. Pick `assert` on a relay-to-relay hop, where the relay
should prove its own identity but the origin labels it forwards must survive.
See [Security](security.md#node-binding).
Formatters render node identity as a syslog-style prefix on the source field:
`edge-01/app.log`. In JSON output the node therefore appears inside the source
field, not as a separate top-level key.
> `trust_node = true` with no `auth` block means any peer the CA vouches for can
> claim **any** node label, including one belonging to another host. On an
> untrusted network set `auth.type = "mtls"` with `node_binding = "force"`;
> `trust_node = false` is the fallback when certificates are not an option.
## Wire Protocol
Protocol version: **1**. Both transports carry the same canonical entry
encoding, and differ only in how the preamble and framing are expressed.
### TCP transport
A persistent connection carrying newline-delimited JSON.
1. The dialer connects and, under TLS, completes the handshake.
2. The dialer immediately writes the hello preamble as one JSON line:
```json
{"logwisp":1,"node":"edge-01"}
```
3. The listener reads that line within `hello_timeout_ms` and rejects the
connection if it is malformed or declares a different protocol version.
4. Every subsequent line is one JSON-encoded `LogEntry`.
Line size is bounded at 1 MiB. An oversized line is a protocol violation and
terminates the connection, because the scanner cannot resynchronize afterwards.
### HTTP transport
Batches of NDJSON delivered by `POST`, with the preamble expressed as headers.
| Header | Direction | Meaning |
|--------|-----------|---------|
| `X-Logwisp-Protocol` | request | Protocol version; must be `1` |
| `X-Logwisp-Node` | request | Origin node label |
| `Content-Type` | request | `application/x-ndjson` |
| `X-Logwisp-Accepted` | response | Number of entries ingested |
Responses: `204` on success, `400` for a bad protocol version or a malformed
body, `413` when the body cap is exceeded, `405` for a non-`POST` method.
### Entry encoding
```json
{
"time": "2026-01-02T15:04:05.123456789Z",
"node": "edge-01",
"source": "app.log",
"level": "ERROR",
"message": "connection refused",
"fields": {"attempt": 3}
}
```
`node`, `level`, and `fields` are omitted when empty. A missing `time` is filled
in at ingest.
## Delivery Semantics
| Transport | Guarantee | Failure behaviour |
|-----------|-----------|-------------------|
| `tcp_chain` | Per-line, held across reconnects | Retries with exponential backoff plus ±20 % jitter until written or shutdown; back-pressure appears upstream as `total_dropped_by_sink` |
| `http_chain` | At-least-once per batch | Retries transport errors, `408`, `429`, `5xx`; drops on any other non-2xx (`dropped_batches`) |
`http_chain` batches can be delivered twice when a successful request's response
is lost. There is no de-duplication downstream; design your consumers to
tolerate it, or use `tcp_chain` where each line is written once per successful
write.
Neither transport persists anything to disk. Entries buffered in memory during
an outage are lost if the process exits.
## Worked Example
**Edge node** — tail files, forward over mTLS:
```toml
[[pipelines]]
name = "edge"
[[pipelines.plugin_sources]]
id = "app"
type = "file"
[pipelines.plugin_sources.config]
directory = "/var/log/myapp"
pattern = "*.log"
[[pipelines.plugin_sinks]]
id = "forward"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "relay.internal"
port = 15801
node = "edge-01"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/edge-01.crt"
key_file = "/etc/logwisp/tls/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"] # pin the relay, not just its hostname
```
**Relay** — ingest, keep errors only, archive and stream:
```toml
[[pipelines]]
name = "relay"
[[pipelines.flow.filters]]
type = "include"
patterns = ["ERROR", "FATAL"]
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "ingest"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "0.0.0.0"
port = 15801
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/relay.crt"
key_file = "/etc/logwisp/tls/relay.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force" # entries are labelled from the certificate
[[pipelines.plugin_sinks]]
id = "archive"
type = "file"
[pipelines.plugin_sinks.config]
directory = "/var/log/logwisp"
name = "errors"
[[pipelines.plugin_sinks]]
id = "live"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = 8080
```
Entries arriving on this relay are labelled `edge-01` or `edge-02` because that
is what their certificates say, regardless of the `node` each edge configured.
`test/mtls-chain-test.sh` builds exactly this shape against a throwaway PKI.
## Operational Notes
- **Formatting is a relay decision.** Because chain links carry structured
entries, the edge node's `flow.format` affects only its own local sinks. Set
the output shape on the node that owns the human-facing sink.
- **Filtering early saves bandwidth.** A filter on the edge drops entries before
they cross the network; a filter on the relay is easier to change centrally.
- **Rate limits are per pipeline.** An edge limit protects the link; a relay
limit protects the relay from a noisy edge.
- **Heartbeats traverse chain links** as ordinary structured entries and keep
otherwise-idle links and their sessions warm.
- **Ports** used by the bundled test scripts: `15801` tcp_chain ingest, `15802`
http_chain ingest, `15803` tcp sink, `15804` http sink.
- **Use `127.0.0.1`, not `localhost`**, when testing locally: all listeners and
dialers are IPv4-only, and `localhost` may resolve to `::1`.
+141 -129
View File
@@ -1,184 +1,196 @@
# Command Line Interface # Command Line Interface
``` LogWisp CLI reference for commands and options.
## Synopsis
```bash
logwisp [command] [options]
logwisp [options] logwisp [options]
logwisp help | -h | --help ```
## Commands
### Main Commands
| Command | Description |
|---------|-------------|
| `--version` | Display version information |
| `--help` | Show help information |
### version Command
Display version information.
```bash
logwisp version
logwisp -v
logwisp --version logwisp --version
``` ```
LogWisp has no subcommands. Earlier releases shipped `logwisp auth` and Output includes:
`logwisp tls` for credential and certificate generation; both were removed - Version number
during the restructure. Use `openssl` or your PKI tooling instead — see - Build date
[Security](security.md). - Git commit hash
- Go version
## Options ## Global Options
Any scalar configuration key is settable as a flag using its TOML path: ### Configuration Options
```
--<path>=<value> e.g. --logging.level=debug
--<path> <value> e.g. --logging.level debug
--<path> bare flag, means true
```
### Common
| Flag | Description | Default | | Flag | Description | Default |
|------|-------------|---------| |------|-------------|---------|
| `-c <path>` | Configuration file | `./logwisp.toml` | | `-c, --config` | Configuration file path | `./logwisp.toml` |
| `--config=<path>` | Configuration file (equals form only) | `./logwisp.toml` | | `-q, --quiet` | Suppress console output | false |
| `--quiet` | Suppress all application output | `false` | | `--status-reporter` | Status logging | true |
| `--status_reporter=<bool>` | Periodic status logging | `true` | | `--auto-reload` | Enable config hot reload | false |
| `--auto_reload=<bool>` | Reload config when the file changes | `false` |
| `--version` | Print version and exit | — |
| `-h`, `--help`, `help` | Print usage and exit | — |
> `--config <path>` with a space is not recognized. The path resolver ### Logging Options
> understands `-c <path>` and `--config=<path>` only; the space form is treated
> as an unknown flag, warned about, and ignored, after which LogWisp silently
> falls back to `./logwisp.toml`.
>
> `-c` as the final argument, with no path after it, crashes with an index
> panic rather than reporting a usage error.
### Logging | Flag | Description | Values |
|------|-------------|--------|
| `--logging.output` | Log output mode | file, stdout, stderr, split, all, none |
| `--logging.level` | Log level | debug, info, warn, error |
| `--logging.file.directory` | Log directory | Path |
| `--logging.file.name` | Log filename | String |
| `--logging.file.max_size_mb` | Max file size | Integer |
| `--logging.file.max_total_size_mb` | Total size limit | Integer |
| `--logging.file.retention_hours` | Retention period | Float |
| `--logging.console.target` | Console target | stdout, stderr, split |
| `--logging.console.format` | Output format | txt, json |
| Flag | Values | ### Pipeline Options
|------|--------|
| `--logging.output` | `file`, `stdout`, `stderr`, `split`, `all`, `none` |
| `--logging.level` | `debug`, `info`, `warn`, `error` |
| `--logging.format` | `raw`, `txt`, `json` |
| `--logging.sanitization` | `raw`, `json`, `txt`, `shell` |
| `--logging.file.directory` | path |
| `--logging.file.name` | string |
| `--logging.file.max_size_mb` | integer |
| `--logging.file.max_total_size_mb` | integer |
| `--logging.file.retention_hours` | float |
`--logging.console.target` is accepted but has no effect; the console Configure pipelines via CLI (N = array index, 0-based).
destination is derived from `--logging.output`.
### Pipelines **Pipeline Configuration:**
Pipelines, sources, sinks, and filters **cannot** be configured from the command | Flag | Description |
line. Array-indexed paths such as `--pipelines.0.name=app` or |------|-------------|
`--pipelines.0.plugin_sinks.0.type=null` are reported as unrecognized and | `--pipelines.N.name` | Pipeline name |
ignored: | `--pipelines.N.plugin_sources.N.type` | Source type |
| `--pipelines.N.flow.filters.N.type` | Filter type |
| `--pipelines.N.plugin_sinks.N.type` | Sink type |
``` ## Flag Formats
Warning: unrecognized flags ignored: [pipelines.0.name]
### Boolean Flags
```bash
logwisp --quiet
logwisp --quiet=true
logwisp --pipelines.0.plugin_sources.0.type=console
``` ```
Use a configuration file. Older documentation described CLI pipeline overrides ### String Flags
that the current loader does not implement.
```bash
logwisp --config /etc/logwisp/config.toml
logwisp -c config.toml
```
### Nested Configuration
```bash
logwisp --logging.level=debug
logwisp --pipelines.0.name=myapp
logwisp --pipelines.0.sources.0.type=console
```
### Array Values (JSON)
```bash
logwisp --pipelines.0.flow.filters.0.patterns='["ERROR","WARN"]'
```
## Environment Variables ## Environment Variables
Configuration paths map to environment variables by replacing `.` with `_` and All flags can be set via environment:
uppercasing:
```bash ```bash
export QUIET=true export LOGWISP_QUIET=true
export LOGGING_LEVEL=debug export LOGWISP_LOGGING_LEVEL=debug
export LOGGING_FILE_DIRECTORY=/var/log/logwisp export LOGWISP_PIPELINES_0_NAME=myapp
``` ```
> The `LOGWISP_` prefix is **not** currently applied to these — see ## Configuration Precedence
> [Configuration](configuration.md#environment-variables). Bare names like
> `QUIET` are what LogWisp actually reads, which is worth knowing both to make
> overrides work and to avoid accidental collisions.
The two variables that do carry the prefix are read directly by the path 1. Command-line flags (highest)
resolver:
| Variable | Effect |
|----------|--------|
| `LOGWISP_CONFIG_FILE` | Configuration file path; joined onto `LOGWISP_CONFIG_DIR` when both are set |
| `LOGWISP_CONFIG_DIR` | Configuration directory; alone, implies `<dir>/logwisp.toml` |
As with flags, array elements cannot be set this way.
## Precedence
1. Command-line flags
2. Environment variables 2. Environment variables
3. Configuration file 3. Configuration file
4. Built-in defaults 4. Built-in defaults (lowest)
## Signals
| Signal | Action |
|--------|--------|
| `SIGINT` | Graceful shutdown |
| `SIGTERM` | Graceful shutdown |
| `SIGHUP` | Reload configuration |
| `SIGUSR1` | Reload configuration |
`SIGHUP` is ignored during startup, before the signal handler is installed, so
LogWisp survives a terminal hang-up like `nohup`. Once running, it triggers a
reload rather than terminating.
Reload rebuilds the whole service. A configuration error leaves the running
service untouched; see [Configuration](configuration.md#hot-reload).
## Exit Codes ## Exit Codes
| Code | Meaning | | Code | Description |
|------|---------| |------|-------------|
| `0` | Clean shutdown, or `--version` / `--help` | | 0 | Success |
| `1` | General error: config load or validation failure, logger init failure, service bootstrap failure | | 1 | General error |
| `2` | Explicitly requested configuration file not found | | 2 | Configuration file not found |
| 137 | SIGKILL received |
Exit code 2 applies only when the file was named explicitly (`-c`, ## Signal Handling
`--config=`, or the `LOGWISP_CONFIG_*` variables). A missing discovered default
is not an error, and LogWisp starts on built-in defaults.
## Built-in Defaults | Signal | Action |
|--------|--------|
With no configuration file present, LogWisp runs one pipeline named | SIGINT (Ctrl+C) | Graceful shutdown |
`default_pipeline`: a `random` source with `special = true`, JSON formatting, | SIGTERM | Graceful shutdown |
a rate limit of 5 entries/second with a burst of 10 and `policy = "drop"`, and a | SIGHUP | Reload configuration |
`console` sink on stdout. It is a self-demonstrating idle mode, not a useful | SIGUSR1 | Reload configuration |
production configuration. | SIGKILL | Immediate termination |
Note that as soon as your file defines `[[pipelines]]`, that entire default
pipeline — rate limit included — is replaced rather than merged.
## Usage Patterns ## Usage Patterns
**Development** ### Development Mode
```bash ```bash
# verbose, everything to stderr # Verbose logging to console
logwisp -c dev.toml --logging.output=stderr --logging.level=debug logwisp --logging.output=stderr --logging.level=debug
# no config at all: synthetic generator to stdout # Quick test with stdin
logwisp logwisp --pipelines.0.plugin_sources.0.type=console --pipelines.0.plugin_sinks.0.type=console
``` ```
**Configuration check** ### Production Deployment
```bash ```bash
# starts the service; a config error exits non-zero before any pipeline runs # Background with file logging
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug logwisp --background --config /etc/logwisp/prod.toml --logging.output=file
# Systemd service
ExecStart=/usr/local/bin/logwisp --config /etc/logwisp/config.toml
``` ```
There is no dry-run or validate-only mode. The closest approximation is starting ### Debugging
with debug logging and stopping once the pipelines report as started.
**Production**
```bash ```bash
logwisp -c /etc/logwisp/logwisp.toml --logging.output=file # Check configuration
logwisp --config test.toml --logging.level=debug --disable-status-reporter
# Dry run (verify config only)
logwisp --config test.toml --quiet
``` ```
Run under a supervisor (systemd, rc.d) rather than backgrounding it — there is ## Help System
no `--background` flag; earlier releases had one and it was removed. See
[Installation](installation.md).
**Reload** ### General Help
```bash ```bash
kill -HUP $(pidof logwisp) logwisp --help
kill -USR1 $(pidof logwisp) logwisp -h
logwisp help
``` ```
## Special Flags
### Internal Flags
These flags are for internal use:
- `--background-daemon`: Child process indicator
- `--config-save-on-exit`: Save config on shutdown
### Hidden Behaviors
- SIGHUP ignored ignored during startup (after startup triggers config reload)
- Automatic panic recovery in pipelines
- Resource cleanup on shutdown
+130 -212
View File
@@ -1,64 +1,41 @@
# Configuration Reference # Configuration Reference
LogWisp is configured with TOML. A complete annotated file listing every option LogWisp configuration uses TOML format with flexible override mechanisms.
and its default ships as [`config/logwisp.toml`](../config/logwisp.toml).
## Configuration Precedence ## Configuration Precedence
Sources are merged in this order, highest priority first: Configuration sources are evaluated in order:
1. **Command-line flags** (highest priority)
1. Command-line flags 2. **Environment variables**
2. Environment variables 3. **Configuration file**
3. Configuration file 4. **Built-in defaults** (lowest priority)
4. Built-in defaults
The `pipelines` array is replaced wholesale, not merged: as soon as your file
defines `[[pipelines]]`, the built-in default pipeline (and its default rate
limit and formatter) disappears entirely.
## File Location ## File Location
The path is resolved before any other configuration is read: LogWisp searches for configuration in order:
1. Path specified via `--config` flag
1. `-c <path>` on the command line 2. Path from `LOGWISP_CONFIG_FILE` environment variable
2. `--config=<path>` on the command line 3. `~/.config/logwisp/logwisp.toml`
3. `$LOGWISP_CONFIG_FILE`, joined onto `$LOGWISP_CONFIG_DIR` when both are set 4. `./logwisp.toml` in current directory
4. `$LOGWISP_CONFIG_DIR/logwisp.toml`
5. `~/.config/logwisp/logwisp.toml`, if it exists
6. `./logwisp.toml`
Missing file behaviour differs by how it was chosen. An explicitly requested
file that does not exist is a fatal error (exit code 2); a missing discovered
default is not an error, and LogWisp starts on built-in defaults.
> `--config <path>` with a space is **not** recognized as a config path. It is
> parsed as an unknown flag, warned about, and ignored — LogWisp then silently
> falls back to `./logwisp.toml`. Use `-c <path>` or `--config=<path>`.
## Global Settings ## Global Settings
Top-level configuration options:
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---------|------|---------|-------------| |---------|------|---------|-------------|
| `quiet` | bool | `false` | Disable all application logging and console diagnostics | | `quiet` | bool | false | Suppress console output |
| `status_reporter` | bool | `true` | Emit a periodic status report every 30 s at DEBUG level | | `status_reporter` | bool | true | Periodic status logging |
| `auto_reload` | bool | `false` | Watch the config file and reload pipelines on change | | `auto_reload` | bool | false | Enable file watch for auto-reload |
`--version` prints version information and exits; it is not a persistent ## Logging Configuration
setting.
Note that `status_reporter` writes at DEBUG level, so it produces nothing unless LogWisp's internal operational logging:
`logging.level = "debug"`.
## Application Logging
This configures LogWisp's own operational log, not the log data it transports.
```toml ```toml
[logging] [logging]
output = "stdout" # file | stdout | stderr | split | all | none output = "stdout" # file|stdout|stderr|split|all|none
level = "info" # debug | info | warn | error level = "info" # debug|info|warn|error
format = "txt" # raw | txt | json
# sanitization = "" # raw | json | txt | shell
[logging.file] [logging.file]
directory = "./log" directory = "./log"
@@ -66,220 +43,161 @@ name = "logwisp"
max_size_mb = 100 max_size_mb = 100
max_total_size_mb = 1000 max_total_size_mb = 1000
retention_hours = 168.0 retention_hours = 168.0
[logging.console]
target = "stdout" # stdout|stderr|split
``` ```
### Output modes ### Output Modes
| Mode | Behaviour | - **file**: Write to log files only
|------|-----------| - **stdout**: Write to standard output
| `file` | Files only | - **stderr**: Write to standard error
| `stdout` | Standard output only | - **split**: INFO/DEBUG to stdout, WARN/ERROR to stderr
| `stderr` | Standard error only | - **all**: Write to both file and console
| `split` | DEBUG/INFO to stdout, WARN/ERROR to stderr | - **none**: Disable all logging
| `all` | Files plus split console |
| `none` | No application logging |
`[logging.file]` applies only to the `file` and `all` modes.
> `[logging.console].target` is accepted and validated (`stdout`, `stderr`,
> `split`) but **not applied**. The console destination is derived from
> `logging.output`. The key is retained for compatibility; setting it has no
> effect.
`quiet = true` overrides every logging setting and disables both file and
console output.
## Pipeline Configuration ## Pipeline Configuration
Each `[[pipelines]]` section defines an independent processing pipeline:
```toml ```toml
[[pipelines]] [[pipelines]]
name = "app" # required, unique across pipelines name = "pipeline-name"
# --- flow: everything between sources and sinks --- # Rate limiting (optional)
[pipelines.flow.rate_limit] [pipelines.flow.rate_limit]
rate = 1000.0 rate = 1000.0
burst = 2000.0 burst = 2000.0
policy = "drop" policy = "drop" # pass|drop
max_entry_size_bytes = 65536 max_entry_size_bytes = 0 # 0=unlimited
# Format configuration (optional)
[pipelines.flow.format]
type = "json" # raw|json|txt
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "my_source"
type = "file"
[pipelines.plugin_sources.config]
# ... source-specific config
# Filters (optional)
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
logic = "or" logic = "or"
patterns = ["ERROR", "WARN"] patterns = ["ERROR", "WARN"]
[pipelines.flow.format] # Sinks (required, 1+)
type = "json"
sanitizer_policy = "json"
[pipelines.flow.heartbeat]
enabled = true
interval_ms = 30000
# --- sources: one or more ---
[[pipelines.plugin_sources]]
id = "app_logs" # unique within the pipeline
type = "file"
[pipelines.plugin_sources.config]
directory = "/var/log/myapp"
# --- sinks: one or more ---
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "sse" id = "my_sink"
type = "http" type = "http"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
port = 8080 # ... sink-specific config
``` ```
Every source and sink is a plugin instance with three keys:
| Key | Meaning |
|-----|---------|
| `id` | Instance identifier, unique within the pipeline; appears in logs and stats |
| `type` | Registered plugin type |
| `config` | Plugin-specific table; see [Sources](sources.md) and [Sinks](sinks.md) |
`config_file` is reserved on both structures for a future include mechanism and
is not implemented.
### Flow stages
| Block | Optional | Reference |
|-------|----------|-----------|
| `flow.rate_limit` | yes | below |
| `flow.filters` | yes | [Filters](filters.md) |
| `flow.format` | yes (defaults to `raw`) | [Formatters](formatters.md) |
| `flow.heartbeat` | yes | below |
#### Rate limiting
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rate` | float | `0` | Entries per second; `<= 0` disables the limiter entirely |
| `burst` | float | `rate` | Token bucket capacity |
| `policy` | string | `pass` | `pass` allows everything through, `drop` discards over-limit entries |
| `max_entry_size_bytes` | int | `0` | Per-entry byte cap; `0` = unlimited |
Two behaviours are easy to trip over:
- The limiter is constructed only when `rate > 0`. With `rate = 0`,
`max_entry_size_bytes` is never enforced.
- `policy = "pass"` short-circuits the whole check, including the size cap.
To enforce a size cap you need `rate > 0` **and** `policy = "drop"`.
#### Heartbeat
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | bool | `false` | Enable heartbeat generation |
| `interval_ms` | int | `1000` | Interval; minimum `100` |
| `include_timestamp` | bool | `false` | `false` formats with level only, no timestamp |
| `include_stats` | bool | `false` | Attach `beat_count` and measured `interval_ms` as fields |
| `format` | string | `txt` | `txt`, `json`, or `raw` |
Heartbeats are ordinary entries with source `heartbeat` and level `INFO`. They
are generated after the flow's filter and rate-limit stages, so filters do not
suppress them, and they reach every sink in the pipeline.
> `format = "comment"` (SSE comment framing) appears in older documentation and
> in a code path in the generator, but the validator rejects it and the pipeline
> fails to start. Use `txt`, `json`, or `raw`.
## Environment Variables ## Environment Variables
Environment overrides are derived from the TOML path: `.` becomes `_` and the All configuration options support environment variable overrides:
result is uppercased.
| TOML path | Environment variable | ### Naming Convention
- Prefix: `LOGWISP_`
- Path separator: `_` (underscore)
- Array indices: Numeric suffix (0-based)
- Case: UPPERCASE
### Mapping Examples
| TOML Path | Environment Variable |
|-----------|---------------------| |-----------|---------------------|
| `quiet` | `QUIET` | | `quiet` | `LOGWISP_QUIET` |
| `status_reporter` | `STATUS_REPORTER` | | `logging.level` | `LOGWISP_LOGGING_LEVEL` |
| `logging.level` | `LOGGING_LEVEL` | | `pipelines[0].name` | `LOGWISP_PIPELINES_0_NAME` |
| `logging.file.directory` | `LOGGING_FILE_DIRECTORY` | | `pipelines[0].plugin_sources[0].type` | `LOGWISP_PIPELINES_0_PLUGIN_SOURCES_0_TYPE` |
> **The `LOGWISP_` prefix is not currently applied.** The configuration loader
> requests it, but supplying a custom path-to-variable transform replaces the
> prefixing step rather than composing with it, so LogWisp reads bare
> `QUIET`, `LOGGING_LEVEL`, and so on from the environment. Treat this as
> current behaviour to be aware of — bare names like `QUIET` can collide with
> unrelated variables — rather than as intended design.
>
> The two exceptions are `LOGWISP_CONFIG_FILE` and `LOGWISP_CONFIG_DIR`, which
> are read directly by the path resolver and **do** carry the prefix.
Only scalar paths that exist in the configuration schema can be set this way.
Array elements cannot: `PIPELINES_0_NAME` has no effect.
## Command-Line Overrides ## Command-Line Overrides
Any scalar configuration path is settable as a flag using its TOML path: All configuration options can be overridden via CLI flags:
```bash ```bash
logwisp --logging.level=debug --status_reporter=false logwisp --quiet \
logwisp --logging.level debug # space form also works --logging.level=debug \
logwisp --quiet # bare flag means true --pipelines.0.name=myapp \
--pipelines.0.plugin_sources.0.type=console
``` ```
Unrecognized flags are reported on stderr before the logger exists and are then ## Configuration Validation
ignored:
``` LogWisp validates configuration at startup:
Warning: unrecognized flags ignored: [pipelines.0.name] - Rpipelines non-empty, name non-empty, ≥1 source, ≥1 sink, logging enum values.equired fields presence
```
> Array-indexed paths are **not** settable from the command line. Partial check in plugin constructor:
> `--pipelines.0.name=x`, `--pipelines.0.plugin_sinks.0.type=null`, and similar - Type correctness
> flags are reported as unrecognized and ignored. Pipelines, sources, sinks, and - Port conflicts
> filters can only be defined in the configuration file. Older documentation - Path accessibility
> claimed otherwise. - Pattern compilation
- Network address formats
## Validation
Startup validation is intentionally split.
`internal/config` validates only global structure:
- at least one pipeline
- unique, non-empty pipeline names
- at least one source and one sink per pipeline
- `logging.output`, `logging.level`, `logging.format`, `logging.sanitization`,
and `logging.console.target` enum membership
Everything else is validated by the plugin constructor that owns it — port
range, required paths, path prefixes, enum values, regex compilation, TLS file
loading. A failure there aborts pipeline construction with a message naming the
pipeline, plugin id, and offending key.
There is **no** cross-pipeline port-conflict detection. Two sinks bound to the
same port fail at listener bind time, when the pipeline starts.
## Hot Reload ## Hot Reload
Enable configuration hot reload:
```toml ```toml
auto_reload = true auto_reload = true
``` ```
or send `SIGHUP` / `SIGUSR1`. Or via command line:
```bash
logwisp --auto-reload
```
Reload rebuilds the whole service: a new service is constructed from the new Reload triggers:
configuration first, and only if that succeeds is the old one shut down. A - File modification detection
configuration error therefore leaves the running service untouched. - SIGHUP or SIGUSR1 signals
| Reloaded | Not reloaded | Reloadable items:
|----------|--------------| - Pipeline configurations
| Pipelines, sources, sinks | `logging.*` (applied once at startup) | - Sources and sinks
| Filters, formatters, rate limits, heartbeats | `quiet` | - Filters and formatters
| `status_reporter` | `auto_reload` (the watcher is not restarted) | - Rate limits
Because the rebuild is total, listeners close and reopen and every connected Non-reloadable (requires restart):
client is disconnected. Chain sinks reconnect on their own backoff schedule. - Logging configuration
- Global settings
## Type Reference ## Default Configuration
| TOML type | Go type | Command-line / environment form | Minimal working configuration:
|-----------|---------|-------------------------------|
| String | `string` | Plain text | ```toml
| Integer | `int64` | Decimal string | [[pipelines]]
| Float | `float64` | Decimal string | name = "default"
| Boolean | `bool` | `true` / `false`, or a bare flag for `true` |
| Array | `[]T` | Not settable outside the file | [[pipelines.plugin_sources]]
| Table | struct | Nested path with `.` (flags) or `_` (environment) | id = "default_source"
type = "file"
[pipelines.plugin_sources.config]
directory = "./"
pattern = "*.log"
[[pipelines.plugin_sinks]]
id = "default_sink"
type = "console"
[pipelines.plugin_sinks.config]
target = "stdout"
```
## Configuration Schema
### Type Reference
| TOML Type | Go Type | Environment Format |
|-----------|---------|-------------------|
| String | string | Plain text |
| Integer | int64 | Numeric string |
| Float | float64 | Decimal string |
| Boolean | bool | true/false |
| Array | []T | JSON array string |
| Table | struct | Nested with `_` |
+139 -146
View File
@@ -1,192 +1,185 @@
# Filters # Filters
Filters decide which entries continue through a pipeline. They run in the flow, LogWisp filters control which log entries pass through the pipeline using pattern matching.
after rate limiting and before formatting.
```toml
[[pipelines.flow.filters]]
type = "include"
logic = "or"
patterns = ["ERROR", "WARN"]
```
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `include` | `include` (only matches pass) or `exclude` (matches are dropped) |
| `logic` | string | `or` | `or` (any pattern matches) or `and` (every pattern matches) |
| `patterns` | []string | `[]` | Go RE2 regular expressions |
A filter with no patterns passes everything. Invalid patterns fail at startup
with the filter index and the offending pattern in the message.
## What Gets Matched
Patterns are matched against a single string assembled from the entry:
```
"<source> <level> <message>"
```
Empty parts are omitted, so an entry with no detected level matches
`"<source> <message>"`. This means a pattern can target the source name or the
level as easily as the message body:
| Pattern | Matches |
|---------|---------|
| `"^app\\.log "` | Entries whose source is `app.log` |
| `"ERROR"` | Level `ERROR`, or the word `ERROR` anywhere in the message |
The structured `fields` payload is **not** part of the match text.
For entries that arrived over a chain link, the `source` used here is the bare
source — the `node/source` prefix is applied later, by the formatter — so
filtering by originating node requires matching on the message, or filtering on
the node that produces the entries.
## Filter Types ## Filter Types
### include ### Include Filter
Only matching entries pass. Everything else is dropped. Only entries matching patterns pass through.
```toml ```toml
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["ERROR", "WARN", "FATAL"] logic = "or" # or|and
patterns = [
"ERROR",
"WARN",
"CRITICAL"
]
``` ```
### exclude ### Exclude Filter
Matching entries are dropped. Everything else passes. Entries matching patterns are dropped.
```toml ```toml
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = ["/healthz", "TRACE"] patterns = [
"DEBUG",
"TRACE",
"health-check"
]
``` ```
## Logic ## Configuration Options
### or (default) | Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | Required | Filter type (include/exclude) |
| `logic` | string | "or" | Pattern matching logic (or/and) |
| `patterns` | []string | Required | Pattern list |
## Pattern Syntax
Patterns support regular expression syntax:
### Basic Patterns
- **Literal match**: `"ERROR"` - matches "ERROR" anywhere
- **Case-insensitive**: `"(?i)error"` - matches "error", "ERROR", "Error"
- **Word boundary**: `"\\berror\\b"` - matches whole word only
### Advanced Patterns
- **Alternation**: `"ERROR|WARN|FATAL"`
- **Character classes**: `"[0-9]{3}"`
- **Wildcards**: `".*exception.*"`
- **Line anchors**: `"^ERROR"` (start), `"ERROR$"` (end)
### Special Characters
Escape special regex characters with backslash:
- `.``\\.`
- `*``\\*`
- `[``\\[`
- `(``\\(`
## Filter Logic
### OR Logic (default)
Entry passes if ANY pattern matches:
```toml ```toml
logic = "or" logic = "or"
patterns = ["ERROR", "WARN"] patterns = ["ERROR", "WARN"]
# passes: "ERROR in module" "WARN: low memory" # Passes: "ERROR in module", "WARN: low memory"
# blocks: "INFO: started" # Blocks: "INFO: started"
``` ```
### and ### AND Logic
Entry passes only if ALL patterns match:
```toml ```toml
logic = "and" logic = "and"
patterns = ["database", "ERROR"] patterns = ["database", "ERROR"]
# passes: "ERROR: database connection failed" # Passes: "ERROR: database connection failed"
# blocks: "ERROR: file not found" # Blocks: "ERROR: file not found"
``` ```
With `logic = "and"` on an `exclude` filter, an entry is dropped only when it ## Filter Chain
matches *every* pattern.
## Filter Chains Multiple filters execute sequentially:
Filters are evaluated in declaration order and an entry must survive all of
them. The first filter to reject an entry ends its life; later filters never see
it.
```toml ```toml
# 1. keep only production traffic # First filter: Include errors and warnings
[[pipelines.flow.filters]]
type = "include"
patterns = ["ERROR", "WARN"]
# Second filter: Exclude test environments
[[pipelines.flow.filters]]
type = "exclude"
patterns = ["test-env", "staging"]
```
Processing order:
1. Entry arrives from source
2. Include filter evaluates
3. If passed, exclude filter evaluates
4. If passed all filters, entry continues to sink
## Performance Considerations
### Pattern Compilation
- Patterns compile once at startup
- Invalid patterns cause startup failure
- Complex patterns may impact performance
### Optimization Tips
- Place most selective filters first
- Use simple patterns when possible
- Combine related patterns with alternation
- Avoid excessive wildcards (`.*`)
## Filter Statistics
Filters track:
- Total entries evaluated
- Entries passed
- Entries blocked
- Processing time per pattern
## Common Use Cases
### Log Level Filtering
```toml
[[pipelines.filters]]
type = "include"
patterns = ["ERROR", "WARN", "FATAL", "CRITICAL"]
```
### Application Filtering
```toml
[[pipelines.flow.filters]]
type = "include"
patterns = ["app1", "app2", "app3"]
```
### Noise Reduction
```toml
[[pipelines.flow.filters]]
type = "exclude"
patterns = [
"health-check",
"ping",
"/metrics",
"heartbeat"
]
```
### Security Filtering
```toml
[[pipelines.flow.filters]]
type = "exclude"
patterns = [
"password",
"token",
"api[_-]key",
"secret"
]
```
### Multi-stage Filtering
```toml
# Include production logs
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["prod-", "production"] patterns = ["prod-", "production"]
# 2. of that, keep only failures # Include only errors
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "include" type = "include"
patterns = ["ERROR", "EXCEPTION", "FATAL"] patterns = ["ERROR", "EXCEPTION", "FATAL"]
# 3. minus known noise # Exclude known issues
[[pipelines.flow.filters]] [[pipelines.flow.filters]]
type = "exclude" type = "exclude"
patterns = ["ECONNRESET", "broken pipe"] patterns = ["ECONNRESET", "broken pipe"]
``` ```
Order matters for cost, not for correctness: put the most selective filter first
so later ones evaluate fewer entries.
## Pattern Syntax
Go's RE2 syntax. No backreferences and no lookaround — RE2 guarantees linear
time, which is exactly what you want in a log hot path.
| Need | Pattern |
|------|---------|
| Literal substring | `ERROR` |
| Case-insensitive | `(?i)error` |
| Whole word | `\\berror\\b` |
| Alternation | `ERROR\|WARN\|FATAL` |
| Character class | `[0-9]{3}` |
| Anchors | `^ERROR`, `ERROR$` |
| Any characters | `.*exception.*` |
Remember that TOML basic strings process escapes, so a regex backslash needs
doubling: `"\\berror\\b"`. TOML literal strings avoid the issue:
`'\berror\b'`.
Anchors apply to the assembled match text, which begins with the source name —
so `^ERROR` will not match an entry whose source is non-empty. Use
`\\bERROR\\b` instead unless you mean to anchor on the source.
## Common Recipes
**Severity floor**
```toml
[[pipelines.flow.filters]]
type = "include"
patterns = ["ERROR", "FATAL", "CRITICAL"]
```
**Noise reduction**
```toml
[[pipelines.flow.filters]]
type = "exclude"
patterns = ["/healthz", "/metrics", "\\bping\\b"]
```
**Secret suppression** — see [Security](security.md); filters are the only
redaction mechanism LogWisp currently offers.
```toml
[[pipelines.flow.filters]]
type = "exclude"
patterns = ["password", "api[_-]?key", "authorization", "bearer ", "secret", "token"]
```
Note this drops the whole entry, it does not redact part of it.
**Per-application routing** — run one pipeline per application, each with its
own include filter, rather than trying to route inside one pipeline. Sinks fan
out to *all* sinks in a pipeline; there is no conditional routing.
## Statistics
Each filter reports `type`, `logic`, `pattern_count`, `total_processed`,
`total_matched`, and `total_dropped`. The chain reports `filter_count`,
`total_processed`, and `total_passed`; the pipeline derives
`total_filtered` as the difference.
## Performance
Patterns compile once at startup. Every entry that reaches the filter stage is
evaluated against every filter until one rejects it, so cost scales with the
number of patterns and their complexity. Prefer literal substrings and simple
alternations over broad `.*` wildcards.
Filters log at DEBUG on every entry — pattern text, match results, and the
final decision. That is invaluable when a filter is not behaving as expected and
very expensive in production; keep `logging.level` at `info` or higher on a busy
pipeline.
+151 -154
View File
@@ -1,183 +1,180 @@
# Formatters # Formatters
The formatter is the last flow stage. It turns a `core.LogEntry` into the byte LogWisp formatters transform log entries before output to sinks.
payload that sinks write, applying a sanitizer policy on the way.
```toml ## Formatter Types
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
flags = 0
timestamp_format = ""
```
One formatter serves the whole pipeline. Sinks receive an identical payload; ### Raw Formatter
there is no per-sink formatting. When you need two shapes of the same data, run
two pipelines, or chain to a node that formats differently.
Omitting `[pipelines.flow.format]` entirely selects `raw`. Outputs the log message as-is with optional newline.
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `raw` | `raw`, `txt` (alias `text`), or `json` |
| `sanitizer_policy` | string | derived from `type` | `raw`, `txt`, `json`, or `shell` |
| `flags` | int64 | `0` | Bitmask override; `0` selects a per-type default |
| `timestamp_format` | string | formatter default | Go reference layout, e.g. `"2006-01-02T15:04:05Z07:00"` |
## Types
### raw
Passthrough. `FlagRaw` bypasses formatting and sanitization: the message reaches
the sink exactly as the source produced it, with no timestamp, level or source
prefix added. An entry that also carries `fields` gets the fields JSON appended
verbatim after a single space — `raw` never drops data and never re-encodes it.
```toml ```toml
[pipelines.flow.format] [pipelines.flow.format]
type = "raw" type = "raw"
sanitizer_policy = "raw"
flags = 1
``` ```
Fastest option, and the right one when you are relaying text that is already in **Configuration Options:**
its final form. Note that it also bypasses sanitization, so control characters
in the source data reach your sinks intact.
Byte-exact transport needs a source that does not split the line: the `console` | Option | Type | Default | Description |
source, or the `file` source with `raw = true`. Both put the whole line — |--------|------|---------|-------------|
newline included — in the message and leave `fields` empty. The `file` source's | `add_new_line` | bool | true | Append newline to messages |
JSON branch splits a line into message and fields, so `raw` reassembles it as | `type` | string | "raw" | raw, json, or txt |
`<msg> <fields>` rather than reproducing the original object. | `flags` | int64 | 0 | log/formatter flags override |
| `sanitizer_policy` | string | | Sanitizer policy (e.g. "json", "raw", "txt", "shell") |
### txt ### JSON Formatter
Human-readable line output with a timestamp and level. Produces structured JSON output.
```toml
[pipelines.format]
type = "json"
+[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
```
**Output Structure:**
```json
{
"timestamp": "2024-01-01T12:00:00Z",
"level": "ERROR",
"source": "app",
"message": "Connection failed"
}
```
### Text Formatter
Template-based text formatting.
```toml ```toml
[pipelines.flow.format] [pipelines.flow.format]
type = "txt" type = "txt"
sanitizer_policy = "txt" sanitizer_policy = "txt"
timestamp_format = "2006-01-02 15:04:05" timestamp_format = "2006-01-02T15:04:05.000Z07:00"
``` ```
### json **Configuration Options:**
Structured output, the natural choice for downstream ingestion. | Option | Type | Default | Description |
|--------|------|---------|-------------|
| `timestamp_format` | string | "" | Time format override |
**Default Template:**
```
[{{.Timestamp | FmtTime}}] [{{.Level | ToUpper}}] {{.Source}} - {{.Message}}{{ if .Fields }} {{.Fields}}{{ end }}
```
## Template Functions
Available functions in text templates:
| Function | Description | Example |
|----------|-------------|---------|
| `FmtTime` | Format timestamp | `{{.Timestamp \| FmtTime}}` |
| `ToUpper` | Convert to uppercase | `{{.Level \| ToUpper}}` |
| `ToLower` | Convert to lowercase | `{{.Source \| ToLower}}` |
| `TrimSpace` | Remove whitespace | `{{.Message \| TrimSpace}}` |
## Template Variables
Available variables in templates:
| Variable | Type | Description |
|----------|------|-------------|
| `.Timestamp` | time.Time | Entry timestamp |
| `.Level` | string | Log level |
| `.Source` | string | Source identifier |
| `.Message` | string | Log message |
| `.Fields` | string | Additional fields (JSON) |
## Time Format Strings
Common Go time format patterns:
| Pattern | Example Output |
|---------|---------------|
| `2006-01-02T15:04:05Z07:00` | 2024-01-02T15:04:05Z |
| `2006-01-02 15:04:05` | 2024-01-02 15:04:05 |
| `Jan 2 15:04:05` | Jan 2 15:04:05 |
| `15:04:05.000` | 15:04:05.123 |
| `2006/01/02` | 2024/01/02 |
## Format Selection
### Default Behavior
If no formatter specified:
- **HTTP/TCP sinks**: JSON format
- **Console/File sinks**: Raw format
- **Client sinks**: JSON format
### Per-Pipeline Configuration
Each pipeline can have its own formatter:
```toml
[[pipelines]]
name = "json-pipeline"
[pipelines.flow.format]
type = "json"
[[pipelines]]
name = "text-pipeline"
[pipelines.flow.format]
type = "txt"
```
## Message Processing
### JSON Message Handling
When using JSON formatter with JSON log messages:
1. Attempts to parse message as JSON
2. Merges fields with LogWisp metadata
3. LogWisp fields take precedence
4. Falls back to string if parsing fails
### Field Preservation
LogWisp metadata always includes:
- Timestamp (from source or current time)
- Level (detected or default)
- Source (origin identifier)
- Message (original content)
## Performance Characteristics
### Formatter Performance
Relative performance (fastest to slowest):
1. **Raw**: Direct passthrough
2. **Text**: Template execution
3. **JSON**: Serialization
4. **JSON (pretty)**: Formatted serialization
### Optimization Tips
- Use raw format for high throughput
- Cache template compilation (automatic)
- Minimize template complexity
- Avoid pretty JSON in production
## Common Configurations
### Structured Logging
```toml ```toml
[pipelines.flow.format] [pipelines.flow.format]
type = "json" type = "json"
sanitizer_policy = "json"
``` ```
Output has the shape: ### Human-Readable Logs
```toml
```json [pipelines.flow.format]
{"time":"2026-01-02T15:04:05.123Z","level":"ERROR","trace":"edge-01/app.log","fields":["connection refused"]} type = "txt"
timestamp_format = "15:04:05"
``` ```
The exact key names and structure come from the `lixenwraith/log` formatter, not
from LogWisp; they are stable for a given dependency version but are not part of
LogWisp's own configuration surface.
## Flags
`flags` is a bitmask passed to the underlying formatter. Leave it at `0` unless
you need to override the defaults.
| Value | Name | Effect |
|-------|------|--------|
| `1` | Raw | Bypass formatting and sanitization entirely |
| `2` | ShowTimestamp | Emit the timestamp |
| `4` | ShowLevel | Emit the level |
| `8` | StructuredJSON | Render attached fields as a JSON object |
| `16` | NoTimestamp | Suppress the timestamp |
| `32` | NoLevel | Suppress the level |
With `flags = 0` the formatter selects `1` for `type = "raw"` and `6`
(timestamp + level) for every other type. `8` is added automatically whenever an
entry carries parseable `fields` and `1` is not set; `1` always wins.
Examples: `flags = 4` for level only, no timestamp; `flags = 2` for timestamp
only, no level.
## Sanitizer Policies
The sanitizer runs before serialization and neutralizes control characters that
would otherwise break framing or reach a terminal.
| Policy | Behaviour | Use with |
|--------|-----------|----------|
| `raw` | No-op passthrough | `type = "raw"` where you control the data |
| `txt` | Escapes non-printable characters | File and console sinks |
| `json` | Escapes control characters for safe JSON embedding | `type = "json"`, chain links |
| `shell` | Strips shell metacharacters, whitespace, and control characters | Data that will be passed to a command |
When `sanitizer_policy` is omitted, the policy is derived from `type`: `json`
for `json`, `txt` for `txt`/`text`, and `raw` for anything else — so the safe
pairing is the default.
> `shell` strips dangerous characters but is **not** sufficient to make a string
> safe for shell construction. Pass arguments through `exec` argv instead of
> building command lines.
To see a policy working, point a pipeline at the `random` source with
`special = true`, which injects control bytes and multi-byte Unicode into every
message.
## Node Identity in Output
Entries that arrived over a chain link carry a `Node` label. The formatter
renders it as a syslog-style prefix on the source field:
```
edge-01/app.log
```
Entries with no node label show the bare source. Node identity therefore appears
*inside* the source field rather than as a separate output key — worth knowing
when writing downstream parsers or grep patterns.
## Structured Fields
When an entry carries `Fields` (raw JSON) and `FlagRaw` is not set, the
formatter parses it and switches to structured rendering by adding the
`StructuredJSON` flag automatically. Fields reach a pipeline in two ways: from
the `file` source when a tailed line parses as JSON with a `fields` key, and
from the heartbeat generator when `include_stats = true`.
## Choosing a Configuration
| Goal | Configuration |
|------|---------------|
| Maximum throughput, data already formatted | `type = "raw"` |
| Human reading in a terminal or file | `type = "txt"`, `sanitizer_policy = "txt"` |
| Downstream ingestion (Loki, Elasticsearch, jq) | `type = "json"`, `sanitizer_policy = "json"` |
| Compact console output | `type = "txt"`, `flags = 4` |
| Untrusted log content | never `raw`; pick `txt` or `json` and set the matching policy |
## Formatting and Chain Links
Chain sinks (`tcp_chain`, `http_chain`) do **not** ship the formatted payload.
They re-serialize the structured entry into the canonical chain encoding, which
makes them independent of the local formatter.
The practical consequence: setting `flow.format` on an edge node changes only
that node's own local sinks. The output shape seen by a human or a downstream
system is decided on the node that owns the sink they read.
If an event ever reaches a chain sink without a structured entry, the sink wraps
the formatted payload into a synthetic entry and counts it in `synthesized`.
A non-zero `synthesized` count means something upstream lost structure.
## Performance
Relative cost, cheapest first: `raw` (passthrough) → `txt` (line assembly) →
`json` (serialization). Sanitization adds a scan of the message; the `raw`
policy skips it.
The formatter holds a mutex because the underlying implementation reuses an
internal buffer and is not goroutine-safe. It is the only shared serialization
point in the hot path, and the reason a single pipeline formats entries one at
a time.
+62 -124
View File
@@ -1,80 +1,49 @@
# Installation Guide # Installation Guide
## Requirements LogWisp installation and service configuration for Linux and FreeBSD systems.
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+) ## Installation Methods
- **Architecture**: amd64
- **Go**: 1.27.1 or newer, to build from source
## Building from Source ### Pre-built Binaries
Download the latest release binary for your platform and install to `/usr/local/bin`:
```bash ```bash
git clone https://github.com/lixenwraith/logwisp.git # Linux amd64
wget https://github.com/yourusername/logwisp/releases/latest/download/logwisp-linux-amd64
chmod +x logwisp-linux-amd64
sudo mv logwisp-linux-amd64 /usr/local/bin/logwisp
# FreeBSD amd64
fetch https://github.com/yourusername/logwisp/releases/latest/download/logwisp-freebsd-amd64
chmod +x logwisp-freebsd-amd64
sudo mv logwisp-freebsd-amd64 /usr/local/bin/logwisp
```
### Building from Source
Requires Go 1.24 or newer:
```bash
git clone https://github.com/yourusername/logwisp.git
cd logwisp cd logwisp
make go build -o logwisp ./src/cmd/logwisp
sudo make install # installs to $PREFIX/bin, default /usr/local/bin sudo install -m 755 logwisp /usr/local/bin/
``` ```
The Makefile works with both GNU make and BSD make. Targets: ### Go Install Method
| Target | Effect | Install directly using Go (version information will not be embedded):
|--------|--------|
| `make` / `make build` | Build `bin/logwisp` with version metadata |
| `make dev` | Build with the race detector enabled |
| `make install` | Install the binary to `$(PREFIX)/bin` (default `/usr/local`) |
| `make uninstall` | Remove `$(BINDIR)/logwisp` |
| `make clean` | Remove the built binary |
| `make version` | Print the version, commit, and build time that would be embedded |
Version, commit hash, and build time are injected via `-ldflags` from `git
describe` and `git rev-parse`. A plain `go build` produces a working binary that
reports `dev` for all three:
```bash ```bash
go build -o bin/logwisp ./cmd/logwisp go install github.com/yourusername/logwisp/cmd/logwisp@latest
``` ```
`go install github.com/lixenwraith/logwisp/cmd/logwisp@latest` also works, with ## Service Configuration
the same loss of version metadata.
## Container Image
The root `Dockerfile` builds the same package into `scratch` under UID 65532,
static and stripped. There is no shell and no config in the image: mount one and
name it, as the binary has no daemon mode and no built-in defaults worth running.
```bash
REV=$(git rev-parse HEAD)
docker build -t "logwisp:$(git rev-parse --short HEAD)" \
--build-arg VERSION="$(git describe --tags --always)" \
--build-arg REVISION="$REV" .
docker run --rm -v /etc/logwisp:/etc/logwisp:ro logwisp:... -c /etc/logwisp/logwisp.toml
```
Sinks that listen (`http`, `tcp`) need their ports published; the read-only
root filesystem and dropped capabilities a restricted runtime imposes are all
compatible with it, provided a `file` sink's directory is writable by 65532.
## Configuration
Copy the annotated reference configuration and edit it:
```bash
sudo mkdir -p /etc/logwisp
sudo cp config/logwisp.toml /etc/logwisp/logwisp.toml
```
LogWisp searches, in order: `-c <path>`, `--config=<path>`,
`$LOGWISP_CONFIG_DIR`/`$LOGWISP_CONFIG_FILE`, `~/.config/logwisp/logwisp.toml`,
`./logwisp.toml`. See [Configuration](configuration.md).
## Running as a Service
LogWisp has no daemon mode; run it in the foreground under a supervisor.
### Linux (systemd) ### Linux (systemd)
`/etc/systemd/system/logwisp.service`: Create systemd service file `/etc/systemd/system/logwisp.service`:
```ini ```ini
[Unit] [Unit]
@@ -86,47 +55,30 @@ Type=simple
User=logwisp User=logwisp
Group=logwisp Group=logwisp
ExecStart=/usr/local/bin/logwisp -c /etc/logwisp/logwisp.toml ExecStart=/usr/local/bin/logwisp -c /etc/logwisp/logwisp.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
WorkingDirectory=/var/lib/logwisp
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
WorkingDirectory=/var/lib/logwisp
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/logwisp /var/lib/logwisp
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
``` ```
`ExecReload` gives you `systemctl reload logwisp` for configuration and Setup service user and directories:
certificate rotation without dropping the process.
If a pipeline binds a port below 1024, add
`AmbientCapabilities=CAP_NET_BIND_SERVICE` rather than running as root.
Setup:
```bash ```bash
sudo useradd -r -s /usr/sbin/nologin logwisp sudo useradd -r -s /bin/false logwisp
sudo mkdir -p /etc/logwisp /var/lib/logwisp /var/log/logwisp sudo mkdir -p /etc/logwisp /var/lib/logwisp /var/log/logwisp
sudo chown logwisp:logwisp /var/lib/logwisp /var/log/logwisp sudo chown logwisp:logwisp /var/lib/logwisp /var/log/logwisp
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now logwisp sudo systemctl enable logwisp
sudo systemctl start logwisp
``` ```
The service account needs **read** access to every directory a `file` source
watches and **write** access to every directory a `file` sink or
`logging.file` writes to.
### FreeBSD (rc.d) ### FreeBSD (rc.d)
`/usr/local/etc/rc.d/logwisp`: Create rc script `/usr/local/etc/rc.d/logwisp`:
```sh ```sh
#!/bin/sh #!/bin/sh
@@ -140,9 +92,8 @@ watches and **write** access to every directory a `file` sink or
name="logwisp" name="logwisp"
rcvar="${name}_enable" rcvar="${name}_enable"
pidfile="/var/run/${name}.pid" pidfile="/var/run/${name}.pid"
procname="/usr/local/bin/logwisp" command="/usr/local/bin/logwisp"
command="/usr/sbin/daemon" command_args="-c /usr/local/etc/logwisp/logwisp.toml"
command_args="-p ${pidfile} -f ${procname} -c /usr/local/etc/logwisp/logwisp.toml"
load_rc_config $name load_rc_config $name
: ${logwisp_enable:="NO"} : ${logwisp_enable:="NO"}
@@ -150,7 +101,7 @@ load_rc_config $name
run_rc_command "$1" run_rc_command "$1"
``` ```
Setup: Setup service:
```bash ```bash
sudo chmod +x /usr/local/etc/rc.d/logwisp sudo chmod +x /usr/local/etc/rc.d/logwisp
@@ -161,59 +112,45 @@ sudo sysrc logwisp_enable="YES"
sudo service logwisp start sudo service logwisp start
``` ```
## Directory Layout ## Directory Structure
Standard installation directories:
| Purpose | Linux | FreeBSD | | Purpose | Linux | FreeBSD |
|---------|-------|---------| |---------|-------|---------|
| Binary | `/usr/local/bin/logwisp` | `/usr/local/bin/logwisp` | | Binary | `/usr/local/bin/logwisp` | `/usr/local/bin/logwisp` |
| Configuration | `/etc/logwisp/` | `/usr/local/etc/logwisp/` | | Configuration | `/etc/logwisp/` | `/usr/local/etc/logwisp/` |
| TLS material | `/etc/logwisp/tls/` | `/usr/local/etc/logwisp/tls/` | | Working Directory | `/var/lib/logwisp/` | `/var/db/logwisp/` |
| Working directory | `/var/lib/logwisp/` | `/var/db/logwisp/` | | Log Files | `/var/log/logwisp/` | `/var/log/logwisp/` |
| Application logs | `/var/log/logwisp/` | `/var/log/logwisp/` | | PID File | `/var/run/logwisp.pid` | `/var/run/logwisp.pid` |
Key files should be mode `0600` and owned by the service account. ## Post-Installation Verification
## Verification Verify the installation:
```bash ```bash
logwisp --version # Check version
logwisp version
# start in the foreground with debug logging and watch pipelines come up # Test configuration
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug --logging.output=stderr logwisp -c /etc/logwisp/logwisp.toml --disable-status-reporter
sudo systemctl status logwisp # Linux # Check service status (Linux)
sudo service logwisp status # FreeBSD sudo systemctl status logwisp
# Check service status (FreeBSD)
sudo service logwisp status
``` ```
Expect `Created source instance`, `Created sink instance`, and ## Uninstallation
`Starting pipeline` for each configured pipeline. There is no validate-only
mode; see [Operations](operations.md#checking-a-configuration).
## Test Scripts
End-to-end scripts under `test/` run against a local build:
```bash
make
./test/chain-test.sh --auto # two independent relay pipelines
./test/chain-aggregate-test.sh --auto # fan-in: both edges into one pipeline
./test/mtls-chain-test.sh --auto # the same fan-in under mTLS
./test/passthrough-test.sh # file source relays a wide envelope intact
```
Without `--auto` the chain scripts run the relay in the foreground for
interactive inspection. They need bash 5+, coreutils, and curl, and they bind
ports 1580115804. The pass-through test binds nothing. Generated configuration
and logs land in `test/run/`.
## Uninstall
### Linux ### Linux
```bash ```bash
sudo systemctl disable --now logwisp sudo systemctl stop logwisp
sudo rm /usr/local/bin/logwisp /etc/systemd/system/logwisp.service sudo systemctl disable logwisp
sudo systemctl daemon-reload sudo rm /usr/local/bin/logwisp
sudo rm /etc/systemd/system/logwisp.service
sudo rm -rf /etc/logwisp /var/lib/logwisp /var/log/logwisp sudo rm -rf /etc/logwisp /var/lib/logwisp /var/log/logwisp
sudo userdel logwisp sudo userdel logwisp
``` ```
@@ -223,7 +160,8 @@ sudo userdel logwisp
```bash ```bash
sudo service logwisp stop sudo service logwisp stop
sudo sysrc -x logwisp_enable sudo sysrc -x logwisp_enable
sudo rm /usr/local/bin/logwisp /usr/local/etc/rc.d/logwisp sudo rm /usr/local/bin/logwisp
sudo rm /usr/local/etc/rc.d/logwisp
sudo rm -rf /usr/local/etc/logwisp /var/db/logwisp /var/log/logwisp sudo rm -rf /usr/local/etc/logwisp /var/db/logwisp /var/log/logwisp
sudo pw userdel logwisp sudo pw userdel logwisp
``` ```
-373
View File
@@ -1,373 +0,0 @@
# mTLS as Authentication
**Status:** implemented. Phases 13 of the original proposal, plus dialer-side
server identity pinning from phase 4, are in the tree and covered by
`test/mtls-chain-test.sh`. The remaining phase-4 items are listed under
[Not Implemented](#not-implemented).
**Scope:** turn transport-level mutual TLS into an authentication and
authorization mechanism.
## Problem
LogWisp already did mutual TLS at the transport layer. A listener with
`client_auth = true` refused any peer that could not present a certificate
chaining to `client_ca_file`, and `internal/tlsx` extracted the peer's Common
Name into session metadata as `tls_peer_cn`.
Nothing read it back. The result was a CA-wide membership check with no notion
of *which* peer connected:
1. **No per-identity authorization.** Every certificate the CA issued was
equivalent. There was no way to say "only `edge-01` and `edge-02` may write
to this ingest port", so one CA could not serve several trust domains, and
withdrawing one peer meant rotating the CA bundle for all of them.
2. **Node labels were unauthenticated.** With `trust_node = true` (the default)
a peer declares its own `node` label in the chain hello or the
`X-Logwisp-Node` header. Any certificate holder could claim any label,
including another host's, and every downstream consumer would attribute
those entries accordingly. The only defence, `trust_node = false`, replaced
the label with a remote address — unforgeable, but useless for identifying a
host behind NAT or a load balancer.
3. **The `http` sink had no authentication at all**, even with TLS on. Its
stream and status endpoints were readable by anyone who could reach the port.
Password, token, and SCRAM authentication were removed during the plugin/flow
restructure and the move to standard-library networking. Certificates are the
one credential the transport already carries, which made mTLS the cheapest path
back to authenticated peers.
## Goals
- Authorize peers by certificate identity, per listener. ✅
- Bind the chain `node` label to the authenticated identity, so origin
attribution is trustworthy. ✅
- Gate the `http` sink's endpoints on client certificates. ✅
- Make identity visible in sessions, statistics, and logs. ✅
- Change nothing for existing configurations that omit the new block. ✅
## Non-Goals
- Reviving password, token, or SCRAM authentication. The reserved hooks
(`chain.Hello.Auth`, the `Authorization` header comment in the `http_chain`
sink, the "Future: password auth block" comments in the network options
structs) stay reserved.
- IP allow/deny lists and per-peer rate limits. Related, but a separate feature
with its own config surface.
- OCSP. See [Revocation](#revocation) for what is done instead.
- Authorization *within* a stream — the unit of decision is a connection (TCP)
or a request (HTTP), never an individual entry.
## Design
### Identity
The authenticated identity is a single string derived from the peer's verified
leaf certificate. Because `tls.RequireAndVerifyClientCert` has already validated
the chain, signature, and validity window by the time we look, extraction is
pure field selection — `tlsx.PeerIdentity`.
| `identity` mode | Source | Notes |
|-----------------|--------|-------|
| `cn` (default) | `Subject.CommonName` | Matches the existing `tls_peer_cn` metadata |
| `san_dns` | first `DNSNames` entry | Preferred for host identities |
| `san_uri` | first `URIs` entry | SPIFFE-style IDs |
| `san_email` | first `EmailAddresses` entry | Operator identities |
An empty identity is a rejection, not an empty match: a certificate with no
usable identity field cannot satisfy any policy.
Identities are not secrets, so ordinary string comparison is used; there is no
timing side channel worth defending here.
### Configuration
An `auth` table sits beside `tls` in every network plugin's `config`. Keeping it
separate from `tls` matters: TLS answers "is this channel private and does the
peer chain to a CA", auth answers "may *this* peer do *this*", and a later
non-certificate method can reuse the block.
```toml
[pipelines.plugin_sources.config.auth]
type = "mtls" # none (default) | mtls
identity = "cn" # cn | san_dns | san_uri | san_email
allow = ["edge-01", "edge-02"] # exact identities
allow_patterns = ["^edge-\\d{2}$"] # RE2, anchored by the author
node_binding = "force" # none | assert | force
```
| Option | Type | Default | Meaning |
|--------|------|---------|---------|
| `type` | string | `none` | `none` preserves pre-auth behaviour exactly; `mtls` enables the policy |
| `identity` | string | `cn` | Which certificate field is the identity |
| `allow` | []string | `[]` | Exact identity matches |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity |
| `node_binding` | string | `force` when `type = "mtls"` | Chain sources only; see below |
Empty `allow` **and** empty `allow_patterns` under `type = "mtls"` means "any
identity the CA vouches for" — that is, the pre-auth behaviour, but with the
identity now recorded and node binding available. It is a deliberate, documented
default rather than a silent deny-all, and the plugin logs a WARN at startup
saying so.
`node_binding` applies only to the chain sources, where a `node` label is
declared. Setting it on any other plugin is a configuration error.
| Value | Connection label | Per-entry `node` field |
|-------|------------------|------------------------|
| `none` | `trust_node` governs, as before | `trust_node` governs |
| `assert` | Must equal the identity; a mismatch or an omission is rejected | `trust_node` governs |
| `force` | The declared label is ignored and the identity is used | Overwritten with the identity |
The split between `assert` and `force` is what makes both worth having:
- **`force`** is for an ingest boundary that does not trust its peer. Every
entry is relabeled, so a compromised edge cannot smuggle a foreign origin
through the per-entry `node` field either. It is the default under
`type = "mtls"` because it is the only setting where a misconfigured or
hostile edge cannot mislabel its entries.
- **`assert`** is for a relay-to-relay hop. The relay must prove *its own*
identity — a mismatch is loud rather than silently corrected — but the entries
it forwards keep the origin labels stamped at the first hop, so multi-hop
attribution survives.
`node_binding` overrides `trust_node`; when binding is active the constructor
logs that `trust_node` is being ignored.
Dialer-side plugins (`tcp_chain` and `http_chain` sinks) accept the same block
to pin the *server's* identity beyond hostname verification. There
`node_binding` does not apply, and `tls.insecure_skip_verify` is rejected:
identity read from an unverified chain is a claim, not a fact.
### Validation
At plugin construction, before anything binds:
- `type = "mtls"` on a listener requires `tls.enabled = true` and
`tls.client_auth = true`; on a dialer it requires `tls.enabled = true` and
forbids `tls.insecure_skip_verify`. Silently accepting an auth policy the
transport cannot enforce is the failure mode worth designing out.
- `identity` must be one of the four modes.
- Every entry in `allow_patterns` must compile.
- `node_binding` must be one of the three values, and must be absent or `none`
outside the chain sources.
Errors follow existing style: `auth: type "mtls" requires tls.client_auth`.
### The `internal/authz` package
```go
package authz
// Policy is the compiled form of config.AuthOptions.
type Policy struct { /* role, identity mode, exact set, patterns, binding, counters */ }
// Role selects the validation and behavior appropriate to the call site.
const ( RoleListener Role = iota; RoleChainListener; RoleDialer )
// New compiles a policy. Returns (nil, nil) when auth is disabled, matching
// the tlsx.Server / tlsx.Client convention. tlsOpts is the sibling `tls`
// block, so an unenforceable policy fails here rather than at run time.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error)
// Identity is the outcome of a successful authorization.
type Identity struct {
Name string // the selected certificate field
Method string // "mtls"
}
// Apply stamps an identity onto session metadata.
func (id Identity) Apply(meta map[string]any)
// Authorize extracts and checks the peer identity from a completed handshake.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error)
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error
// ResolveNode applies node_binding to the label a peer declared.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error)
// TrustsEntryNode reports whether per-entry node labels survive the policy.
func (p *Policy) TrustsEntryNode(trustNode bool) bool
// Stats reports counters for the sink/source stats map.
func (p *Policy) Stats() map[string]any
```
This mirrors `internal/tlsx`: one small package that is the single seam between
declarative config and a cross-cutting concern. `New` returns `(nil, nil)` for
the disabled case and **every method tolerates a nil receiver**, so a call site
reads identically whether or not auth is configured — no nil checks, no branch
on config:
```go
id, err := s.auth.Authorize(tlsState) // nil policy: (zero Identity, nil)
if err != nil { /* reject */ }
```
### Enforcement Points
**`tcp_chain` source** (`internal/source/tcpchain/tcpchain.go`, `handleConn`)
Handshake → **authorize** → read hello → `ResolveNode` → create session. The
authorization sits between the handshake and the hello read, so an unauthorized
peer never gets a preamble parsed on its behalf. `chain.DecodeEntry` is then
called with `auth.TrustsEntryNode(trust_node)` rather than `trust_node` itself.
**`http_chain` source** (`internal/source/httpchain/httpchain.go`, `handleIngest`)
Per request, from `r.TLS`, before the body is read — an unauthorized sender does
not get to stream `max_body_bytes` into the process. Rejection is `403`,
distinct from the `400` used for protocol errors, so a sender can tell "you are
not allowed" from "your batch was malformed". `ResolveNode` then governs the
`X-Logwisp-Node` header exactly as it governs the TCP hello. The session cache
key includes the identity, so two peers sharing a remote address never share a
session.
**`tcp` sink** (`internal/sink/tcp/tcp.go`, `handleConn`)
After the explicit handshake, before the session is created and the client is
registered — so an unauthorized peer never appears in the client map and never
receives a broadcast.
**`http` sink** (`internal/sink/http/http.go`, `authMiddleware`)
A middleware around the mux covers both the stream and the status endpoint with
one wrapper and keeps the handlers themselves unaware of authorization. The
authorized identity is passed down through the request context for session
metadata. Rejections are `403` with no body detail — the status endpoint leaks
host, port, and throughput counters, so a rejection should not leak policy shape
on top of that.
**`tcp_chain` / `http_chain` sinks** (dialers)
The policy is installed as `tls.Config.VerifyConnection`, which runs after the
standard chain and hostname checks. A server whose identity the policy rejects
fails the handshake itself rather than the first write, and the chain sink's
existing backoff loop handles it as any other connect failure.
### Capabilities
`core.CapAuth` now means "this plugin authorizes peers" — it is derived from the
policy, not from `tlsConfig.ClientAuth`. Transport-level mTLS without a policy
still reports `CapTLS`, the `mtls=true` field on the startup log line, and the
`tls` statistic.
`Pipeline.initSourceCapabilities` and `initSinkCapabilities` treat this as a
cross-cutting check: a plugin advertising `CapAuth` without `CapTLS` is a
contradiction and fails pipeline construction rather than starting.
### Observability
Every authorization decision is visible, because a silent deny is
indistinguishable from a network fault at 3am.
- **Session metadata** gains `auth_method` and `auth_identity` alongside the
existing `tls` and `tls_peer_cn`.
- **Statistics** gain `auth`, `auth_identity` (the mode), `auth_unrestricted`,
`auth_allowed`, `auth_rejected`, and — on chain sources — `node_binding`, in
the `details` map of every affected source and sink. They surface in the
status reporter and in the `http` sink's status endpoint.
- **Logs** record a WARN per rejection with the remote address and the reason.
The startup line carries a rendered policy summary
(`auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=force"`),
a WARN when the allow list is empty, and an INFO when node binding overrides
`trust_node`.
### Revocation
Certificate revocation is handled by the allow-list rather than by CRL or OCSP:
1. Remove the identity from `allow` / `allow_patterns`.
2. `kill -HUP`.
The reload path rebuilds every pipeline, so the policy takes effect on the next
connection and existing connections are dropped by the rebuild itself. This is
one moving part instead of three, it needs no network calls on the handshake
path, and it is exact — no window between revocation and the next CRL
publication.
## Compatibility
No configuration breaks. Omitting the `auth` block, or setting `type = "none"`,
reproduces the previous behaviour exactly: `Policy` is nil, every call site
short-circuits, and `trust_node` continues to govern node labels.
The one behavioural note for adopters: turning on `type = "mtls"` defaults
`node_binding` to `force`, so entries from a peer whose certificate identity
differs from its configured `node` label will be relabelled. That is the point
of the feature, but it moves data between labels in a dashboard, so plan for it.
Use `node_binding = "assert"` on relay-to-relay hops where upstream origin
labels must survive.
## Verification
`test/mtls-chain-test.sh` builds a full PKI with `openssl` and exercises both
target topologies end to end:
```
./test/mtls-chain-test.sh --auto
```
Scenario 1 — chained instances, client authenticating with mTLS:
- an authorized edge (`edge-01`) delivers entries through both the `tcp_chain`
and `http_chain` ingest ports into a file sink
- `node_binding = "force"` overrides the label the sender configured
- an identity outside the allow list (`edge-99`) is refused, even while claiming
to be `edge-01`
- a peer presenting no certificate fails the handshake
- a dialer that pins a server identity the relay does not hold refuses to
connect, even though the server certificate chains to the trusted CA
Scenario 2 — a viewer client reading a streaming sink over mTLS:
- an authorized viewer streams from the `tcp` sink and from the `http` sink's
SSE endpoint, and reads `/status`
- a CA-valid but unauthorized viewer gets nothing from the `tcp` sink and `403`
from both `http` sink endpoints
- a client with no certificate fails the handshake
- the status endpoint reports the policy and its rejection count
`test/chain-test.sh` and `test/chain-aggregate-test.sh` continue to pass
unchanged, which is the regression check for the auth-disabled path.
## Not Implemented
The remaining phase-4 items, in rough order of value:
1. **CRL file support** alongside `client_ca_file`, re-read on reload, for
operators with existing CRL infrastructure. The allow-list covers the same
ground with fewer moving parts, so this is only worth doing for a fleet whose
revocation already flows through a CRL.
2. **Certificate expiry warnings** at startup and on reload — a leaf expiring
inside 30 days logged at WARN. Nothing warns today; expiry shows up as a
handshake failure.
3. **Per-identity rate limits.** The natural follow-on now that identity exists,
and the natural home for the per-IP limiting that was also removed. Kept out
of scope here so this feature stayed reviewable.
4. **Per-client identity in the `http` sink's status output.** The endpoint
reports the policy and counters, but not which identities are currently
connected; session metadata has the data.
5. **A list of `identity` modes** (try `san_uri`, fall back to `cn`) for
heterogeneous PKI. A single mode is simpler and covers a uniform CA.
## Decisions Taken
Four questions were left open by the proposal. What was chosen, and why:
1. **An empty allow-list allows rather than denies.** Deny-by-default is the
safer instinct, but `type = "mtls"` with no list is a legitimate
configuration — "any peer this CA issued, but bind the node labels" — and
node binding alone is worth enabling without enumerating every node.
Erroring on it would force operators to list their whole fleet to get
trustworthy attribution. The compromise is a WARN at startup naming the
condition and the fix.
2. **`identity` takes a single mode.** Simpler, and a uniform CA is the common
case. Listed above as a possible extension.
3. **Per-identity rate limits are out of scope**, as proposed.
4. **`assert` rejects rather than warns and corrects.** A certificate/config
mismatch under `assert` is an outage, which is the point: `force` is the
forgiving option and it is the default, so an operator reaches for `assert`
precisely when they want the mismatch to be loud.
+57 -170
View File
@@ -1,103 +1,37 @@
# Networking # Networking
Everything LogWisp does over a socket, and the knobs that shape it. For *Note: Under redesign*
certificates and trust see [Security](security.md); for multi-node topologies
see [Chaining](chaining.md).
## Address Family ## TLS Configuration
**All listeners bind `tcp4` and all dialers dial `tcp4`.** IPv6 is not *Note: As of the latest architecture updates, network TLS, mTLS, and Rate Limiting features are undergoing a redesign and are currently acting as placeholders. The documentation below details the structure for future updates.*
supported, deliberately. An IPv6 client cannot connect and will simply see a
connection failure.
When testing locally use `127.0.0.1`, not `localhost` — the latter may resolve ## Connection Management
to `::1` and appear as an unexplained connection refusal.
## Network Plugins ### TCP Keep-Alive
| Plugin | Role | Protocol | Purpose |
|--------|------|----------|---------|
| `tcp` sink | Listener | Raw stream | Broadcast formatted payloads to clients |
| `http` sink | Listener | HTTP SSE | Browser-friendly live stream plus status JSON |
| `tcp_chain` source | Listener | Chain v1 | Ingest a persistent NDJSON stream |
| `http_chain` source | Listener | Chain v1 | Ingest NDJSON batches over POST |
| `tcp_chain` sink | Dialer | Chain v1 | Forward entries over a persistent connection |
| `http_chain` sink | Dialer | Chain v1 | Forward entries as batched POSTs |
There is no port registry and no default port: `port` is required on every
network plugin. There is also no cross-pipeline conflict detection — two sinks
on the same port fail at bind time when the pipeline starts:
```
ERROR msg="Failed to start sink" error="tcp sink bind 0.0.0.0:9090: listen tcp4 0.0.0.0:9090: bind: address already in use"
```
## Timeouts
Every network plugin exposes the deadlines relevant to its role. Zero means "no
deadline" wherever the table says so.
| Plugin | Option | Default | Bounds |
|--------|--------|---------|--------|
| `tcp` sink | `write_timeout_ms` | `5000` | One write to one client; a miss disconnects that client |
| `http` sink | `write_timeout_ms` | `0` (none) | One SSE event write |
| `tcp_chain` source | `hello_timeout_ms` | `10000` | Reading the protocol preamble |
| `tcp_chain` source | `read_timeout_ms` | `0` (none) | Idle time between entries |
| `http_chain` source | `read_timeout_ms` | `30000` | Reading a whole request body |
| `tcp_chain` sink | `dial_timeout_ms` | `5000` | TCP connect |
| `tcp_chain` sink | `write_timeout_ms` | `5000` | One line write |
| `http_chain` sink | `request_timeout_ms` | `10000` | Dial plus write plus response |
Fixed, non-configurable bounds:
| Bound | Value | Applies to |
|-------|-------|------------|
| TLS handshake | 10 s | All TLS listeners and dialers |
| HTTP read-header timeout | 10 s | `http` sink, `http_chain` source |
| HTTP server shutdown grace | 2 s | `http` sink, `http_chain` source |
| Max single entry line | 1 MiB | Chain listeners |
The `http` sink deliberately leaves the server's `WriteTimeout` unset, since it
would terminate long-lived SSE streams; per-event deadlines come from
`write_timeout_ms` instead.
## Connection Limits
`max_connections` caps concurrent connections on the `tcp` sink, the `http`
sink, and the `tcp_chain` source. `0` means unlimited.
- Admission is a load-then-check, so a burst can over-admit by roughly one
connection. This is accepted, not a bug to work around.
- On the `tcp` sink and `tcp_chain` source the count is taken at accept, so it
bounds concurrent TLS handshakes as well as established sessions.
- Over-limit connections are closed immediately and counted in `rejected_conns`
(TCP) or `rejected_clients` (HTTP, which first answers `503`).
The `http_chain` source has no connection cap; it bounds work with
`max_body_bytes` and `read_timeout_ms` instead.
There is **no** per-IP limiting and no IP allow/deny list. `flow.rate_limit` is
a pipeline-wide entry rate limit, not a network-level one — it cannot
distinguish or throttle an individual peer.
## Keep-Alive
TCP keep-alive is available on the `tcp` sink (for accepted connections) and the
`tcp_chain` sink (for its outbound connection):
```toml ```toml
[[pipelines.plugin_sinks]]
id = "tcp_out"
type = "tcp"
[pipelines.plugin_sinks.config]
keep_alive = true keep_alive = true
keep_alive_period_ms = 30000 keep_alive_period_ms = 30000 # 30 seconds
``` ```
This is kernel-level keep-alive; it detects a dead peer but does not keep an ### Connection Timeouts
application-level stream flowing. For that, use a heartbeat.
## Heartbeats ```toml
[[pipelines.plugin_sinks]]
id = "http_out"
type = "http"
[pipelines.plugin_sinks.config]
write_timeout_ms = 10000 # 10 seconds
```
Heartbeats are a **flow-level** feature, not a per-sink one. Enabling one ## Heartbeat Configuration
injects a synthetic entry into the pipeline at a fixed interval; it reaches
every sink and traverses chain links as an ordinary structured entry. Keep connections alive with periodic heartbeats. Note that Heartbeat is a flow-level feature in the new architecture.
```toml ```toml
[pipelines.flow.heartbeat] [pipelines.flow.heartbeat]
@@ -105,104 +39,57 @@ enabled = true
interval_ms = 30000 interval_ms = 30000
include_timestamp = true include_timestamp = true
include_stats = false include_stats = false
format = "txt" # txt | json | raw format = "comment" # comment|event|json
``` ```
Use it to keep idle SSE clients, TCP clients, and chain links from being ## Network Protocols
reaped by intermediate NAT or proxy timeouts, and to make an idle pipeline
visibly alive.
> `format = "comment"` (SSE `:` comment framing) is rejected by validation ### HTTP/HTTPS
> despite appearing in older documentation and in a still-present code branch.
> A pipeline configured with it fails to start.
## Reconnection - HTTP/1.1 support
- Persistent connections
- Server-Sent Events (SSE)
Chain sinks reconnect on their own. Both use exponential backoff between ### TCP
`backoff_min_ms` and `backoff_max_ms` with ±20 % jitter, and both are
interruptible by shutdown.
```toml - Raw TCP sockets
backoff_min_ms = 500 - Newline-delimited protocol
backoff_max_ms = 30000
```
The connection is established lazily, so an edge node starts cleanly even when ## Port Configuration
its relay is down and connects as soon as the relay appears. Reconnect counts
are reported in the sink's `reconnects` statistic.
Server-side sinks (`tcp`, `http`) do not reconnect; clients are expected to ### Default Ports
retry. Browsers reconnect SSE streams automatically.
## Protocol Details | Service | Default Port | Protocol |
|---------|--------------|----------|
| HTTP Sink | 8080 | HTTP |
| TCP Sink | 9090 | TCP |
**HTTP sink (SSE)** — HTTP/1.1 in plaintext; HTTP/2 is negotiated via ALPN when ### Port Conflict Prevention
TLS is enabled. Only `GET` is routed to the stream and status paths. Each event
is framed as one `data:` line per newline in the payload, so multi-line entries
stream intact. Response headers set `Cache-Control: no-cache`,
`X-Accel-Buffering: no`, and `Access-Control-Allow-Origin: *`.
**TCP sink** — raw payload bytes, no framing added by the sink. Whether entries LogWisp validates port usage at startup:
are newline-delimited depends on the formatter. - Detects port conflicts across pipelines
- Prevents duplicate bindings
**Chain transports** — see [Chaining](chaining.md) for the hello preamble,
headers, and entry encoding.
## Troubleshooting ## Troubleshooting
**Connection refused** ### Common Issues
- Confirm the pipeline started; a bind failure is logged at ERROR.
- Confirm you are dialing IPv4. `localhost` may resolve to `::1`.
- Check the port is not already bound by another pipeline in the same process.
**TLS handshake failure** **Connection Refused**
- `client didn't provide a certificate` — the listener has `client_auth = true` - Check firewall rules
and the dialer has no `cert_file`/`key_file`. - Verify service is running
- `certificate signed by unknown authority` — the dialer's `ca_file` does not - Confirm correct port/host
contain the issuer of the server certificate, or the listener's
`client_ca_file` does not contain the issuer of the client certificate.
- `certificate is not valid for any names` / SAN mismatch — the dialed `host`
is not covered by the server certificate's SANs; set `server_name`.
- `protocol version not supported` — one side is pinned to `min_version = "1.3"`
and the other cannot negotiate it.
- Handshake failures appear as WARN with the remote address, and increment
`tls_handshake_errors`.
**Rejected after a successful handshake** **TLS Handshake Failure**
- `auth: identity "..." is not allowed` — the certificate is valid and chains to - Verify certificate validity
the CA, but the identity is not in `auth.allow` / `auth.allow_patterns`. On - Check certificate chain
TCP the connection is closed; on HTTP the answer is `403`. - Confirm TLS versions match
- `auth: peer certificate carries no <mode> identity``auth.identity` names a
field the certificate does not populate, e.g. `san_dns` on a CN-only leaf.
- `auth: node_binding "assert": declared node "..." does not match identity`
the sender's `node` option and its certificate disagree. Fix one, or use
`node_binding = "force"` to let the certificate win silently.
- On a dialer, the same message inside `Chain connect failed` means the
*server* was refused: its certificate identity is not in the sink's
`auth.allow`.
- Rejections appear as WARN and increment `auth_rejected`.
**Entries not arriving over a chain link** **Rate Limit Exceeded**
- Check the sink's `connected` statistic and its `reconnects` count. - Adjust rate limit parameters
- Check the source's `auth_rejected` — an allow-list miss looks exactly like a - Add IP to whitelist
network fault from the sender's side. - Implement client-side throttling
- Check the source's `parse_errors` — a version skew shows up here.
- On `http_chain`, remember entries wait up to `flush_interval_ms` before a
batch is sent.
**Entries arriving under an unexpected node label** **Connection Timeout**
- `auth.node_binding` defaults to `force` when `auth.type = "mtls"`, which - Increase timeout values
relabels every entry with the sender's certificate identity. If a dashboard - Check network latency
suddenly shows a different label, that is why. Use `node_binding = "assert"` - Verify keep-alive settings
to keep upstream origin labels on relay-to-relay hops, or `"none"` to leave
`trust_node` in charge.
**Clients connect but see nothing**
- The pipeline may be filtering everything out; check `flow.filters` stats.
- The rate limiter may be dropping everything; check `rate_limiter` stats.
- Nothing may be arriving from the sources; check source `total_entries`.
**Entries missing under load**
- Compare `dropped_writes` (per-client queue full — raise
`client_buffer_size`) against `total_dropped_by_sink` (sink input queue full
— raise `buffer_size` or reduce sink latency).
+218 -211
View File
@@ -1,164 +1,130 @@
# Operations Guide # Operations Guide
Running, monitoring, and maintaining LogWisp. Running, monitoring, and maintaining LogWisp in production.
## Starting *Note: TLS, acccess control under redesign*
## Starting LogWisp
### Manual Start
```bash ```bash
# foreground, explicit config # Foreground with default config
logwisp -c /etc/logwisp/logwisp.toml
# no config: built-in demo pipeline (random source -> stdout)
logwisp logwisp
# Background mode
logwisp --background
# With specific configuration
logwisp --config /etc/logwisp/production.toml
``` ```
There is no built-in daemon mode. Run LogWisp in the foreground under a ### Service Management
supervisor — systemd, rc.d, or a container runtime — which is where restart,
log capture, and resource limits belong. See [Installation](installation.md).
**systemd**
**Linux (systemd):**
```bash ```bash
sudo systemctl start logwisp sudo systemctl start logwisp
sudo systemctl stop logwisp
sudo systemctl restart logwisp
sudo systemctl status logwisp sudo systemctl status logwisp
sudo journalctl -u logwisp -f
``` ```
**FreeBSD rc.d** **FreeBSD (rc.d):**
```bash ```bash
sudo service logwisp start sudo service logwisp start
sudo service logwisp stop
sudo service logwisp restart
sudo service logwisp status sudo service logwisp status
``` ```
## Configuration Changes ## Configuration Management
### Hot reload ### Hot Reload
Enable automatic configuration reload:
```toml ```toml
auto_reload = true config_auto_reload = true
``` ```
or send a signal: Or via command line:
```bash
logwisp --config-auto-reload
```
Trigger manual reload:
```bash ```bash
kill -HUP $(pidof logwisp) kill -HUP $(pidof logwisp)
# or
kill -USR1 $(pidof logwisp)
``` ```
Reload constructs a new service from the new configuration **before** tearing ### Configuration Validation
the old one down, so a broken configuration leaves the running service intact
and logs the failure:
```
ERROR msg="Failed to bootstrap new service, keeping old service running" error=...
```
What reload does *not* do:
- Re-apply `logging.*`; application logging is configured once at startup.
- Preserve connections. Listeners close and reopen, and every SSE, TCP, and
chain client is disconnected. Chain sinks reconnect on their own backoff;
browsers reconnect SSE automatically; raw TCP consumers must retry themselves.
- Reload certificates without a reload — certificate files are read at plugin
construction, so rotation requires `SIGHUP`.
Plan reloads on a busy relay the way you would plan a restart.
### Checking a configuration
There is no validate-only mode. To check a file, start it with debug logging and
watch for pipeline startup:
Test configuration without starting:
```bash ```bash
logwisp -c candidate.toml --logging.level=debug --logging.output=stderr logwisp --config test.toml --quiet --status-reporter=false
``` ```
Success looks like `Created source instance`, `Created sink instance`, and Check for errors:
`Starting pipeline` for each pipeline. Failures name the pipeline and the - Port conflicts
offending key: - Invalid patterns
- Missing required fields
``` - File permissions
ERROR msg="Failed to create pipeline" pipeline=app error="failed to create sink out: port: must be 1-65535, got 0"
```
Remember that most validation lives in plugin constructors, so a config only
proves itself when the pipeline is actually built.
## Monitoring ## Monitoring
### Status reporter ### Status Reporter
Enabled by default, every 30 seconds. It logs at **DEBUG**, so it produces Built-in periodic status logging (30-second intervals):
nothing unless `logging.level = "debug"` — a common surprise.
```
[INFO] Status report active_pipelines=2 time=15:04:05
[INFO] Pipeline status pipeline=app entries_processed=10523
[INFO] Pipeline status pipeline=system entries_processed=5231
```
Disable if not needed:
```toml ```toml
status_reporter = true disable_status_reporter = true
[logging]
level = "debug"
``` ```
It emits a service summary and then walks each pipeline, flattening scalar ### HTTP Status Endpoint
statistics into log fields and recursing into flow, rate limiter, filter,
source, and sink stats.
Disable with `status_reporter = false`.
### HTTP status endpoint
When a pipeline has an `http` sink:
When using HTTP sink:
```bash ```bash
curl -s http://127.0.0.1:8080/status | jq . curl http://localhost:8080/status | jq .
``` ```
Response structure:
```json ```json
{ {
"service": "LogWisp", "uptime": "2h15m30s",
"version": "v0.16.0", "pipelines": {
"instance_id": "sse", "default": {
"server": { "sources": 1,
"type": "http", "sinks": 2,
"host": "0.0.0.0", "processed": 15234,
"port": 8080, "filtered": 523,
"tls": false, "dropped": 12
"active_clients": 3, }
"buffer_size": 1000,
"client_buffer_size": 256,
"max_connections": 32,
"write_timeout_ms": 5000,
"uptime_seconds": 8130
},
"endpoints": { "stream": "/stream", "status": "/status" },
"statistics": {
"total_processed": 15234,
"dropped_writes": 12,
"rejected_clients": 0
} }
} }
``` ```
This endpoint is scoped to one sink, not to the whole process, and it is ### Metrics Collection
**unauthenticated**. Bind it to a trusted interface.
### Metrics worth watching Track via logs:
- Total entries processed
| Metric | Where | Meaning if rising | - Entries filtered
|--------|-------|-------------------| - Entries dropped
| `dropped_entries` | source | Downstream cannot keep up with the source | - Active connections
| `total_dropped` | flow | Rate limit or filters are discarding entries (often intended) | - Buffer utilization
| `total_dropped_by_sink` | pipeline | A sink's input queue is full |
| `dropped_writes` | tcp/http sink | A client's queue overflowed: either it is too slow, or one burst exceeded `client_buffer_size` |
| `rejected_conns` / `rejected_clients` | tcp/http sink, tcp_chain source | `max_connections` is being hit |
| `tls_handshake_errors` | tcp sink, tcp_chain source | Certificate or version mismatch, or scanning |
| `parse_errors` | chain source | Protocol or version skew upstream |
| `reconnects` | chain sink | Unstable link or a flapping downstream |
| `dropped_batches` | http_chain sink | Downstream rejecting batches permanently |
| `synthesized` | chain sink | Events reaching the sink without structure |
## Log Management ## Log Management
LogWisp's own operational log: ### LogWisp's Operational Logs
Configuration for LogWisp's own logs:
```toml ```toml
[logging] [logging]
@@ -169,152 +135,193 @@ level = "info"
directory = "/var/log/logwisp" directory = "/var/log/logwisp"
name = "logwisp" name = "logwisp"
max_size_mb = 100 max_size_mb = 100
max_total_size_mb = 1000 retention_hours = 168
retention_hours = 168.0
``` ```
Rotation is automatic on size, with a total-size cap and a retention window. ### Log Rotation
There is no signal to reopen log files, so do not move files out from under
LogWisp and expect it to reattach — let it rotate, or restart it.
Production level: `info`, or `warn` on a busy relay. Avoid `debug` under load: Automatic rotation based on:
the filter stage logs several lines per entry evaluated. - File size threshold
- Total size limit
- Retention period
Manual rotation:
```bash
# Move current log
mv /var/log/logwisp/logwisp.log /var/log/logwisp/logwisp.log.1
# Send signal to reopen
kill -USR1 $(pidof logwisp)
```
### Log Levels
Operational log levels:
- **debug**: Detailed debugging information
- **info**: General operational messages
- **warn**: Warning conditions
- **error**: Error conditions
Production recommendation: `info` or `warn`
## Performance Tuning ## Performance Tuning
### Buffers ### Buffer Sizing
Raise `buffer_size` when `total_dropped_by_sink` is climbing but the sink itself Adjust buffers based on load:
is healthy — that is a burst-absorption problem.
`dropped_writes` on a network sink has two causes that a counter alone does not
separate. A consumer slower than the sustained rate cannot be bought off with
buffer, and drops are the intended outcome. A burst the consumer would have
drained, arriving faster than it reads, is configuration: the sink queues a
whole burst while the client writes one frame at a time, so the part of a burst
above `client_buffer_size` is lost even to a loopback reader.
Where a `rate_limit` bounds the pipeline, its `burst` is that number — keep
`client_buffer_size` at or above it and the second cause disappears. The HTTP
status endpoint reports both queue bounds alongside the counters so an operator
can tell which one is in play.
```toml ```toml
[pipelines.plugin_sinks.config] [[pipelines.plugin_sources]]
buffer_size = 5000 id = "file_in"
client_buffer_size = 1024 type = "file"
[pipelines.plugin_sources.config]
``` ```
### Rate limiting ### Rate Limiting
Protect against overload:
```toml ```toml
[pipelines.flow.rate_limit] [pipelines.flow.rate_limit]
rate = 1000.0 rate = 1000.0 # Entries per second
burst = 2000.0 burst = 2000.0 # Burst capacity
policy = "drop" policy = "drop" # Drop excess entries
max_entry_size_bytes = 65536
``` ```
Two behaviours to keep in mind: the limiter does not exist at all when
`rate <= 0`, and `policy = "pass"` short-circuits the size cap as well as the
rate check. Enforcing `max_entry_size_bytes` therefore requires `rate > 0` and
`policy = "drop"`.
### Formatting
`raw` is the cheapest and skips sanitization; `json` costs the most. The
formatter serializes on a mutex, so it is the one shared bottleneck in a
pipeline — splitting work across pipelines parallelizes it.
### Chain batching
`http_chain` trades latency for efficiency. Lower `flush_interval_ms` for
freshness, raise `max_batch_count` and `max_batch_bytes` for throughput. Use
`tcp_chain` when per-entry latency matters.
## Troubleshooting ## Troubleshooting
**Nothing appears at the sink** ### Common Issues
Walk the pipeline in order and read the counters: source `total_entries` (is **High Memory Usage**
anything being produced?), flow `total_dropped` (filters or rate limit?), - Check buffer sizes
pipeline `total_dropped_by_sink` (sink backed up?), sink `total_processed`. - Monitor goroutine count
- Review retention settings
**File source reads nothing** **Dropped Entries**
- Increase buffer sizes
- Add rate limiting
- Check sink performance
- The watcher seeks to end-of-file on start; only content appended afterwards is **Connection Errors**
read. Positions are in memory, so a restart re-seeks to end and anything - Verify network connectivity
written during the downtime is lost. `from = "start"` reads each file whole - Check firewall rules
instead, and replays it on every restart. - Review TLS certificates
- `pattern` is a filename glob with `*` and `?` only, and matching is not
recursive.
- `check_interval_ms` governs how quickly a *new file* is noticed; tailing an
open file polls at a fixed 100 ms.
**High memory use** ### Debug Mode
Buffers are bounded, so unbounded growth almost always means many buffers: Enable detailed logging:
count sinks × `buffer_size`, plus clients × `client_buffer_size`. A `tcp_chain` ```bash
sink blocked on an unreachable downstream also holds its full input queue. logwisp --logging.level=debug --logging.output=stderr
```
**Chain link not delivering** ### Health Checks
Check `connected` and `reconnects` on the sink, `parse_errors` on the source, Implement external monitoring:
and remember `http_chain` waits up to `flush_interval_ms`. For TLS problems see ```bash
[Networking](networking.md#troubleshooting). #!/bin/bash
# Health check script
if ! curl -sf http://localhost:8080/status > /dev/null; then
echo "LogWisp health check failed"
exit 1
fi
```
**Environment variable override has no effect** ## Backup and Recovery
LogWisp currently reads these **without** the `LOGWISP_` prefix — `QUIET`, ### Configuration Backup
`LOGGING_LEVEL`, and so on. Array-indexed paths cannot be set from the
environment or the command line at all. ```bash
# Backup configuration
cp /etc/logwisp/logwisp.toml /backup/logwisp-$(date +%Y%m%d).toml
# Version control
git add /etc/logwisp/
git commit -m "LogWisp config update"
```
### State Recovery
LogWisp maintains minimal state:
- File read positions (automatic)
- Connection state (automatic)
Recovery after crash:
1. Service automatically restarts (systemd/rc.d)
2. File sources resume from last position
3. Network sources accept new connections
4. Clients reconnect automatically
## Security Operations ## Security Operations
**Certificate rotation** ### Certificate Management
Monitor certificate expiration:
```bash ```bash
openssl x509 -in /etc/logwisp/tls/relay.crt -noout -enddate openssl x509 -in /path/to/cert.pem -noout -enddate
``` ```
Certificates load at plugin construction, so rotation is: write the new files, Rotate certificates:
then `kill -HUP`. Automate the expiry check; nothing in LogWisp warns you. 1. Generate new certificates
2. Update configuration
3. Reload service (SIGHUP)
**Access review** ### Access Auditing
With `tls` alone, any certificate signed by the configured `client_ca_file` is Monitor access patterns:
accepted, so "access review" means reviewing what your CA has issued. Add an - Review connection logs
`auth` block with an explicit `allow` list and the review becomes the config - Monitor rate limit hits
file itself: the identities listed there are the ones that can connect, and
removing one plus a `SIGHUP` is the revocation path. Authorized identities are
recorded in session metadata as `auth_identity`; rejections are counted in
`auth_rejected` and logged at WARN. See
[Security](security.md#the-auth-block).
**Secret leakage**
Filters are the only redaction mechanism, and they drop whole entries rather
than masking parts of them. See [Filters](filters.md#common-recipes).
## Maintenance ## Maintenance
**Upgrades** ### Planned Maintenance
1. Read the changelog for configuration-schema changes. 1. Notify users of maintenance window
2. Start the new binary against the current configuration in a scratch 2. Stop accepting new connections
environment. 3. Drain existing connections
3. Stop the old process, install the new binary, start it. 4. Perform maintenance
4. Confirm each pipeline started and that counters are advancing. 5. Restart service
**Backup** ### Upgrade Process
Configuration files and TLS material are the only durable state worth backing 1. Download new version
up. LogWisp keeps no persistent runtime state: file read positions live in 2. Test with current configuration
memory, connections are re-established on restart, and in-flight entries are 3. Stop old version
lost. 4. Install new version
5. Start service
6. Verify operation
**Redundancy** ### Cleanup Tasks
Because there is no persistence, availability comes from topology, not from Regular maintenance:
LogWisp itself. Give each edge two chain sinks pointing at two relays if you - Remove old log files
need to survive a relay outage, and accept that this duplicates entries - Clean temporary files
downstream. - Verify disk space
- Update documentation
## Disaster Recovery
### Backup Strategy
- Configuration files: Daily
- TLS certificates: After generation
- Authentication credentials: Secure storage
### Recovery Procedures
Service failure:
1. Check service status
2. Review error logs
3. Verify configuration
4. Restart service
Data loss:
1. Restore configuration from backup
2. Regenerate certificates if needed
3. Recreate authentication credentials
4. Restart service
### Business Continuity
- Run multiple instances for redundancy
- Use load balancer for distribution
- Implement monitoring alerts
- Document recovery procedures
+1 -392
View File
@@ -1,395 +1,4 @@
# Security # Security
This page covers LogWisp's transport security: what it protects, how to *Note: Security features like mTLS and IP-Based Access Control are currently under redesign and act as placeholders in the new architecture. Future versions will reintroduce full security capabilities.*
configure it, and — equally important — what it does not yet do.
## Current State
| Capability | Status |
|------------|--------|
| TLS 1.2 / 1.3 on all network sources and sinks | Implemented |
| Server certificate verification by dialers | Implemented |
| Mutual TLS (client certificate required and verified) | Implemented at the transport layer |
| Peer identity recorded per session | Implemented |
| Authorization from peer identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Authentication on the `http` sink's stream and status endpoints | Implemented, via the auth block |
| Server identity pinning by dialers | Implemented, via the auth block |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list |
| Password, token, or SCRAM authentication | **Removed**; not currently available |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** |
Earlier releases carried basic-auth, bearer-token, and SCRAM authentication.
Those were removed during the move to the plugin/flow architecture and the
switch to standard-library networking. Certificates are the one credential the
transport still carries, so they are what authentication is built on: the `tls`
block establishes that a peer chains to your CA, and the `auth` block decides
which peers that CA vouches for may actually do what.
## The TLS Block
One option shape serves both roles, so the configuration reads the same
wherever it appears. Which keys matter depends on whether the plugin listens or
dials.
```toml
[pipelines.plugin_sources.config.tls] # or plugin_sinks.config.tls
enabled = false
cert_file = ""
key_file = ""
client_auth = false
client_ca_file = ""
ca_file = ""
server_name = ""
insecure_skip_verify = false
min_version = "1.3"
```
| Option | Role | Default | Description |
|--------|------|---------|-------------|
| `enabled` | both | `false` | Master switch; when false the whole block is ignored |
| `cert_file` | both | — | Local certificate. **Required** for listeners; optional client identity for dialers |
| `key_file` | both | — | Private key for `cert_file`. Must be set together with it |
| `client_auth` | listener | `false` | Require and verify a client certificate (mTLS) |
| `client_ca_file` | listener | — | CA bundle used to verify client certificates. **Required** when `client_auth` is true |
| `ca_file` | dialer | system store | CA bundle used to verify the server certificate |
| `server_name` | dialer | the configured `host` | SNI and certificate name to verify against |
| `insecure_skip_verify` | dialer | `false` | Disable server verification |
| `min_version` | both | `"1.3"` | `"1.2"` or `"1.3"` |
**Roles by plugin:**
| Plugin | Role | Keys that apply |
|--------|------|-----------------|
| `tcp` sink, `http` sink | Listener | `cert_file`, `key_file`, `client_auth`, `client_ca_file`, `min_version` |
| `tcp_chain` source, `http_chain` source | Listener | same as above |
| `tcp_chain` sink, `http_chain` sink | Dialer | `ca_file`, `server_name`, `insecure_skip_verify`, `cert_file`, `key_file`, `min_version` |
> `min_version` takes `"1.2"` or `"1.3"`. The older `"TLS1.2"` spelling from
> pre-restructure releases is rejected. There is no `max_version` and no
> `cipher_suites` option; TLS 1.3 suites are not configurable in Go, and the
> 1.2 defaults are the standard library's.
### Validation
Misconfiguration fails at plugin construction, before the pipeline starts:
- a listener with `enabled = true` and no `cert_file`/`key_file`
- `client_auth = true` with no `client_ca_file`
- a dialer with only one of `cert_file` / `key_file`
- a certificate or key that will not load, or a CA file containing no
certificates
- a `min_version` that is neither `"1.2"` nor `"1.3"`
## The Auth Block
TLS answers "is this channel private, and does the peer chain to a CA". Auth
answers "may *this* peer do *this*". They are separate blocks because they are
separate questions, and because a later non-certificate method should be able to
reuse the second one.
```toml
[pipelines.plugin_sources.config.auth] # or plugin_sinks.config.auth
type = "none" # none | mtls
identity = "cn" # cn | san_dns | san_uri | san_email
allow = []
allow_patterns = []
node_binding = "force" # chain sources only
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `none` | `none` ignores the whole block; `mtls` authorizes by certificate identity |
| `identity` | string | `cn` | Which certificate field carries the identity |
| `allow` | []string | `[]` | Exact identities to admit |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | string | `force` under `mtls` | Chain sources only: `none`, `assert`, or `force` |
**Roles by plugin:**
| Plugin | Role | Decides |
|--------|------|---------|
| `tcp_chain` source, `http_chain` source | Listener | Which senders may ingest, and what node label their entries carry |
| `tcp` sink, `http` sink | Listener | Which clients may read the stream (and, on `http`, the status endpoint) |
| `tcp_chain` sink, `http_chain` sink | Dialer | Which server identity to accept, beyond hostname verification |
### Identity
The identity is one string pulled from the peer's verified leaf certificate.
The handshake has already checked the chain, signature, and validity window, so
this is pure field selection.
| Mode | Source | Typical use |
|------|--------|-------------|
| `cn` (default) | `Subject.CommonName` | Matches the existing `tls_peer_cn` metadata |
| `san_dns` | first DNS SAN | Host identities |
| `san_uri` | first URI SAN | SPIFFE-style IDs |
| `san_email` | first email SAN | Operator identities |
A certificate with no usable value in the chosen field is rejected. An empty
identity is a refusal, not an empty match.
### The allow list
`allow` is an exact-match set; `allow_patterns` holds RE2 patterns. An identity
passes if it appears in either.
Leaving **both** empty under `type = "mtls"` admits any identity the CA vouches
for. That is deliberate — it is how you enable node binding without enumerating
a whole fleet — but it is announced rather than silent:
```
WARN msg="Auth policy admits any identity the configured CA vouches for"
component=tcp_chain_source instance_id=in_tcp
hint="set auth.allow or auth.allow_patterns to authorize named peers"
```
Anchor your patterns. `allow_patterns = ["edge-\\d{2}"]` matches
`evil-edge-01-impostor`; `["^edge-\\d{2}$"]` does not.
### Node binding
`node_binding` applies only to the chain sources, and it overrides `trust_node`.
| Value | Connection label | Per-entry `node` field |
|-------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the identity; a mismatch or an omission is rejected | `trust_node` governs |
| `force` | Ignored; the identity is used | Overwritten with the identity |
Use **`force`** on an ingest boundary you do not trust. Every entry is
relabelled, so a compromised edge cannot smuggle a foreign origin through the
per-entry `node` field either. It is the default under `type = "mtls"`.
Use **`assert`** on a relay-to-relay hop. The relay must prove its own identity —
a mismatch fails loudly instead of being silently corrected — but the entries it
forwards keep the origin labels stamped at the first hop, so multi-hop
attribution survives.
When binding is active the source says so at startup:
```
INFO msg="Node labels bound to peer identity; trust_node is ignored"
component=tcp_chain_source node_binding=force trust_node=true
```
### Dialer-side pinning
On a chain sink, the same block pins the *server's* identity. Hostname
verification already proves the server holds a certificate valid for the address
you dialed; pinning additionally requires that certificate to name an identity
you listed.
```toml
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
```
The check runs as part of the handshake, so a server the policy rejects never
receives an entry — the sink's normal backoff loop handles it like any other
connect failure. `insecure_skip_verify` is refused alongside `type = "mtls"`:
an identity read from an unverified chain is a claim, not a fact.
### Validation
Misconfiguration fails at plugin construction, before the pipeline starts:
- `type = "mtls"` on a listener without `tls.enabled` **and** `tls.client_auth`
- `type = "mtls"` on a dialer without `tls.enabled`, or with
`tls.insecure_skip_verify`
- an `identity` that is not one of the four modes
- an `allow_patterns` entry that does not compile
- a `node_binding` that is not one of the three values, or one set on a plugin
that has no node concept
Errors read like `auth: type "mtls" requires tls.client_auth`.
## Enabling mTLS
### 1. Generate a CA and certificates
LogWisp no longer ships a certificate-generation subcommand; the `logwisp tls`
command was removed with the rest of the CLI restructure. Use `openssl`,
`cfssl`, `step-cli`, or your existing PKI.
```bash
# CA
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \
-keyout ca.key -out ca.crt -subj "/CN=LogWisp CA"
# Relay (server) certificate — SAN must match how clients address it
openssl req -newkey rsa:2048 -nodes -keyout relay.key -out relay.csr \
-subj "/CN=relay.internal"
openssl x509 -req -in relay.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out relay.crt -days 825 \
-extfile <(printf "subjectAltName=DNS:relay.internal\nextendedKeyUsage=serverAuth")
# Edge (client) certificate — CN identifies the node
openssl req -newkey rsa:2048 -nodes -keyout edge-01.key -out edge-01.csr \
-subj "/CN=edge-01"
openssl x509 -req -in edge-01.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out edge-01.crt -days 825 \
-extfile <(printf "extendedKeyUsage=clientAuth")
```
The server certificate's SAN must cover the address clients dial. Dialers seed
`ServerName` from the configured `host`, so an IP literal in `host` requires an
IP SAN, and a DNS name requires a DNS SAN. Override with `server_name` when the
dialed address and the certificate name legitimately differ.
### 2. Configure the listener
```toml
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/relay.crt"
key_file = "/etc/logwisp/tls/relay.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/ca.crt"
min_version = "1.3"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
```
Without the `auth` block the listener accepts every certificate the CA issued.
With it, only `edge-01` and `edge-02` may ingest, and their entries are labelled
from their certificates rather than from whatever they declare.
### 3. Configure the dialer
```toml
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/edge-01.crt"
key_file = "/etc/logwisp/tls/edge-01.key"
min_version = "1.3"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
```
### 4. Verify
Startup logs report the transport flags and the compiled policy:
```
INFO msg="TCP chain source initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[2 exact, 0 pattern(s)] node_binding=force"
INFO msg="TCP chain sink initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=none"
```
A client that presents no certificate is refused during the handshake:
```
WARN msg="TLS handshake failed" component=tcp_chain_source
remote_addr=127.0.0.1:53840 error="tls: client didn't provide a certificate"
```
A client whose certificate is valid but whose identity is not authorized gets
past the handshake and is refused by the policy:
```
WARN msg="Connection rejected by auth policy" component=tcp_chain_source
remote_addr=127.0.0.1:33946 error="auth: identity \"edge-99\" is not allowed"
```
Handshake failures are counted in `tls_handshake_errors`; policy rejections in
`auth_rejected`. Both appear in the status reporter and in the `http` sink's
status endpoint. Accepted peers are recorded in session metadata as
`auth_method` and `auth_identity`.
`test/mtls-chain-test.sh` builds a throwaway PKI and exercises the whole surface
end to end — run it with `--auto` to see each guarantee asserted.
## What Each Layer Enforces
**`tls` with `client_auth = true`** — a membership check. The peer holds a
certificate chaining to `client_ca_file`, within its validity window, and holds
the matching private key. An attacker without a CA-issued certificate cannot
connect at all. What it does *not* decide is which CA-issued certificate: every
one is equivalent at this layer.
**`auth` with `type = "mtls"`** — an identity check, per listener:
- only the identities you list may connect, so one CA can serve several trust
domains and a single peer can be withdrawn without touching the others
- the chain `node` label is bound to the certificate, so a compromised edge
cannot attribute its entries to another host
- the `http` sink's stream and status endpoints stop being open to anyone who
can reach the port
**Revocation** is the allow-list, not a CRL. Remove the identity from `allow` /
`allow_patterns` and send `SIGHUP`: the reload rebuilds every pipeline, so the
change takes effect on the next connection and existing ones are dropped by the
rebuild. No network call on the handshake path, and no window between revocation
and the next CRL publication. See
[mtls-auth-plan.md](mtls-auth-plan.md#not-implemented) for what CRL support
would add.
## Surfaces Without Access Control
An `auth` block closes each of these. Without one, bind them to a trusted
interface or front them with an authenticating proxy.
| Surface | Exposure when `auth.type = "none"` |
|---------|-----------------------------------|
| `http` sink `stream_path` | Full log stream, with `Access-Control-Allow-Origin: *`, so any browser origin can read it |
| `http` sink `status_path` | Host, port, TLS flag, uptime, client counts, throughput counters |
| `tcp` sink | Full log stream to any client that connects |
| `tcp_chain` / `http_chain` source | Ingest from any peer the CA vouches for, under any node label it claims |
`max_connections` bounds concurrency on all of them but does not distinguish
callers.
Note that `auth` requires `client_auth = true`, which requires TLS. There is no
way to authenticate a plaintext listener.
## Operational Guidance
**Certificates**
- Use a dedicated CA for LogWisp so its trust decisions stay independent.
- Keep leaf lifetimes short (90825 days) and automate renewal.
- Key files should be `0600` and owned by the service account.
- Rotation requires a reload (`SIGHUP`), because certificates are loaded once at
plugin construction; there is no on-disk watch for certificate files.
- Check expiry yourself: `openssl x509 -in relay.crt -noout -enddate`. Nothing
warns before a certificate lapses; it surfaces as a handshake failure.
- Keep the identity field you authorize on stable across rotations. Reissuing a
leaf with a different CN silently drops the peer out of the allow list.
**Deployment**
- Prefer `min_version = "1.3"`. Drop to `"1.2"` only for a peer that genuinely
cannot do 1.3.
- Never enable `insecure_skip_verify` outside a lab; it disables server
verification entirely and makes the connection trivially interceptable.
- Bind listeners to specific interfaces rather than `0.0.0.0` where you can.
- On any ingest port reachable from a network you do not fully control, set
`auth.type = "mtls"` with an explicit `allow` list. `trust_node = false` is the
fallback when certificates are not an option; it is unforgeable but labels
entries by remote address, which is useless behind NAT or a load balancer.
- Run LogWisp as an unprivileged user with write access only to its own log and
configuration directories.
**Log content**
Logs routinely contain secrets that were never meant to leave the host. Filters
are the available tool:
```toml
[[pipelines.flow.filters]]
type = "exclude"
patterns = ["password", "api[_-]?key", "authorization", "bearer ", "secret"]
```
Choose a sanitizer policy that matches the sink — `json` for JSON output,
`txt` for files and consoles — so control characters in log data cannot break
framing or inject terminal escapes downstream. See
[Formatters](formatters.md).
+73 -296
View File
@@ -1,107 +1,79 @@
# Output Sinks # Output Sinks
Sinks consume `core.TransportEvent` values — a formatted `Payload` plus the LogWisp sinks deliver processed log entries to various destinations.
original structured `Entry` — and deliver them somewhere. Each sink is declared
as a `[[pipelines.plugin_sinks]]` entry with an `id`, a `type`, and a
type-specific `config` table.
Registered types: `console`, `file`, `http`, `tcp`, `null`, `tcp_chain`, ## Sink Types
`http_chain`.
Dispatch into a sink is non-blocking. A sink whose input queue is full drops the ### Console Sink
event *for itself only* and the pipeline counts it in `total_dropped_by_sink`;
sibling sinks are unaffected.
--- Output to stdout/stderr.
## console
Writes formatted payloads to stdout or stderr.
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "stdout" id = "console_out"
type = "console" type = "console"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout" # stdout|stderr|split
buffer_size = 1000 buffer_size = 1000
``` ```
**Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `target` | string | `stdout` | `stdout` or `stderr` | | `target` | string | "stdout" | Output target (stdout/stderr/split) |
| `buffer_size` | int | `1000` | Sink input queue depth | | `buffer_size` | int | 1000 | Internal buffer size |
> `split` is **not** a valid target for this sink and is rejected at startup. **Target Modes:**
> Level-based splitting exists only for LogWisp's own application log - **stdout**: All output to standard output
> (`logging.output = "split"`). - **stderr**: All output to standard error
- **split**: INFO/DEBUG to stdout, WARN/ERROR to stderr
Payloads are written verbatim; the sink adds no framing. Whether entries are ### File Sink
newline-terminated is decided by the formatter.
--- Write logs to rotating files.
## file
Rotating file writer.
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "archive" id = "file_out"
type = "file" type = "file"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
directory = "/var/log/logwisp" directory = "./logs"
name = "output" name = "output"
max_size_mb = 100 max_size_mb = 100
max_total_size_mb = 1000 max_total_size_mb = 1000
min_disk_free_mb = 0 min_disk_free_mb = 500
retention_hours = 168.0 retention_hours = 168.0
buffer_size = 1000 buffer_size = 1000
flush_interval_ms = 100 flush_interval_ms = 1000
``` ```
**Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `directory` | string | **required** | Output directory | | `directory` | string | Required | Output directory |
| `name` | string | **required** | Base filename | | `name` | string | Required | Base filename |
| `max_size_mb` | int | `100` | Rotate when the active file reaches this size | | `max_size_mb` | int | 100 | Rotation threshold |
| `max_total_size_mb` | int | `1000` | Cap across all rotated files | | `max_total_size_mb` | int | 1000 | Total size limit |
| `min_disk_free_mb` | int | `0` | Free-space floor before writing; `0` = no floor | | `min_disk_free_mb` | int | 500 | Minimum free disk space |
| `retention_hours` | float | `168.0` | Delete rotated files older than this | | `retention_hours` | float | 168 | Delete files older than |
| `buffer_size` | int | `1000` | Sink input queue depth | | `buffer_size` | int | 1000 | Internal buffer size |
| `flush_interval_ms` | int | `100` | Forced flush interval | | `flush_interval_ms` | int | 1000 | Force flush interval |
> `min_disk_free_mb` has an unusual default. The constructor replaces only **Features:**
> *negative* values with `100`; leaving the key unset yields `0`, which means no - Automatic rotation on size
> free-space floor. Set it explicitly if you want one. - Retention management
- Disk space monitoring
- Periodic flushing
The sink drives an internal writer configured for raw output with timestamps and ### HTTP Sink
levels disabled, so what lands on disk is exactly the formatted payload.
--- SSE (Server-Sent Events) streaming server.
## null
Discards everything, counting entries and bytes. Useful for benchmarking a
source or flow in isolation.
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "discard" id = "http_out"
type = "null"
```
No options. The input queue is fixed at 1000.
---
## http
Server-Sent Events stream plus a JSON status endpoint.
```toml
[[pipelines.plugin_sinks]]
id = "sse"
type = "http" type = "http"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
host = "0.0.0.0" host = "0.0.0.0"
@@ -112,84 +84,28 @@ buffer_size = 1000
client_buffer_size = 256 client_buffer_size = 256
write_timeout_ms = 0 write_timeout_ms = 0
max_connections = 0 max_connections = 0
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
``` ```
**Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only | | `host` | string | "0.0.0.0" | Bind address |
| `port` | int | **required** | Listen port | | `port` | int | Required | Listen port |
| `stream_path` | string | `/stream` | SSE endpoint; must start with `/` | | `stream_path` | string | "/stream" | SSE stream endpoint |
| `status_path` | string | `/status` | Status endpoint; must start with `/` and differ from `stream_path` | | `status_path` | string | "/status" | Status endpoint |
| `buffer_size` | int | `1000` | Sink input queue depth | | `buffer_size` | int | 1000 | Sink input queue size |
| `client_buffer_size` | int | `256` | Per-client send queue depth | | `client_buffer_size` | int | 256 | Per-client send queue size |
| `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none | | `write_timeout_ms` | int | 0 | Write deadline per event (0 = none) |
| `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited | | `max_connections` | int | 0 | Concurrent connection cap (0 = unlimited) |
| `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
**Behaviour** ### TCP Sink
- Only `GET` is routed to either path; anything else gets `405`, `HEAD` on TCP streaming server for debugging and raw client forwarding.
`stream_path` included — a stream is a body, and a client registered to have
its body discarded never reads and never leaves.
- With an `auth` block, one middleware gates **both** endpoints: an
unauthorized client gets `403` with no body detail, and the rejection is
logged at WARN and counted in `auth_rejected`. The authorized identity is
recorded in the client's session as `auth_method` / `auth_identity`.
- On connect the client receives an `event: connected` frame carrying its
client id, session id, sink instance id, endpoint paths, and buffer size.
- Payloads are framed per the SSE spec, one `data:` line per newline in the
payload, so multi-line entries stream correctly.
- The server sets no `WriteTimeout` (that would kill long-lived streams);
per-write deadlines come from `write_timeout_ms` via `http.ResponseController`
and cover the connected frame, every payload, and the idle comment.
- A quiet stream emits an SSE comment every 15 s. It refreshes the client's
session and is how a peer that stopped reading is noticed.
- A client whose send queue is full has that event dropped
(`dropped_writes`); it is not disconnected. A `dropped_writes` that rises while
no client is behind is a burst larger than `client_buffer_size`, not
backpressure: size the queue at or above whatever burst the pipeline's
`rate_limit` releases at once.
- A client is registered only once its connected frame has flushed, so the
broker never queues into a buffer whose reader has not started.
- Clients whose session has been idle-expired by the session manager are
evicted by the broker. With the idle comment above, that reaches only a peer
that has stopped accepting bytes on a sink configured `write_timeout_ms = 0`.
- On shutdown, connected clients receive
`event: disconnect / data: {"reason":"server_shutdown"}`.
- HTTP/2 is negotiated via ALPN when TLS is enabled; plaintext is HTTP/1.1.
**Status endpoint** returns service and version identity, host, port, TLS flag,
the compiled auth policy, active client count, sink and per-client buffer sizes,
connection limit, write timeout, uptime, endpoint paths, and the
`total_processed` / `dropped_writes` / `rejected_clients` / `auth_rejected`
counters.
> Without an `auth` block both endpoints are unauthenticated, and the stream
> response carries `Access-Control-Allow-Origin: *`, so any web origin can read
> it. Set `auth.type = "mtls"` (which requires `tls.client_auth`), bind to a
> trusted interface, or put an authenticating reverse proxy in front.
---
## tcp
Broadcasts formatted payloads to every connected TCP client.
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "tap" id = "tcp_out"
type = "tcp" type = "tcp"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
host = "0.0.0.0" host = "0.0.0.0"
@@ -200,178 +116,39 @@ write_timeout_ms = 5000
keep_alive = true keep_alive = true
keep_alive_period_ms = 30000 keep_alive_period_ms = 30000
max_connections = 0 max_connections = 0
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
``` ```
**Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only | | `host` | string | "0.0.0.0" | Bind address |
| `port` | int | **required** | Listen port | | `port` | int | Required | Listen port |
| `buffer_size` | int | `1000` | Sink input queue depth | | `buffer_size` | int | 1000 | Sink input queue size |
| `client_buffer_size` | int | `256` | Per-client send queue depth | | `client_buffer_size` | int | 256 | Per-client send queue size |
| `write_timeout_ms` | int | `5000` | Per-write deadline | | `write_timeout_ms` | int | 5000 | Write timeout |
| `keep_alive` | bool | `true` | Enable TCP keep-alive on accepted connections | | `keep_alive` | bool | true | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period | | `keep_alive_period_ms` | int | 30000 | Keep-alive interval |
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited | | `max_connections` | int | 0 | Concurrent connection cap (0 = unlimited) |
| `tls` | table | — | Listener TLS | ```
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
**Behaviour** ### Null Sink
- The sink is write-only. Each connection also runs a reader that discards
inbound bytes; it exists to detect disconnects and to refresh session
activity when a client sends anything.
- A write that misses its deadline means the kernel buffer stayed full for the
whole timeout, so the client is disconnected immediately rather than retried.
- A client whose send queue is full has that event dropped (`dropped_writes`)
and stays connected.
- With TLS enabled the handshake runs under a 10 s bound *after* the
`max_connections` check, so concurrent handshakes are bounded too.
- With an `auth` block, authorization runs after that handshake and *before*
registration, so an unauthorized client never enters the client map and never
receives a broadcast. Its connection is closed, the rejection logged at WARN,
and `rejected_conns` incremented.
---
## tcp_chain
Forwards structured entries to a downstream LogWisp `tcp_chain` source over one
persistent connection. See [Chaining](chaining.md).
```toml ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "to_relay" id = "null_out"
type = "tcp_chain" type = "null"
[pipelines.plugin_sinks.config]
host = "relay.internal"
port = 15801
node = "edge-01"
buffer_size = 1000
dial_timeout_ms = 5000
write_timeout_ms = 5000
backoff_min_ms = 500
backoff_max_ms = 30000
keep_alive = true
keep_alive_period_ms = 30000
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/client.crt"
key_file = "/etc/logwisp/tls/client.key"
``` ```
| Option | Type | Default | Description | ## Buffer Management
|--------|------|---------|-------------|
| `host` | string | **required** | Downstream host |
| `port` | int | **required** | Downstream port |
| `node` | string | `os.Hostname()` | Origin label stamped on first-hop entries |
| `buffer_size` | int | `1000` | Sink input queue depth |
| `dial_timeout_ms` | int | `5000` | TCP connect timeout |
| `write_timeout_ms` | int | `5000` | Per-write deadline |
| `backoff_min_ms` | int | `500` | Reconnect backoff floor |
| `backoff_max_ms` | int | `30000` | Reconnect backoff ceiling |
| `keep_alive` | bool | `true` | Enable TCP keep-alive |
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
**Behaviour** - Full input buffer: entry dropped for that sink only (counted per pipeline as `total_dropped_by_sink`)"
- The connection is established lazily, so pipeline start does not depend on the
downstream being up.
- Each entry is serialized as one canonical JSON line. Delivery holds the line
across reconnects until it is written or the process shuts down, with
exponential backoff plus ±20 % jitter between attempts.
- Because delivery blocks the sink's run loop during an outage, back-pressure
surfaces as a full input queue and is counted by the pipeline as
`total_dropped_by_sink`.
- With TLS, dial and handshake are bounded together by
`dial_timeout_ms` + 10 s.
- Events arriving without a structured entry are wrapped from the formatted
payload and counted in `synthesized`.
An `auth` block on a dialer pins the server's identity: the policy runs as part
of the handshake, so a server it rejects is treated like any other connect
failure and retried under the normal backoff.
**Statistics**: `target`, `node`, `tls`, `auth`, `connected`, `reconnects`,
`write_errors`, `synthesized`.
---
## http_chain
Batches structured entries as NDJSON and POSTs them to a downstream LogWisp
`http_chain` source.
```toml
[[pipelines.plugin_sinks]]
id = "to_collector"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "collector.internal"
port = 15802
ingest_path = "/ingest"
node = "edge-01"
buffer_size = 1000
max_batch_count = 100
max_batch_bytes = 1048576
flush_interval_ms = 1000
request_timeout_ms = 10000
backoff_min_ms = 500
backoff_max_ms = 30000
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
cert_file = "/etc/logwisp/tls/client.crt"
key_file = "/etc/logwisp/tls/client.key"
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `host` | string | **required** | Downstream host |
| `port` | int | **required** | Downstream port |
| `ingest_path` | string | `/ingest` | Endpoint path; must start with `/` |
| `node` | string | `os.Hostname()` | Origin label stamped on first-hop entries |
| `buffer_size` | int | `1000` | Sink input queue depth |
| `max_batch_count` | int | `100` | Flush after this many entries |
| `max_batch_bytes` | int | `1048576` | Flush after this many bytes (1 MiB) |
| `flush_interval_ms` | int | `1000` | Flush after this long |
| `request_timeout_ms` | int | `10000` | Covers dial, write, and response |
| `backoff_min_ms` | int | `500` | Retry backoff floor |
| `backoff_max_ms` | int | `30000` | Retry backoff ceiling |
| `tls` | table | — | Dialer TLS; `cert_file`/`key_file` present a client identity |
| `auth` | table | — | Server identity pinning; see [Security](security.md#dialer-side-pinning) |
**Behaviour**
- Delivery is at-least-once per batch: a retried batch can be delivered twice if
the first attempt succeeded but the response was lost.
- Retries apply to transport errors, `408`, `429`, and `5xx`. Any other
non-2xx response is treated as permanent, and the batch is dropped and counted
in `dropped_batches`.
- HTTP/2 is off by design; batched NDJSON POSTs gain nothing from it.
- On shutdown a single best-effort flush of the pending batch is attempted.
**Statistics**: `target`, `node`, `tls`, `auth`, `batches_sent`,
`request_errors`, `dropped_batches`, `synthesized`.
---
## Sink Statistics ## Sink Statistics
Every sink reports: `id`, `type`, `total_processed`, `active_connections`, All sinks track:
`start_time`, `last_processed`, and a type-specific `details` map. - Total entries processed
- Active connections
- Failed sends
- Retry attempts
- Last processed timestamp
+61 -247
View File
@@ -1,115 +1,65 @@
# Input Sources # Input Sources
Sources produce `core.LogEntry` values for a pipeline. Every source is declared LogWisp sources monitor various inputs and generate log entries for pipeline processing.
as a `[[pipelines.plugin_sources]]` entry with an `id`, a `type`, and a
type-specific `config` table. ## Source Types
### Directory Source
Monitors a directory for log files matching a pattern. (type: `file`)
```toml ```toml
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
id = "app_logs" id = "file_in"
type = "file" type = "file"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
directory = "/var/log/myapp" directory = "/var/log/myapp"
pattern = "*.log" # Glob pattern
check_interval_ms = 100 # Poll interval
``` ```
Registered types: `file`, `console`, `random`, `null`, `tcp_chain`, **Configuration Options:**
`http_chain`.
Publication from any source is non-blocking. When a subscriber channel is full
the entry is dropped and counted in `dropped_entries`.
---
## file
Tails every file in a directory whose name matches a glob.
```toml
[[pipelines.plugin_sources]]
id = "app_logs"
type = "file"
[pipelines.plugin_sources.config]
directory = "/var/log/myapp"
pattern = "*.log"
check_interval_ms = 100
raw = false
from = "end"
```
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `directory` | string | **required** | Directory to scan; not recursive | | `directory` | string | Required | Directory to monitor |
| `pattern` | string | `*` | Glob over filenames; `*` and `?` only | | `pattern` | string | "*" | File pattern (glob) |
| `check_interval_ms` | int | `100` | Directory rescan interval; minimum `10` | | `check_interval_ms` | int | 100 | File check interval in milliseconds |
| `raw` | bool | `false` | Never parse a line: the whole line is the message |
| `from` | string | `end` | Where a new watcher starts: `end` or `start` of the file |
**Behaviour** **Features:**
- Automatic rotation detection (inode + size tracking)
- In-memory position tracking; on restart, monitoring resumes from the current end of each file (offsets are not persisted)
- Concurrent file monitoring
- Pattern-based file selection
- `check_interval_ms` governs how often the directory is rescanned for new or ### Stdin Source
removed files. Tailing an already-open file polls on a **fixed 100 ms**
interval that this option does not change.
- Each matched file gets its own watcher. Watchers for files that disappear are
stopped and removed on the next scan.
- A new watcher seeks to end-of-file. Positions live in memory only, so a
restart resumes from the current end of each file and content written while
LogWisp was down is not read. `from = "start"` reads each file whole when its
watcher is created instead — what a process writing beside LogWisp needs, at
the cost of replaying a file already on disk at every restart.
- Rotation is detected from size decrease, modification-time reset, a position
beyond end-of-file, or an inode change. An inode change where the new file is
already larger than the recorded position is treated as an atomic save, not a
rotation, and the position is preserved.
- A rotation that renames in place — what a size-capped writer does — puts the
same inode back under a name `pattern` also matches. Its watcher resumes at
the position the original reached, so `from = "start"` reads the tail an
unfinished read left behind rather than the whole archive a second time.
- A line is parsed as JSON only when it is an object whose top-level keys are
all drawn from `time`, `level`, `msg` and `fields` — the four an entry can
carry. `time` is read as RFC3339Nano. Any other key, and any non-object line,
is kept whole as text with the level inferred from common markers
(`[ERROR]`, `WARN:`, and so on), because parsing it would drop the rest.
- `raw = true` skips the JSON branch entirely. The line, plus its newline,
becomes the message; `fields` stays empty, the time is the read time, and the
level is inferred from the text as for any unparsed line. Paired with
`format.type = "raw"` this is byte-exact transport for records LogWisp's
envelope cannot hold — see [Formatters](formatters.md#raw).
- `Source` is set to the file's base name.
**Statistics**: per-watcher size, position, entries read, rotation count, and Reads log entries from standard input.
last read time, plus `active_watchers`.
---
## console
Reads newline-delimited entries from standard input.
```toml ```toml
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
id = "stdin" id = "console_in"
type = "console" type = "console"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
buffer_size = 1000 buffer_size = 1000
``` ```
**Configuration Options:**
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `buffer_size` | int | `1000` | Subscriber channel depth | | `buffer_size` | int | 1000 | Internal buffer size |
At most **one** instance per pipeline: the type is registered with **Features:**
`MaxInstances: 1`, and a second instance is rejected at pipeline construction. - Line-based processing
The level is inferred from the line text, and `Source` is set to `console`. - Automatic level detection
- Non-blocking reads
--- ### Random Source
## random
Synthetic entry generator for development, smoke tests, and sanitizer testing.
```toml ```toml
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
id = "generator" id = "random_in"
type = "random" type = "random"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
interval_ms = 500 interval_ms = 500
@@ -119,175 +69,39 @@ length = 20
special = false special = false
``` ```
| Option | Type | Default | Description | **Configuration Options:**
|--------|------|---------|-------------|
| `interval_ms` | int | `500` | Emission period |
| `jitter_ms` | int | `0` | Symmetric jitter; clamped to `interval_ms`, must be non-negative |
| `format` | string | `txt` | `raw` (message only), `txt` (bracketed line), `json` (JSON object as the message) |
| `length` | int | `20` | Message length in characters |
| `special` | bool | `false` | Inject control and non-ASCII characters |
`special = true` is the intended way to exercise sanitizer policies: it inserts
control bytes and multi-byte Unicode into otherwise ordinary messages. Levels
are chosen at random from DEBUG, INFO, WARN, ERROR.
---
## null
Produces nothing. Useful as a placeholder so a sink-only pipeline satisfies the
"at least one source" requirement.
```toml
[[pipelines.plugin_sources]]
id = "void"
type = "null"
```
No options.
---
## tcp_chain
Listens for persistent NDJSON streams from upstream LogWisp `tcp_chain` sinks.
See [Chaining](chaining.md) for the protocol.
```toml
[[pipelines.plugin_sources]]
id = "ingest_tcp"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "0.0.0.0"
port = 15801
buffer_size = 1000
max_connections = 0
read_timeout_ms = 0
hello_timeout_ms = 10000
trust_node = true
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
min_version = "1.3"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
```
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only | | `interval_ms` | int | 500 | Generation interval |
| `port` | int | **required** | Listen port, 165535 | | `jitter_ms` | int | 0 | Random jitter interval |
| `buffer_size` | int | `1000` | Subscriber channel depth | | `format` | string | "txt" | "txt", "json", "raw" |
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited | | `length` | int | 20 | Log length |
| `read_timeout_ms` | int | `0` | Per-connection idle read deadline; `0` = none | | `special` | bool | false | Include special characters |
| `hello_timeout_ms` | int | `10000` | Deadline for the hello preamble |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
**Behaviour**
- TLS handshakes run explicitly with a 10 s bound before the preamble is read,
after the `max_connections` admission check.
- Authorization runs between the handshake and the hello read, so an
unauthorized peer never gets a preamble parsed on its behalf. A rejection is
logged at WARN and counted in `rejected_conns`.
- A connection is rejected if the first line is not a valid hello with a
matching protocol version.
- The node label is then resolved: under `auth.node_binding` it comes from the
peer's certificate, otherwise `trust_node` governs. `force` also overrides the
`node` field on every individual entry; `assert` leaves per-entry labels to
`trust_node`, so a relay can forward other nodes' entries while proving its
own identity.
- Each accepted connection gets a session recording the remote address, node
label, — under TLS — `tls` and `tls_peer_cn`, and — under auth —
`auth_method` and `auth_identity`.
- A malformed entry line increments `parse_errors` and is skipped; the
connection survives. A line over 1 MiB is a protocol violation and terminates
the connection.
**Statistics**: `active_connections`, `rejected_conns`, `parse_errors`,
`tls_handshake_errors`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
---
## http_chain
Accepts NDJSON batches POSTed by upstream LogWisp `http_chain` sinks.
```toml
[[pipelines.plugin_sources]]
id = "ingest_http"
type = "http_chain"
[pipelines.plugin_sources.config]
host = "0.0.0.0"
port = 15802
ingest_path = "/ingest"
buffer_size = 1000
max_body_bytes = 8388608
read_timeout_ms = 30000
trust_node = true
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/server.crt"
key_file = "/etc/logwisp/tls/server.key"
client_auth = true
client_ca_file = "/etc/logwisp/tls/client-ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01", "edge-02"]
node_binding = "force"
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only |
| `port` | int | **required** | Listen port |
| `ingest_path` | string | `/ingest` | Endpoint path; must start with `/` |
| `buffer_size` | int | `1000` | Subscriber channel depth |
| `max_body_bytes` | int | `8388608` | Per-request body cap (8 MiB) |
| `read_timeout_ms` | int | `30000` | Full request read deadline |
| `trust_node` | bool | `true` | `false` overrides the sender's node label with its remote address. Ignored when `auth.node_binding` is active |
| `tls` | table | — | Listener TLS |
| `auth` | table | — | Peer authorization and node binding; see [Security](security.md#the-auth-block) |
**Behaviour**
- Only `POST` to `ingest_path` is routed; other methods get `405` with an
`Allow` header, and other paths get `404`.
- Authorization runs before the body is read, so an unauthorized sender does not
get to stream `max_body_bytes` into the process. Both a policy rejection and a
node-binding failure answer `403`, distinct from the `400` used for protocol
errors, so a sender can tell "not allowed" from "malformed batch".
- A missing or mismatched `X-Logwisp-Protocol` header is rejected with `400`.
- Batch acceptance is atomic: entries are published only after the body reads
cleanly end to end. A transfer error rejects the whole batch (`400`, or `413`
when the body cap is hit) so the sender retries it. A malformed *line* inside
an otherwise clean transfer is skipped and counted in `parse_errors`.
- Success is `204 No Content` with `X-Logwisp-Accepted` set to the number of
entries ingested.
- Sessions are cached per remote host + node + authenticated identity, and
recreated after idle expiry. Including the identity in the key means two peers
sharing a remote address never share a session.
**Statistics**: `total_requests`, `rejected_requests`, `parse_errors`,
`cached_sessions`, `trust_node`, `auth`, `auth_allowed`, `auth_rejected`,
`node_binding`.
---
## Source Statistics ## Source Statistics
Every source reports: `id`, `type`, `total_entries`, `dropped_entries`, All sources track:
`start_time`, `last_entry_time`, and a type-specific `details` map. These appear - Total entries received
in the status reporter output and in the `http` sink's status endpoint. - Dropped entries (buffer full)
- Invalid entries
- Last entry timestamp
- Active connections (network sources)
- Source-specific metrics
### Null Source
```toml
[[pipelines.plugin_sources]]
id = "null_in"
type = "null"
[pipelines.plugin_sources.config]
```
## Buffer Management
Each source maintains internal buffers:
- Default size: 1000 entries
- Drop policy when full
- Configurable per source
- Non-blocking writes
+2 -2
View File
@@ -1,10 +1,10 @@
module logwisp module logwisp
go 1.27.1 go 1.26.0
require ( require (
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98 github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 github.com/lixenwraith/log v0.1.1-0.20260717175128-82eea9846ccd
) )
require ( require (
+4 -6
View File
@@ -6,14 +6,12 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98 h1:VEFo2WsYgM5YYfzAfvXRKigCWr3zEz6h4M59N5TXMpk= github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98 h1:VEFo2WsYgM5YYfzAfvXRKigCWr3zEz6h4M59N5TXMpk=
github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98/go.mod h1:J9ydxY7he4Dz+S59xKs/kDQ2YAv+zOLF05b3Rr2/ogE= github.com/lixenwraith/config v0.1.1-0.20260712172228-ccd280ba6a98/go.mod h1:J9ydxY7he4Dz+S59xKs/kDQ2YAv+zOLF05b3Rr2/ogE=
github.com/lixenwraith/log v0.1.1-0.20260724174821-e688c5a07207 h1:EnDMcnFkgTwTGo1ojoJ85/b6aH3yxlBUAg6AefaBcrI= github.com/lixenwraith/log v0.1.1-0.20260717175128-82eea9846ccd h1:06Rk4DLvJW1kdeclH5HwywizgtMI64PHNE/2sBeuiwA=
github.com/lixenwraith/log v0.1.1-0.20260724174821-e688c5a07207/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc= github.com/lixenwraith/log v0.1.1-0.20260717175128-82eea9846ccd/go.mod h1:2+qURSVdWcX7REFH+3jUtIbtc+NtsAPecSvXLeGyK6U=
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3 h1:5ojkxyuOKiUBRYhqPqKJS1lrqkjckYqYMNGBB49kdY0=
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-318
View File
@@ -1,318 +0,0 @@
// Package authz turns a verified certificate into an authorization decision.
// It is the single seam between declarative auth config and the network
// plugins: each one compiles a Policy at construction and calls Authorize per
// connection (TCP) or per request (HTTP).
//
// New returns (nil, nil) when auth is disabled, mirroring tlsx.Server and
// tlsx.Client, and every method tolerates a nil receiver — so call sites read
// the same whether or not a policy is configured.
package authz
import (
"crypto/tls"
"fmt"
"regexp"
"strings"
"sync/atomic"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// Authentication methods
const (
MethodNone = "none"
MethodMTLS = "mtls"
)
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
// difference between assert and force.
const (
BindingNone = "none"
BindingAssert = "assert"
BindingForce = "force"
)
// Role selects the validation and behavior appropriate to the call site
type Role int
const (
// RoleListener authorizes client certificates on a plugin with no node
// concept: the tcp and http sinks
RoleListener Role = iota
// RoleChainListener authorizes client certificates and binds the node
// label a peer declares: the tcp_chain and http_chain sources
RoleChainListener
// RoleDialer pins the server's identity beyond hostname verification:
// the tcp_chain and http_chain sinks
RoleDialer
)
// Policy is the compiled form of config.AuthOptions
type Policy struct {
role Role
identity string
allow map[string]struct{}
patterns []*regexp.Regexp
binding string
// Statistics
allowed atomic.Uint64
rejected atomic.Uint64
}
// Identity is the outcome of a successful authorization. The zero value is
// what a disabled policy yields.
type Identity struct {
Name string // the selected certificate field
Method string // MethodMTLS
}
// Apply stamps an authenticated identity onto session metadata. A zero
// Identity (auth disabled) leaves the map untouched.
func (id Identity) Apply(meta map[string]any) {
if id.Name == "" {
return
}
meta["auth_method"] = id.Method
meta["auth_identity"] = id.Name
}
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
// tlsOpts is the sibling `tls` block: an auth policy the transport cannot
// enforce is rejected here rather than silently accepted, which is the
// failure mode worth designing out.
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error) {
if o == nil {
return nil, nil
}
switch o.Type {
case "", MethodNone:
return nil, nil
case MethodMTLS:
default:
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
}
if tlsOpts == nil || !tlsOpts.Enabled {
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
}
if role == RoleDialer {
// Identity from an unverified chain is a claim, not a fact
if tlsOpts.InsecureSkipVerify {
return nil, fmt.Errorf("auth: type %q cannot pin an identity with tls.insecure_skip_verify", MethodMTLS)
}
} else if !tlsOpts.ClientAuth {
return nil, fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
}
identity := o.Identity
if identity == "" {
identity = tlsx.IdentityCN
}
switch identity {
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
default:
return nil, fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
identity, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
}
binding := o.NodeBinding
if role == RoleChainListener {
if binding == "" {
// The only setting under which a misconfigured or hostile edge
// cannot mislabel its entries
binding = BindingForce
}
} else if binding != "" && binding != BindingNone {
return nil, fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
} else {
binding = BindingNone
}
switch binding {
case BindingNone, BindingAssert, BindingForce:
default:
return nil, fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)",
binding, BindingNone, BindingAssert, BindingForce)
}
p := &Policy{
role: role,
identity: identity,
binding: binding,
allow: make(map[string]struct{}, len(o.Allow)),
}
for _, a := range o.Allow {
if a = strings.TrimSpace(a); a != "" {
p.allow[a] = struct{}{}
}
}
for i, pat := range o.AllowPatterns {
re, err := regexp.Compile(pat)
if err != nil {
return nil, fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
}
p.patterns = append(p.patterns, re)
}
return p, nil
}
// Authorize extracts and checks the peer identity from a completed handshake.
// A nil policy authorizes everything and yields the zero Identity, so callers
// need no branch on whether auth is configured.
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
if p == nil {
return Identity{}, nil
}
if cs == nil {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
}
name := tlsx.PeerIdentity(*cs, p.identity)
if name == "" {
// An unusable identity field is a rejection, not an empty match
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: peer certificate carries no %s identity", p.identity)
}
if !p.permits(name) {
p.rejected.Add(1)
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", name)
}
p.allowed.Add(1)
return Identity{Name: name, Method: MethodMTLS}, nil
}
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer,
// so a server whose identity the policy rejects fails the handshake itself
// rather than after the first write. It runs after the standard chain and
// hostname checks, so the identity it reads is already verified.
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
_, err := p.Authorize(&cs)
return err
}
// permits reports whether an identity satisfies the allow list. An empty list
// admits any identity the CA vouches for; that is the documented default, and
// constructors log it at startup rather than leaving it silent.
// Identities are not secrets, so ordinary comparison is fine.
func (p *Policy) permits(name string) bool {
if len(p.allow) == 0 && len(p.patterns) == 0 {
return true
}
if _, ok := p.allow[name]; ok {
return true
}
for _, re := range p.patterns {
if re.MatchString(name) {
return true
}
}
return false
}
// ResolveNode returns the node label for a connection. With no policy, or
// node_binding "none", trust_node governs as before: the declared label stands
// only when trusted and non-empty, otherwise fallback (the remote address) is
// used. Otherwise the label is bound to the authenticated identity.
func (p *Policy) ResolveNode(declared, fallback string, trustNode bool, id Identity) (string, error) {
if p == nil || p.binding == BindingNone {
if declared == "" || !trustNode {
return fallback, nil
}
return declared, nil
}
if id.Name == "" {
return "", fmt.Errorf("auth: node_binding %q requires an authenticated identity", p.binding)
}
if p.binding == BindingForce {
return id.Name, nil
}
// BindingAssert: a mismatch is loud rather than silently corrected
if declared == "" {
return "", fmt.Errorf("auth: node_binding %q: peer %q declared no node label", BindingAssert, id.Name)
}
if declared != id.Name {
return "", fmt.Errorf("auth: node_binding %q: declared node %q does not match identity %q",
BindingAssert, declared, id.Name)
}
return declared, nil
}
// TrustsEntryNode reports whether node labels carried by individual entries
// survive the policy. force relabels every entry, so an ingest boundary that
// does not trust its peer gets exact attribution; assert pins only the
// connection's own label, so a relay forwarding other nodes' entries proves
// who it is while preserving their origin.
func (p *Policy) TrustsEntryNode(trustNode bool) bool {
if p == nil {
return trustNode
}
if p.binding == BindingForce {
return false
}
return trustNode
}
// BindsNode reports whether the policy overrides trust_node
func (p *Policy) BindsNode() bool {
return p != nil && p.binding != BindingNone
}
// NodeBinding returns the effective binding mode
func (p *Policy) NodeBinding() string {
if p == nil {
return BindingNone
}
return p.binding
}
// Enabled reports whether a policy is in force
func (p *Policy) Enabled() bool { return p != nil }
// Unrestricted reports whether the policy admits any identity the CA vouches
// for. Constructors log this at startup: it is a deliberate default, and a
// silent one would be a footgun.
func (p *Policy) Unrestricted() bool {
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
}
// Describe renders the policy for a startup log line
func (p *Policy) Describe() string {
if p == nil {
return MethodNone
}
scope := fmt.Sprintf("%d exact, %d pattern(s)", len(p.allow), len(p.patterns))
if p.Unrestricted() {
scope = "any identity issued by the configured CA"
}
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s",
MethodMTLS, p.identity, scope, p.binding)
}
// Rejected returns the number of authorization failures
func (p *Policy) Rejected() uint64 {
if p == nil {
return 0
}
return p.rejected.Load()
}
// Stats reports policy state for a plugin's stats details map. Merge it in
// with maps.Copy so rejections surface in the status reporter and in the
// http sink's status endpoint.
func (p *Policy) Stats() map[string]any {
if p == nil {
return map[string]any{"auth": MethodNone}
}
d := map[string]any{
"auth": MethodMTLS,
"auth_identity": p.identity,
"auth_unrestricted": p.Unrestricted(),
"auth_allowed": p.allowed.Load(),
"auth_rejected": p.rejected.Load(),
}
if p.role == RoleChainListener {
d["node_binding"] = p.binding
}
return d
}
-298
View File
@@ -1,298 +0,0 @@
package authz
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net/url"
"testing"
"logwisp/internal/config"
"logwisp/internal/tlsx"
)
// peerState fakes a completed handshake. Only the leaf's identity fields are
// read: the chain is verified by crypto/tls before a policy ever sees it.
func peerState(leaf *x509.Certificate) *tls.ConnectionState {
return &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}}
}
func leafCN(cn string) *x509.Certificate {
return &x509.Certificate{Subject: pkix.Name{CommonName: cn}}
}
func mtlsListenerTLS() *config.TLSOptions {
return &config.TLSOptions{Enabled: true, ClientAuth: true}
}
func TestPeerIdentityModes(t *testing.T) {
uri, err := url.Parse("spiffe://example.org/edge-01")
if err != nil {
t.Fatalf("parse uri: %v", err)
}
leaf := &x509.Certificate{
Subject: pkix.Name{CommonName: "edge-01"},
DNSNames: []string{"edge-01.internal", "alt.internal"},
URIs: []*url.URL{uri},
EmailAddresses: []string{"ops@example.org"},
}
cs := peerState(leaf)
cases := map[string]string{
tlsx.IdentityCN: "edge-01",
tlsx.IdentitySANDNS: "edge-01.internal",
tlsx.IdentitySANURI: "spiffe://example.org/edge-01",
tlsx.IdentitySANEmail: "ops@example.org",
"": "edge-01", // empty mode defaults to CN
"nonsense": "",
}
for mode, want := range cases {
if got := tlsx.PeerIdentity(*cs, mode); got != want {
t.Errorf("PeerIdentity(%q) = %q, want %q", mode, got, want)
}
}
// A mode the certificate does not carry yields no identity
bare := peerState(leafCN("edge-01"))
if got := tlsx.PeerIdentity(*bare, tlsx.IdentitySANDNS); got != "" {
t.Errorf("PeerIdentity(san_dns) on bare cert = %q, want empty", got)
}
// No peer certificate at all
if got := tlsx.PeerIdentity(tls.ConnectionState{}, tlsx.IdentityCN); got != "" {
t.Errorf("PeerIdentity with no peer certs = %q, want empty", got)
}
}
func TestNewDisabled(t *testing.T) {
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
p, err := New(o, nil, RoleListener)
if err != nil {
t.Fatalf("New(%+v) error: %v", o, err)
}
if p != nil {
t.Fatalf("New(%+v) = %v, want nil policy", o, p)
}
}
}
// A nil policy must behave as if auth were never configured
func TestNilPolicyIsTransparent(t *testing.T) {
var p *Policy
id, err := p.Authorize(nil)
if err != nil || id.Name != "" {
t.Fatalf("nil Authorize = (%+v, %v), want (zero, nil)", id, err)
}
if p.Enabled() || p.BindsNode() || p.Unrestricted() {
t.Fatal("nil policy reports itself active")
}
if p.NodeBinding() != BindingNone {
t.Fatalf("nil NodeBinding = %q", p.NodeBinding())
}
if !p.TrustsEntryNode(true) || p.TrustsEntryNode(false) {
t.Fatal("nil policy must defer to trust_node")
}
// trust_node semantics are unchanged without a policy
node, err := p.ResolveNode("edge-01", "10.0.0.5", true, Identity{})
if err != nil || node != "edge-01" {
t.Fatalf("nil ResolveNode(trust) = (%q, %v), want edge-01", node, err)
}
node, err = p.ResolveNode("edge-01", "10.0.0.5", false, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
node, err = p.ResolveNode("", "10.0.0.5", true, Identity{})
if err != nil || node != "10.0.0.5" {
t.Fatalf("nil ResolveNode(no label) = (%q, %v), want 10.0.0.5", node, err)
}
}
func TestNewValidation(t *testing.T) {
tests := []struct {
name string
auth *config.AuthOptions
tls *config.TLSOptions
role Role
}{
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener},
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener},
{"tls disabled", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{}, RoleListener},
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleListener},
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener},
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener},
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener},
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener},
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &config.TLSOptions{Enabled: true}, RoleDialer},
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true, InsecureSkipVerify: true}, RoleDialer},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := New(tc.auth, tc.tls, tc.role); err == nil {
t.Fatal("expected an error, got nil")
}
})
}
// A dialer needs TLS but not client_auth: it pins the server's identity
if _, err := New(&config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleDialer); err != nil {
t.Fatalf("dialer policy rejected: %v", err)
}
}
func TestAuthorizeMatching(t *testing.T) {
p, err := New(&config.AuthOptions{
Type: MethodMTLS,
Allow: []string{"edge-01", " edge-02 "},
AllowPatterns: []string{`^relay-\d{2}$`},
}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if p.Unrestricted() {
t.Fatal("policy with an allow list reports unrestricted")
}
allowed := []string{"edge-01", "edge-02", "relay-07"}
for _, cn := range allowed {
id, err := p.Authorize(peerState(leafCN(cn)))
if err != nil {
t.Errorf("Authorize(%q): %v", cn, err)
continue
}
if id.Name != cn || id.Method != MethodMTLS {
t.Errorf("Authorize(%q) = %+v", cn, id)
}
}
denied := []string{"edge-99", "relay-007", "prefix-relay-07", "", "EDGE-01"}
for _, cn := range denied {
if _, err := p.Authorize(peerState(leafCN(cn))); err == nil {
t.Errorf("Authorize(%q) allowed, want rejection", cn)
}
}
if got, want := p.Rejected(), uint64(len(denied)); got != want {
t.Errorf("Rejected = %d, want %d", got, want)
}
stats := p.Stats()
if stats["auth_allowed"].(uint64) != uint64(len(allowed)) {
t.Errorf("auth_allowed = %v, want %d", stats["auth_allowed"], len(allowed))
}
if _, ok := stats["node_binding"]; ok {
t.Error("plain listener stats report node_binding")
}
}
// Empty allow and allow_patterns admits any CA-vouched identity, but still
// records it and still refuses a certificate with no usable identity field
func TestAuthorizeUnrestricted(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener)
if err != nil {
t.Fatalf("New: %v", err)
}
if !p.Unrestricted() {
t.Fatal("empty allow list should be unrestricted")
}
id, err := p.Authorize(peerState(leafCN("anyone")))
if err != nil || id.Name != "anyone" {
t.Fatalf("Authorize = (%+v, %v)", id, err)
}
if _, err := p.Authorize(peerState(leafCN(""))); err == nil {
t.Error("certificate with no CN was authorized")
}
if _, err := p.Authorize(&tls.ConnectionState{}); err == nil {
t.Error("connection with no peer certificate was authorized")
}
if _, err := p.Authorize(nil); err == nil {
t.Error("non-TLS connection was authorized")
}
}
func TestResolveNodeBindings(t *testing.T) {
newChain := func(binding string) *Policy {
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
if err != nil {
t.Fatalf("New(%q): %v", binding, err)
}
return p
}
id := Identity{Name: "edge-01", Method: MethodMTLS}
// Default under mtls is force
if got := newChain("").NodeBinding(); got != BindingForce {
t.Errorf("default node_binding = %q, want %q", got, BindingForce)
}
// force ignores the declared label, however it was spoofed
force := newChain(BindingForce)
for _, declared := range []string{"edge-99", "", "edge-01"} {
node, err := force.ResolveNode(declared, "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("force ResolveNode(%q) = (%q, %v), want edge-01", declared, node, err)
}
}
if force.TrustsEntryNode(true) {
t.Error("force must not trust per-entry node labels")
}
// assert rejects a mismatch and an omission, and leaves per-entry labels
// alone so a relay can forward other nodes' entries
assert := newChain(BindingAssert)
node, err := assert.ResolveNode("edge-01", "10.0.0.5", true, id)
if err != nil || node != "edge-01" {
t.Errorf("assert ResolveNode(match) = (%q, %v)", node, err)
}
if _, err := assert.ResolveNode("edge-99", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a mismatched node label")
}
if _, err := assert.ResolveNode("", "10.0.0.5", true, id); err == nil {
t.Error("assert accepted a missing node label")
}
if !assert.TrustsEntryNode(true) || assert.TrustsEntryNode(false) {
t.Error("assert must leave per-entry node labels to trust_node")
}
// none leaves trust_node governing entirely
none := newChain(BindingNone)
if none.BindsNode() {
t.Error("node_binding none should not bind")
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", true, id)
if err != nil || node != "edge-99" {
t.Errorf("none ResolveNode = (%q, %v), want edge-99", node, err)
}
node, err = none.ResolveNode("edge-99", "10.0.0.5", false, id)
if err != nil || node != "10.0.0.5" {
t.Errorf("none ResolveNode(no trust) = (%q, %v), want 10.0.0.5", node, err)
}
// Binding without an authenticated identity is a refusal, not a fallback
if _, err := force.ResolveNode("edge-01", "10.0.0.5", true, Identity{}); err == nil {
t.Error("force resolved a node without an identity")
}
}
func TestIdentityApply(t *testing.T) {
meta := map[string]any{"type": "tcp_chain"}
Identity{}.Apply(meta)
if len(meta) != 1 {
t.Fatalf("zero identity stamped metadata: %v", meta)
}
Identity{Name: "edge-01", Method: MethodMTLS}.Apply(meta)
if meta["auth_identity"] != "edge-01" || meta["auth_method"] != MethodMTLS {
t.Fatalf("metadata = %v", meta)
}
}
func TestVerifyConnectionPinsServer(t *testing.T) {
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
&config.TLSOptions{Enabled: true}, RoleDialer)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("relay.internal"))); err != nil {
t.Errorf("pinned server rejected: %v", err)
}
if err := p.VerifyConnection(*peerState(leafCN("impostor.internal"))); err == nil {
t.Error("unpinned server accepted")
}
}
+6 -80
View File
@@ -198,8 +198,6 @@ type FileSourceOptions struct {
Directory string `toml:"directory"` Directory string `toml:"directory"`
Pattern string `toml:"pattern"` // glob pattern Pattern string `toml:"pattern"` // glob pattern
CheckIntervalMS int64 `toml:"check_interval_ms"` 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
} }
// ConsoleSourceOptions defines settings for a stdin-based source // ConsoleSourceOptions defines settings for a stdin-based source
@@ -210,7 +208,6 @@ type ConsoleSourceOptions struct {
// TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting // TCPChainSourceOptions defines settings for a stdlib TCP listener ingesting
// NDJSON entries from upstream logwisp tcp_chain sinks // NDJSON entries from upstream logwisp tcp_chain sinks
type TCPChainSourceOptions struct { type TCPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` BufferSize int64 `toml:"buffer_size"`
@@ -218,14 +215,12 @@ type TCPChainSourceOptions struct {
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"` // Future: TLS/auth options
// Future: password auth block
} }
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting // HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
// NDJSON batches from upstream logwisp http_chain sinks // NDJSON batches from upstream logwisp http_chain sinks
type HTTPChainSourceOptions struct { type HTTPChainSourceOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
IngestPath string `toml:"ingest_path"` IngestPath string `toml:"ingest_path"`
@@ -233,8 +228,7 @@ type HTTPChainSourceOptions struct {
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"` // Future: TLS/auth options
// Future: password auth block
} }
// --- Sink Options --- // --- Sink Options ---
@@ -279,7 +273,6 @@ type FileSinkOptions struct {
// TCPSinkOptions defines settings for a TCP server sink // TCPSinkOptions defines settings for a TCP server sink
type TCPSinkOptions struct { type TCPSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
BufferSize int64 `toml:"buffer_size"` // sink input queue BufferSize int64 `toml:"buffer_size"` // sink input queue
@@ -288,13 +281,11 @@ type TCPSinkOptions struct {
KeepAlive bool `toml:"keep_alive"` KeepAlive bool `toml:"keep_alive"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"` KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"` // Future: TLS (cert_file/key_file/client_ca), auth (token/mTLS) blocks
// Future: password auth block
} }
// HTTPSinkOptions defines settings for an HTTP SSE server sink // HTTPSinkOptions defines settings for an HTTP SSE server sink
type HTTPSinkOptions struct { type HTTPSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
StreamPath string `toml:"stream_path"` StreamPath string `toml:"stream_path"`
@@ -303,14 +294,12 @@ type HTTPSinkOptions struct {
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"` // Future: TLS (server.TLSConfig), auth middleware options
// Future: password auth block
} }
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding // TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
// entries to a downstream logwisp tcp_chain source // entries to a downstream logwisp tcp_chain source
type TCPChainSinkOptions struct { type TCPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname() Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
@@ -321,14 +310,12 @@ type TCPChainSinkOptions struct {
BackoffMaxMS int64 `toml:"backoff_max_ms"` BackoffMaxMS int64 `toml:"backoff_max_ms"`
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"` KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"` KeepAlive bool `toml:"keep_alive"`
Auth *AuthOptions `toml:"auth"` // Future: TLS/auth options
// Future: password auth block
} }
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting // HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
// NDJSON batches to a downstream logwisp http_chain source // NDJSON batches to a downstream logwisp http_chain source
type HTTPChainSinkOptions struct { type HTTPChainSinkOptions struct {
TLS *TLSOptions `toml:"tls"`
Node string `toml:"node"` // origin label, default: os.Hostname() Node string `toml:"node"` // origin label, default: os.Hostname()
Host string `toml:"host"` Host string `toml:"host"`
Port int64 `toml:"port"` Port int64 `toml:"port"`
@@ -340,66 +327,5 @@ type HTTPChainSinkOptions struct {
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
BackoffMinMS int64 `toml:"backoff_min_ms"` BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"` BackoffMaxMS int64 `toml:"backoff_max_ms"`
Auth *AuthOptions `toml:"auth"` // Future: TLS/auth options
// Future: password auth block
}
// --- Auth Options ---
// AuthOptions defines certificate-based authorization for network plugins.
// It sits beside `tls` rather than inside it: TLS answers "is this channel
// private and does the peer chain to a CA", auth answers "may *this* peer do
// *this*". One shape serves both roles:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) authorize the
// peer's client certificate; type "mtls" requires tls.client_auth.
// - Dialers (tcp_chain/http_chain sinks) pin the server's identity beyond
// hostname verification.
type AuthOptions struct {
// Method: "none" (default, preserves pre-auth behavior) | "mtls"
Type string `toml:"type"`
// Certificate field carrying the identity:
// "cn" (default) | "san_dns" | "san_uri" | "san_email"
Identity string `toml:"identity"`
// Exact identity matches. Empty Allow *and* AllowPatterns means "any
// identity the CA vouches for" - today's behavior, but with the identity
// recorded and node binding available.
Allow []string `toml:"allow"`
// RE2 patterns matched against the identity; anchor them yourself
AllowPatterns []string `toml:"allow_patterns"`
// Chain sources only: "none" | "assert" | "force" (default "force" when
// Type is "mtls"). Overrides trust_node.
NodeBinding string `toml:"node_binding"`
}
// --- TLS Options ---
// TLSOptions defines transport security for network sources and sinks.
// One shape serves both roles so the config block is uniform:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) use
// cert_file/key_file as server identity; client_auth/client_ca_file
// require and verify peer certificates (mTLS).
// - Dialers (tcp_chain/http_chain sinks) use ca_file/server_name to verify
// the server; cert_file/key_file present a client identity (mTLS).
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"`
// 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"`
// Minimum protocol version: "1.2" | "1.3" (default "1.3")
MinVersion string `toml:"min_version"`
} }
-4
View File
@@ -13,10 +13,6 @@ const (
SessionCleanupInterval = 5 * time.Minute SessionCleanupInterval = 5 * time.Minute
// Idle keepalive for a served stream. Well under SessionDefaultMaxIdleTime,
// so a quiet stream refreshes its session long before the sweep expires it.
StreamKeepaliveInterval = 15 * time.Second
ServiceStatsUpdateInterval = 1 * time.Second ServiceStatsUpdateInterval = 1 * time.Second
ShutdownTimeout = 10 * time.Second ShutdownTimeout = 10 * time.Second
+43 -30
View File
@@ -90,44 +90,57 @@ func NewFormatterAdapter(cfg *config.FormatConfig) (*FormatterAdapter, error) {
// Format implements Formatter interface // Format implements Formatter interface
func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) { func (a *FormatterAdapter) Format(entry core.LogEntry) ([]byte, error) {
return a.serialize(entry, a.flags), nil // Map logwisp LogEntry to formatter args
level := mapLevel(entry.Level)
// syslog-style origin prefix for chained entries
src := sourceLabel(entry)
// Build args based on whether we have structured fields
var args []any
effectiveFlags := a.flags
if len(entry.Fields) > 0 {
// Parse fields JSON
var fields map[string]any
if err := json.Unmarshal(entry.Fields, &fields); err == nil && len(fields) > 0 {
// Use structured JSON format for fields
args = []any{entry.Message, fields}
// Add structured flag to properly format fields as JSON object
effectiveFlags |= formatter.FlagStructuredJSON
return a.formatter.Format(effectiveFlags, entry.Time, level, src, args), nil
}
}
if args == nil {
args = []any{entry.Message}
}
a.mu.Lock()
out := bytes.Clone(a.formatter.Format(effectiveFlags, entry.Time, level, src, args))
a.mu.Unlock()
return out, nil
} }
// FormatWithFlags allows custom flags for specific formatting needs // FormatWithFlags allows custom flags for specific formatting needs
func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) { func (a *FormatterAdapter) FormatWithFlags(entry core.LogEntry, customFlags int64) ([]byte, error) {
return a.serialize(entry, customFlags), nil level := mapLevel(entry.Level)
} src := sourceLabel(entry)
// serialize renders an entry under the given flags. The returned slice is a var args []any
// copy: the underlying formatter reuses one buffer and sinks retain payloads. if len(entry.Fields) > 0 {
func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte { var fields map[string]any
args, flags := formatArgs(entry, flags) if err := json.Unmarshal(entry.Fields, &fields); err == nil && len(fields) > 0 {
args = []any{entry.Message, fields}
customFlags |= formatter.FlagStructuredJSON
}
}
if args == nil {
args = []any{entry.Message}
}
a.mu.Lock() a.mu.Lock()
out := bytes.Clone(a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args)) out := bytes.Clone(a.formatter.Format(customFlags, entry.Time, level, src, args))
a.mu.Unlock() a.mu.Unlock()
return out return out, nil
}
// formatArgs pairs the entry with its flags. FlagRaw keeps the fields JSON
// verbatim beside the message rather than silently overriding the caller's
// choice of passthrough; every other mode renders it as a JSON object.
func formatArgs(entry core.LogEntry, flags int64) ([]any, int64) {
if len(entry.Fields) == 0 {
return []any{entry.Message}, flags
}
if flags&formatter.FlagRaw != 0 {
if entry.Message == "" {
return []any{[]byte(entry.Fields)}, flags
}
return []any{entry.Message, []byte(entry.Fields)}, flags
}
var fields map[string]any
if err := json.Unmarshal(entry.Fields, &fields); err != nil || len(fields) == 0 {
return []any{entry.Message}, flags
}
return []any{entry.Message, fields}, flags | formatter.FlagStructuredJSON
} }
// Name returns formatter type // Name returns formatter type
+2 -30
View File
@@ -153,16 +153,11 @@ func (p *Pipeline) initializeComponents() error {
// initSourceCapabilities checks and injects optional capabilities // initSourceCapabilities checks and injects optional capabilities
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error { func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
// Initiate and activate source capabilities // Initiate and activate source capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
// Network capabilities // Network capabilities
case core.CapNetLimit: case core.CapNetLimit, core.CapTLS, core.CapAuth:
continue // No-op for now, placeholder continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities // Session capabilities
case core.CapSessionAware: case core.CapSessionAware:
@@ -174,36 +169,17 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
} }
} }
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("source %s: %w", cfg.ID, err)
}
return nil
}
// checkAuthCapability rejects a plugin that decides on peer identity without a
// transport that verifies one - the decision would rest on an unauthenticated
// claim
func checkAuthCapability(hasTLS, hasAuth bool) error {
if hasAuth && !hasTLS {
return fmt.Errorf("capability %q requires %q", core.CapAuth, core.CapTLS)
}
return nil return nil
} }
// initSinkCapabilities checks and injects optional capabilities // initSinkCapabilities checks and injects optional capabilities
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error { func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
// Initiate and activate sink capabilities // Initiate and activate sink capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() { for _, c := range s.Capabilities() {
switch c { switch c {
// Network capabilities // Network capabilities
case core.CapNetLimit: case core.CapNetLimit, core.CapTLS, core.CapAuth:
continue // No-op for now, placeholder continue // No-op for now, placeholder
case core.CapTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities // Session capabilities
case core.CapSessionAware: case core.CapSessionAware:
@@ -215,10 +191,6 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
} }
} }
if err := checkAuthCapability(hasTLS, hasAuth); err != nil {
return fmt.Errorf("sink %s: %w", cfg.ID, err)
}
return nil return nil
} }
+37 -164
View File
@@ -2,11 +2,9 @@ package http
import ( import (
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -15,13 +13,11 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/sink" "logwisp/internal/sink"
"logwisp/internal/tlsx"
"logwisp/internal/version" "logwisp/internal/version"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
@@ -67,14 +63,6 @@ type HTTPSink struct {
clients map[uint64]*sseClient clients map[uint64]*sseClient
clientsMu sync.Mutex clientsMu sync.Mutex
nextClientID atomic.Uint64 nextClientID atomic.Uint64
writeTimeout time.Duration
keepalive time.Duration
// TLS
tlsConfig *tls.Config
// Authorization
auth *authz.Policy
// Runtime // Runtime
done chan struct{} done chan struct{}
@@ -82,6 +70,8 @@ type HTTPSink struct {
wg sync.WaitGroup wg sync.WaitGroup
startTime time.Time startTime time.Time
writeTimeout time.Duration
// Statistics // Statistics
activeClients atomic.Int64 activeClients atomic.Int64
totalProcessed atomic.Uint64 totalProcessed atomic.Uint64
@@ -132,14 +122,6 @@ func NewHTTPSinkPlugin(
if opts.ClientBufferSize <= 0 { if opts.ClientBufferSize <= 0 {
opts.ClientBufferSize = DefaultHTTPClientBufferSize opts.ClientBufferSize = DefaultHTTPClientBufferSize
} }
tlsCfg, err := tlsx.Server(opts.TLS)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
if err != nil {
return nil, err
}
h := &HTTPSink{ h := &HTTPSink{
id: id, id: id,
@@ -151,9 +133,6 @@ func NewHTTPSinkPlugin(
logger: logger, logger: logger,
clients: make(map[uint64]*sseClient), clients: make(map[uint64]*sseClient),
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
keepalive: core.StreamKeepaliveInterval,
tlsConfig: tlsCfg,
auth: authPolicy,
} }
h.lastProcessed.Store(time.Time{}) h.lastProcessed.Store(time.Time{})
@@ -163,29 +142,17 @@ func NewHTTPSinkPlugin(
"host", opts.Host, "host", opts.Host,
"port", opts.Port, "port", opts.Port,
"stream_path", opts.StreamPath, "stream_path", opts.StreamPath,
"status_path", opts.StatusPath, "status_path", opts.StatusPath)
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "http_sink",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named clients")
}
return h, nil return h, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (h *HTTPSink) Capabilities() []core.Capability { func (h *HTTPSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} // CapTLS/CapAuth appended when transport security lands
if h.tlsConfig != nil { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
core.CapMultiSession,
} }
if h.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
}
return caps
} }
// Input returns the channel for sending transport events // Input returns the channel for sending transport events
@@ -196,8 +163,8 @@ func (h *HTTPSink) Input() chan<- core.TransportEvent {
// Start binds the listener and serves stream/status endpoints // Start binds the listener and serves stream/status endpoints
func (h *HTTPSink) Start(ctx context.Context) error { func (h *HTTPSink) Start(ctx context.Context) error {
// IPv4-only, parity with existing network sinks. // IPv4-only, parity with existing network sinks.
// TLS is applied via server.TLSConfig + ServeTLS below, not by wrapping // TLS extension point: wrap ln with tls.NewListener (or set
// ln; net/http then owns handshake, ALPN (h2), and per-conn errors. // server.TLSConfig and use ServeTLS); single seam, handlers unchanged.
ln, err := net.Listen("tcp4", h.addr) ln, err := net.Listen("tcp4", h.addr)
if err != nil { if err != nil {
return fmt.Errorf("http sink bind %s: %w", h.addr, err) return fmt.Errorf("http sink bind %s: %w", h.addr, err)
@@ -207,39 +174,24 @@ func (h *HTTPSink) Start(ctx context.Context) error {
// Method-scoped patterns: mux answers 405 with Allow header on non-GET // Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream) mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus) mux.HandleFunc(http.MethodGet+" "+h.config.StatusPath, h.handleStatus)
// A GET pattern also serves HEAD, and a HEAD stream is a registered client
// whose body writes are discarded: it never reads, so nothing but the peer
// closing the connection ends it. The status path answers one either way.
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
// One wrapper covers stream and status, and keeps the handlers themselves // Auth extension point: wrap mux with auth middleware once credentials
// unaware of authorization // land, e.g. handler = authMiddleware(cfg)(handler)
var handler http.Handler = mux var handler http.Handler = mux
if h.auth.Enabled() {
handler = h.authMiddleware(handler)
}
h.server = &http.Server{ h.server = &http.Server{
Handler: handler, Handler: handler,
ReadHeaderTimeout: HTTPReadHeaderTimeout, ReadHeaderTimeout: HTTPReadHeaderTimeout,
// WriteTimeout unset by design: SSE responses are long-lived. // WriteTimeout unset by design: SSE responses are long-lived.
// net/http bounds the TLS handshake by min(ReadHeaderTimeout, // Per-write deadlines via ResponseController in handleStream.
// ReadTimeout, WriteTimeout), so ReadHeaderTimeout covers it here.
ErrorLog: tlsx.HTTPErrorLog(h.logger, "http_sink"),
} }
h.startTime = time.Now() h.startTime = time.Now()
h.wg.Add(1) h.wg.Add(1)
go h.brokerLoop(ctx) go h.brokerLoop(ctx)
serve := h.server.Serve
if h.tlsConfig != nil {
h.server.TLSConfig = h.tlsConfig
serve = func(l net.Listener) error { return h.server.ServeTLS(l, "", "") }
}
go func() { go func() {
if err := serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := h.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
h.logger.Error("msg", "HTTP server terminated", h.logger.Error("msg", "HTTP server terminated",
"component", "http_sink", "component", "http_sink",
"instance_id", h.id, "instance_id", h.id,
@@ -293,9 +245,9 @@ func (h *HTTPSink) shutdown() {
}) })
} }
// removeClient unregisters a client; the first caller closes the send channel. // removeClient unregisters a client; the first caller closes the send
// Broker (stale-session eviction) and stream handler (disconnect) may race here // channel and removes the session. Broker (stale-session eviction) and
// safely. The session is the handler's, released when it returns. // stream handler (disconnect) may race here safely.
func (h *HTTPSink) removeClient(id uint64) { func (h *HTTPSink) removeClient(id uint64) {
h.clientsMu.Lock() h.clientsMu.Lock()
c, ok := h.clients[id] c, ok := h.clients[id]
@@ -305,6 +257,7 @@ func (h *HTTPSink) removeClient(id uint64) {
h.clientsMu.Unlock() h.clientsMu.Unlock()
if ok { if ok {
close(c.send) close(c.send)
h.proxy.RemoveSession(c.sessionID)
} }
} }
@@ -359,19 +312,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w) rc := http.NewResponseController(w)
remote := r.RemoteAddr remote := r.RemoteAddr
meta := map[string]any{ sess := h.proxy.CreateSession(remote, map[string]any{
"type": "http_client", "type": "http_client",
} })
if r.TLS != nil {
meta["tls"] = true
if cn := tlsx.PeerCN(*r.TLS); cn != "" {
meta["tls_peer_cn"] = cn
}
}
// Set by authMiddleware; absent when auth is disabled
ident, _ := r.Context().Value(identityKey{}).(authz.Identity)
ident.Apply(meta)
sess := h.proxy.CreateSession(remote, meta)
c := &sseClient{ c := &sseClient{
send: make(chan []byte, h.config.ClientBufferSize), send: make(chan []byte, h.config.ClientBufferSize),
@@ -379,18 +322,20 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
} }
id := h.nextClientID.Add(1) id := h.nextClientID.Add(1)
h.clientsMu.Lock()
h.clients[id] = c
h.clientsMu.Unlock()
count := h.activeClients.Add(1) count := h.activeClients.Add(1)
h.logger.Debug("msg", "HTTP client connected", h.logger.Debug("msg", "HTTP client connected",
"component", "http_sink", "component", "http_sink",
"remote_addr", remote, "remote_addr", remote,
"session_id", sess.ID, "session_id", sess.ID,
"client_id", id, "client_id", id,
"auth_identity", ident.Name,
"active_clients", count) "active_clients", count)
defer func() { defer func() {
h.removeClient(id) h.removeClient(id)
h.proxy.RemoveSession(sess.ID)
newCount := h.activeClients.Add(-1) newCount := h.activeClients.Add(-1)
h.logger.Debug("msg", "HTTP client disconnected", h.logger.Debug("msg", "HTTP client disconnected",
"component", "http_sink", "component", "http_sink",
@@ -415,24 +360,11 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
"status_path": h.config.StatusPath, "status_path": h.config.StatusPath,
"buffer_size": h.config.ClientBufferSize, "buffer_size": h.config.ClientBufferSize,
}) })
h.armWrite(rc)
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", info) fmt.Fprintf(w, "event: connected\ndata: %s\n\n", info)
if err := rc.Flush(); err != nil { if err := rc.Flush(); err != nil {
return return
} }
// Registered only now: a client the broker can queue into before its reader
// reaches the loop below loses a burst to a buffer nobody is draining.
h.clientsMu.Lock()
h.clients[id] = c
h.clientsMu.Unlock()
// A stream with nothing to carry still has to prove the peer is there. The
// comment refreshes the session the broker evicts on, and fails on a peer
// that stopped reading.
idle := time.NewTicker(h.keepalive)
defer idle.Stop()
clientGone := r.Context().Done() clientGone := r.Context().Done()
for { for {
select { select {
@@ -440,7 +372,9 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return // broker evicted (stale session) return // broker evicted (stale session)
} }
h.armWrite(rc) if h.writeTimeout > 0 {
_ = rc.SetWriteDeadline(time.Now().Add(h.writeTimeout))
}
if err := writeSSE(w, payload); err != nil { if err := writeSSE(w, payload); err != nil {
return return
} }
@@ -448,15 +382,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
return return
} }
h.proxy.UpdateActivity(sess.ID) h.proxy.UpdateActivity(sess.ID)
case <-idle.C:
h.armWrite(rc)
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
return
}
if err := rc.Flush(); err != nil {
return
}
h.proxy.UpdateActivity(sess.ID)
case <-clientGone: case <-clientGone:
return return
case <-h.done: case <-h.done:
@@ -467,14 +392,6 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
} }
} }
// armWrite bounds the next response write. Without it an SSE write is unbounded
// and a peer that stops reading wedges its handler for as long as it stays open.
func (h *HTTPSink) armWrite(rc *http.ResponseController) {
if h.writeTimeout > 0 {
_ = rc.SetWriteDeadline(time.Now().Add(h.writeTimeout))
}
}
// handleStatus provides a JSON status report // handleStatus provides a JSON status report
func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) { func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]any{ status := map[string]any{
@@ -485,13 +402,8 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
"type": "http", "type": "http",
"host": h.config.Host, "host": h.config.Host,
"port": h.config.Port, "port": h.config.Port,
"tls": h.tlsConfig != nil,
"auth": h.auth.Describe(),
"active_clients": h.activeClients.Load(), "active_clients": h.activeClients.Load(),
"buffer_size": h.config.BufferSize, "buffer_size": h.config.BufferSize,
"client_buffer_size": h.config.ClientBufferSize,
"max_connections": h.config.MaxConnections,
"write_timeout_ms": h.config.WriteTimeoutMS,
"uptime_seconds": int(time.Since(h.startTime).Seconds()), "uptime_seconds": int(time.Since(h.startTime).Seconds()),
}, },
"endpoints": map[string]string{ "endpoints": map[string]string{
@@ -502,7 +414,6 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
"total_processed": h.totalProcessed.Load(), "total_processed": h.totalProcessed.Load(),
"dropped_writes": h.droppedWrites.Load(), "dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(), "rejected_clients": h.rejectedClients.Load(),
"auth_rejected": h.auth.Rejected(),
}, },
} }
@@ -513,23 +424,6 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
// GetStats returns sink statistics // GetStats returns sink statistics
func (h *HTTPSink) GetStats() sink.SinkStats { func (h *HTTPSink) GetStats() sink.SinkStats {
lastProc, _ := h.lastProcessed.Load().(time.Time) lastProc, _ := h.lastProcessed.Load().(time.Time)
details := map[string]any{
"host": h.config.Host,
"port": h.config.Port,
"buffer_size": h.config.BufferSize,
"client_buffer_size": h.config.ClientBufferSize,
"max_connections": h.config.MaxConnections,
"write_timeout_ms": h.config.WriteTimeoutMS,
"tls": h.tlsConfig != nil,
"dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(),
"endpoints": map[string]string{
"stream": h.config.StreamPath,
"status": h.config.StatusPath,
},
}
maps.Copy(details, h.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: h.id, ID: h.id,
Type: "http", Type: "http",
@@ -537,41 +431,20 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
ActiveConnections: h.activeClients.Load(), ActiveConnections: h.activeClients.Load(),
StartTime: h.startTime, StartTime: h.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: details, Details: map[string]any{
"host": h.config.Host,
"port": h.config.Port,
"buffer_size": h.config.BufferSize,
"dropped_writes": h.droppedWrites.Load(),
"rejected_clients": h.rejectedClients.Load(),
"endpoints": map[string]string{
"stream": h.config.StreamPath,
"status": h.config.StatusPath,
},
},
} }
} }
// identityKey carries the authorized identity from the middleware to the
// handlers; absent when auth is disabled
type identityKey struct{}
// authMiddleware gates every endpoint on the client certificate policy.
// The rejection carries no detail: the status endpoint already exposes host,
// port, and throughput counters, so a 403 should not add the shape of the
// policy on top of that.
func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.Authorize(r.TLS)
if err != nil {
h.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, ident)))
})
}
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
// writeSSE frames a payload per the W3C SSE spec (multi-line safe) // writeSSE frames a payload per the W3C SSE spec (multi-line safe)
func writeSSE(w http.ResponseWriter, payload []byte) error { func writeSSE(w http.ResponseWriter, payload []byte) error {
for _, line := range splitLines(payload) { for _, line := range splitLines(payload) {
-167
View File
@@ -1,167 +0,0 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"logwisp/internal/session"
"logwisp/internal/sink"
"github.com/lixenwraith/log"
)
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
manager := session.NewManager(time.Hour)
defer manager.Stop()
created, err := NewHTTPSinkPlugin(
"stream",
map[string]any{
"host": "127.0.0.1",
"port": int64(8081),
"buffer_size": int64(4096),
"client_buffer_size": int64(512),
"max_connections": int64(32),
"write_timeout_ms": int64(5000),
},
log.NewLogger(),
session.NewProxy(manager, "stream"),
)
if err != nil {
t.Fatal(err)
}
httpSink, ok := created.(*HTTPSink)
if !ok {
t.Fatalf("sink type = %T", created)
}
recorder := httptest.NewRecorder()
httpSink.handleStatus(recorder, httptest.NewRequest("GET", "/status", nil))
if recorder.Code != 200 {
t.Fatalf("status code = %d", recorder.Code)
}
var response struct {
Server map[string]any `json:"server"`
}
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatal(err)
}
for key, want := range map[string]float64{
"buffer_size": 4096,
"client_buffer_size": 512,
"max_connections": 32,
"write_timeout_ms": 5000,
} {
if got := response.Server[key]; got != want {
t.Errorf("server.%s = %v, want %v", key, got, want)
}
}
stats := httpSink.GetStats()
details := stats.Details
for key, want := range map[string]int64{
"buffer_size": 4096,
"client_buffer_size": 512,
"max_connections": 32,
"write_timeout_ms": 5000,
} {
if got := details[key]; got != want {
t.Errorf("details[%q] = %v, want %v", key, got, want)
}
}
var _ sink.Sink = httpSink
}
// A stream carrying nothing still refreshes its session. Log traffic is what
// bumps activity otherwise, so a quiet source would idle-expire a healthy client
// and the broker would evict it on the next entry.
func TestQuietStreamRefreshesItsSession(t *testing.T) {
manager := session.NewManager(time.Hour)
defer manager.Stop()
created, err := NewHTTPSinkPlugin(
"stream",
map[string]any{"host": "127.0.0.1", "port": int64(18191), "write_timeout_ms": int64(5000)},
log.NewLogger(),
session.NewProxy(manager, "stream"),
)
if err != nil {
t.Fatal(err)
}
httpSink := created.(*HTTPSink)
httpSink.keepalive = 100 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := httpSink.Start(ctx); err != nil {
t.Fatal(err)
}
defer httpSink.Stop()
resp, err := http.Get("http://127.0.0.1:18191/stream")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
activity := func() time.Time {
for _, s := range manager.GetActiveSessions() {
return s.LastActivity
}
t.Fatal("no session for the connected client")
return time.Time{}
}
deadline := time.Now().Add(2 * time.Second)
for activity().IsZero() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
before := activity()
// No events are sent for several keepalive periods.
time.Sleep(350 * time.Millisecond)
if after := activity(); !after.After(before) {
t.Fatalf("last activity %v did not advance on a silent stream", after)
}
}
// HEAD on the stream path is refused rather than served from the GET pattern:
// its body writes are discarded, so the client it would register never reads.
func TestHeadOnStreamPathIsRefused(t *testing.T) {
manager := session.NewManager(time.Hour)
defer manager.Stop()
created, err := NewHTTPSinkPlugin(
"stream",
map[string]any{"host": "127.0.0.1", "port": int64(18192)},
log.NewLogger(),
session.NewProxy(manager, "stream"),
)
if err != nil {
t.Fatal(err)
}
httpSink := created.(*HTTPSink)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := httpSink.Start(ctx); err != nil {
t.Fatal(err)
}
defer httpSink.Stop()
resp, err := http.Head("http://127.0.0.1:18192/stream")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("HEAD /stream = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed)
}
if got := resp.Header.Get("Allow"); got != http.MethodGet {
t.Errorf("Allow = %q, want %q", got, http.MethodGet)
}
if n := manager.GetSessionCount(); n != 0 {
t.Errorf("sessions after HEAD = %d, want 0", n)
}
}
+15 -59
View File
@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"maps"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -16,14 +15,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/sink" "logwisp/internal/sink"
"logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -57,12 +54,6 @@ type HTTPChainSink struct {
node string node string
url string url string
tlsEnabled bool
mtls bool
// Authorization: pins the downstream server's identity
auth *authz.Policy
client *http.Client client *http.Client
input chan core.TransportEvent input chan core.TransportEvent
logger *log.Logger logger *log.Logger
@@ -137,20 +128,6 @@ func NewHTTPChainSinkPlugin(
} }
} }
tlsCfg, err := tlsx.Client(opts.TLS, opts.Host)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
if err != nil {
return nil, err
}
if authPolicy.Enabled() {
// Runs after the standard chain and hostname checks, so a server the
// policy rejects fails the handshake instead of the first request
tlsCfg.VerifyConnection = authPolicy.VerifyConnection
}
addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)) addr := net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10))
transport := &http.Transport{ transport := &http.Transport{
@@ -162,15 +139,7 @@ func NewHTTPChainSinkPlugin(
MaxIdleConnsPerHost: 2, MaxIdleConnsPerHost: 2,
IdleConnTimeout: 90 * time.Second, IdleConnTimeout: 90 * time.Second,
DisableCompression: true, DisableCompression: true,
TLSClientConfig: tlsCfg, // nil = plaintext // Future: TLSClientConfig; HTTP/2 via ALPN once TLS lands
TLSHandshakeTimeout: tlsx.HandshakeTimeout,
// h2 stays off: custom DialContext disables auto-ALPN and batched
// NDJSON POSTs gain nothing from it
}
scheme := "http"
if tlsCfg != nil {
scheme = "https"
} }
t := &HTTPChainSink{ t := &HTTPChainSink{
@@ -178,10 +147,8 @@ func NewHTTPChainSinkPlugin(
proxy: proxy, proxy: proxy,
config: opts, config: opts,
node: node, node: node,
tlsEnabled: tlsCfg != nil, // Future: "https" scheme with TLS
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0, url: "http://" + addr + opts.IngestPath,
auth: authPolicy,
url: scheme + "://" + addr + opts.IngestPath,
client: &http.Client{Transport: transport}, client: &http.Client{Transport: transport},
input: make(chan core.TransportEvent, opts.BufferSize), input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}), done: make(chan struct{}),
@@ -204,23 +171,16 @@ func NewHTTPChainSinkPlugin(
"component", "http_chain_sink", "component", "http_chain_sink",
"instance_id", id, "instance_id", id,
"target", t.url, "target", t.url,
"node", node, "node", node)
"tls", t.tlsEnabled,
"mtls", t.mtls,
"auth", authPolicy.Describe())
return t, nil return t, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (t *HTTPChainSink) Capabilities() []core.Capability { func (t *HTTPChainSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware} // CapTLS/CapAuth added when transport security lands
if t.tlsEnabled { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
} }
if t.auth.Enabled() {
caps = append(caps, core.CapAuth) // pins the server identity
}
return caps
} }
// Input returns the channel for sending transport events // Input returns the channel for sending transport events
@@ -265,24 +225,20 @@ func (t *HTTPChainSink) Stop() {
// GetStats returns sink statistics // GetStats returns sink statistics
func (t *HTTPChainSink) GetStats() sink.SinkStats { func (t *HTTPChainSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time) lastProc, _ := t.lastProcessed.Load().(time.Time)
details := map[string]any{
"target": t.url,
"node": t.node,
"tls": t.tlsEnabled,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "http_chain", Type: "http_chain",
TotalProcessed: t.totalProcessed.Load(), TotalProcessed: t.totalProcessed.Load(),
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: details, Details: map[string]any{
"target": t.url,
"node": t.node,
"batches_sent": t.batchesSent.Load(),
"request_errors": t.requestErrors.Load(),
"dropped_batches": t.droppedBatches.Load(),
"synthesized": t.synthesized.Load(),
},
} }
} }
+30 -116
View File
@@ -2,23 +2,19 @@ package tcp
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/sink" "logwisp/internal/sink"
"logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -62,14 +58,6 @@ type TCPSink struct {
clients map[uint64]*tcpClient clients map[uint64]*tcpClient
clientsMu sync.Mutex clientsMu sync.Mutex
nextClientID atomic.Uint64 nextClientID atomic.Uint64
writeTimeout time.Duration
// TLS
tlsConfig *tls.Config
tlsHandshakeErrors atomic.Uint64
// Authorization
auth *authz.Policy
// Runtime // Runtime
done chan struct{} done chan struct{}
@@ -77,6 +65,8 @@ type TCPSink struct {
wg sync.WaitGroup wg sync.WaitGroup
startTime time.Time startTime time.Time
writeTimeout time.Duration
// Statistics // Statistics
activeConns atomic.Int64 activeConns atomic.Int64
totalProcessed atomic.Uint64 totalProcessed atomic.Uint64
@@ -125,14 +115,6 @@ func NewTCPSinkPlugin(
if opts.KeepAlivePeriodMS <= 0 { if opts.KeepAlivePeriodMS <= 0 {
opts.KeepAlivePeriodMS = DefaultTCPKeepAlivePeriodMS opts.KeepAlivePeriodMS = DefaultTCPKeepAlivePeriodMS
} }
tlsCfg, err := tlsx.Server(opts.TLS)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
if err != nil {
return nil, err
}
t := &TCPSink{ t := &TCPSink{
id: id, id: id,
@@ -144,8 +126,6 @@ func NewTCPSinkPlugin(
logger: logger, logger: logger,
clients: make(map[uint64]*tcpClient), clients: make(map[uint64]*tcpClient),
writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond, writeTimeout: time.Duration(opts.WriteTimeoutMS) * time.Millisecond,
tlsConfig: tlsCfg,
auth: authPolicy,
} }
t.lastProcessed.Store(time.Time{}) t.lastProcessed.Store(time.Time{})
@@ -153,29 +133,17 @@ func NewTCPSinkPlugin(
"component", "tcp_sink", "component", "tcp_sink",
"instance_id", id, "instance_id", id,
"host", opts.Host, "host", opts.Host,
"port", opts.Port, "port", opts.Port)
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "tcp_sink",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named clients")
}
return t, nil return t, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (t *TCPSink) Capabilities() []core.Capability { func (t *TCPSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} // CapTLS/CapAuth appended when transport security lands
if t.tlsConfig != nil { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
core.CapMultiSession,
} }
if t.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
}
return caps
} }
// Input returns the channel for sending transport events // Input returns the channel for sending transport events
@@ -183,9 +151,10 @@ func (t *TCPSink) Input() chan<- core.TransportEvent {
return t.input return t.input
} }
// listen creates the server listener, TLS-wrapped when configured. // listen creates the server listener.
// Handshake is deferred: tls.NewListener conns handshake explicitly in // TLS extension point: wrap the returned listener with tls.NewListener here
// handleConn under tlsx.HandshakeTimeout, post max_connections admission. // once cert config lands; no other code path changes. mTLS peer identity is
// then available via conn.(*tls.Conn).ConnectionState() in the auth hook.
func (t *TCPSink) listen() (net.Listener, error) { func (t *TCPSink) listen() (net.Listener, error) {
lc := net.ListenConfig{} lc := net.ListenConfig{}
if t.config.KeepAlive { if t.config.KeepAlive {
@@ -195,14 +164,7 @@ func (t *TCPSink) listen() (net.Listener, error) {
} }
} }
// IPv4-only, parity with existing network sinks // IPv4-only, parity with existing network sinks
ln, err := lc.Listen(context.Background(), "tcp4", t.addr) return lc.Listen(context.Background(), "tcp4", t.addr)
if err != nil {
return nil, err
}
if t.tlsConfig != nil {
ln = tls.NewListener(ln, t.tlsConfig)
}
return ln, nil
} }
// Start binds the listener and launches accept and broadcast loops // Start binds the listener and launches accept and broadcast loops
@@ -287,9 +249,8 @@ func (t *TCPSink) acceptLoop() {
continue continue
} }
// Certificate authorization runs in handleConn post-handshake, // Auth extension point: credential/peer verification runs here,
// pre-registration. Password-auth extension point: preamble // pre-registration (password preamble read or TLS peer cert check)
// verification belongs at the same place.
t.wg.Add(1) t.wg.Add(1)
go t.handleConn(conn) go t.handleConn(conn)
@@ -302,58 +263,11 @@ func (t *TCPSink) handleConn(conn net.Conn) {
defer t.wg.Done() defer t.wg.Done()
remote := conn.RemoteAddr().String() remote := conn.RemoteAddr().String()
// Counted from accept: max_connections bounds concurrent handshakes too sess := t.proxy.CreateSession(remote, map[string]any{
count := t.activeConns.Add(1)
defer func() {
newCount := t.activeConns.Add(-1)
t.logger.Debug("msg", "TCP connection closed",
"component", "tcp_sink",
"remote_addr", remote,
"active_connections", newCount)
}()
meta := map[string]any{
"type": "tcp_client", "type": "tcp_client",
"remote_addr": remote, "remote_addr": remote,
} })
var tlsState *tls.ConnectionState
if tc, ok := conn.(*tls.Conn); ok {
hctx, cancel := context.WithTimeout(context.Background(), tlsx.HandshakeTimeout)
err := tc.HandshakeContext(hctx)
cancel()
if err != nil {
t.tlsHandshakeErrors.Add(1)
t.logger.Debug("msg", "TLS handshake failed",
"component", "tcp_sink",
"remote_addr", remote,
"error", err)
conn.Close()
return
}
cs := tc.ConnectionState()
tlsState = &cs
meta["tls"] = true
if cn := tlsx.PeerCN(cs); cn != "" {
meta["tls_peer_cn"] = cn
}
}
// Authorize before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast
ident, err := t.auth.Authorize(tlsState)
if err != nil {
t.rejectedConns.Add(1)
t.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_sink",
"instance_id", t.id,
"remote_addr", remote,
"error", err)
conn.Close()
return
}
ident.Apply(meta)
sess := t.proxy.CreateSession(remote, meta)
c := &tcpClient{ c := &tcpClient{
conn: conn, conn: conn,
send: make(chan []byte, t.config.ClientBufferSize), send: make(chan []byte, t.config.ClientBufferSize),
@@ -366,11 +280,11 @@ func (t *TCPSink) handleConn(conn net.Conn) {
t.clients[id] = c t.clients[id] = c
t.clientsMu.Unlock() t.clientsMu.Unlock()
count := t.activeConns.Add(1)
t.logger.Debug("msg", "TCP connection opened", t.logger.Debug("msg", "TCP connection opened",
"component", "tcp_sink", "component", "tcp_sink",
"remote_addr", remote, "remote_addr", remote,
"session_id", sess.ID, "session_id", sess.ID,
"auth_identity", ident.Name,
"active_connections", count) "active_connections", count)
defer func() { defer func() {
@@ -380,6 +294,11 @@ func (t *TCPSink) handleConn(conn net.Conn) {
conn.Close() conn.Close()
<-c.closed // reader has exited <-c.closed // reader has exited
t.proxy.RemoveSession(sess.ID) t.proxy.RemoveSession(sess.ID)
newCount := t.activeConns.Add(-1)
t.logger.Debug("msg", "TCP connection closed",
"component", "tcp_sink",
"remote_addr", remote,
"active_connections", newCount)
}() }()
// Reader: sink is write-only; drain and discard inbound bytes to detect // Reader: sink is write-only; drain and discard inbound bytes to detect
@@ -458,18 +377,6 @@ func (t *TCPSink) broadcastLoop(ctx context.Context) {
// GetStats returns sink statistics // GetStats returns sink statistics
func (t *TCPSink) GetStats() sink.SinkStats { func (t *TCPSink) GetStats() sink.SinkStats {
lastProc, _ := t.lastProcessed.Load().(time.Time) lastProc, _ := t.lastProcessed.Load().(time.Time)
details := map[string]any{
"host": t.config.Host,
"port": t.config.Port,
"buffer_size": t.config.BufferSize,
"write_errors": t.writeErrors.Load(),
"dropped_writes": t.droppedWrites.Load(),
"rejected_conns": t.rejectedConns.Load(),
"tls": t.tlsConfig != nil,
"tls_handshake_errors": t.tlsHandshakeErrors.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "tcp", Type: "tcp",
@@ -477,6 +384,13 @@ func (t *TCPSink) GetStats() sink.SinkStats {
ActiveConnections: t.activeConns.Load(), ActiveConnections: t.activeConns.Load(),
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: details, Details: map[string]any{
"host": t.config.Host,
"port": t.config.Port,
"buffer_size": t.config.BufferSize,
"write_errors": t.writeErrors.Load(),
"dropped_writes": t.droppedWrites.Load(),
"rejected_conns": t.rejectedConns.Load(),
},
} }
} }
+18 -63
View File
@@ -2,10 +2,8 @@ package tcpchain
import ( import (
"context" "context"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"math/rand/v2" "math/rand/v2"
"net" "net"
"os" "os"
@@ -14,14 +12,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/sink" "logwisp/internal/sink"
"logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -52,10 +48,6 @@ type TCPChainSink struct {
node string node string
addr string addr string
helloLine []byte helloLine []byte
tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
auth *authz.Policy
input chan core.TransportEvent input chan core.TransportEvent
logger *log.Logger logger *log.Logger
@@ -130,19 +122,6 @@ func NewTCPChainSinkPlugin(
if err != nil { if err != nil {
return nil, fmt.Errorf("hello: %w", err) return nil, fmt.Errorf("hello: %w", err)
} }
tlsCfg, err := tlsx.Client(opts.TLS, opts.Host)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
if err != nil {
return nil, err
}
if authPolicy.Enabled() {
// Runs after the standard chain and hostname checks, so a server the
// policy rejects fails the handshake instead of the first write
tlsCfg.VerifyConnection = authPolicy.VerifyConnection
}
t := &TCPChainSink{ t := &TCPChainSink{
id: id, id: id,
@@ -151,8 +130,6 @@ func NewTCPChainSinkPlugin(
node: node, node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)), addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
helloLine: helloLine, helloLine: helloLine,
tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize), input: make(chan core.TransportEvent, opts.BufferSize),
done: make(chan struct{}), done: make(chan struct{}),
logger: logger, logger: logger,
@@ -175,23 +152,16 @@ func NewTCPChainSinkPlugin(
"component", "tcp_chain_sink", "component", "tcp_chain_sink",
"instance_id", id, "instance_id", id,
"target", t.addr, "target", t.addr,
"node", node, "node", node)
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
"auth", authPolicy.Describe())
return t, nil return t, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (t *TCPChainSink) Capabilities() []core.Capability { func (t *TCPChainSink) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware} // CapTLS/CapAuth added when transport security lands
if t.tlsConfig != nil { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
} }
if t.auth.Enabled() {
caps = append(caps, core.CapAuth) // pins the server identity
}
return caps
} }
// Input returns the channel for sending transport events // Input returns the channel for sending transport events
@@ -240,17 +210,6 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
if t.connected.Load() { if t.connected.Load() {
active = 1 active = 1
} }
details := map[string]any{
"target": t.addr,
"node": t.node,
"tls": t.tlsConfig != nil,
"connected": t.connected.Load(),
"reconnects": t.reconnects.Load(),
"write_errors": t.writeErrors.Load(),
"synthesized": t.synthesized.Load(),
}
maps.Copy(details, t.auth.Stats())
return sink.SinkStats{ return sink.SinkStats{
ID: t.id, ID: t.id,
Type: "tcp_chain", Type: "tcp_chain",
@@ -258,7 +217,14 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
ActiveConnections: active, ActiveConnections: active,
StartTime: t.startTime, StartTime: t.startTime,
LastProcessed: lastProc, LastProcessed: lastProc,
Details: details, Details: map[string]any{
"target": t.addr,
"node": t.node,
"connected": t.connected.Load(),
"reconnects": t.reconnects.Load(),
"write_errors": t.writeErrors.Load(),
"synthesized": t.synthesized.Load(),
},
} }
} }
@@ -355,28 +321,18 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
} }
} }
// connect performs a single dial (+ TLS handshake) + hello attempt // connect performs a single dial + hello attempt
func (t *TCPChainSink) connect(ctx context.Context) error { func (t *TCPChainSink) connect(ctx context.Context) error {
nd := net.Dialer{Timeout: t.dialTimeout} d := net.Dialer{Timeout: t.dialTimeout}
if t.config.KeepAlive { if t.config.KeepAlive {
nd.KeepAliveConfig = net.KeepAliveConfig{ d.KeepAliveConfig = net.KeepAliveConfig{
Enable: true, Enable: true,
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond, Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
} }
} }
var conn net.Conn // IPv4-only
var err error conn, err := d.DialContext(ctx, "tcp4", t.addr)
if t.tlsConfig != nil {
// nd.Timeout only bounds the TCP connect; tls.Dialer runs the
// handshake under ctx, so bound dial + handshake together here
dctx, cancel := context.WithTimeout(ctx, t.dialTimeout+tlsx.HandshakeTimeout)
td := tls.Dialer{NetDialer: &nd, Config: t.tlsConfig}
conn, err = td.DialContext(dctx, "tcp4", t.addr) // IPv4-only
cancel()
} else {
conn, err = nd.DialContext(ctx, "tcp4", t.addr) // IPv4-only
}
if err != nil { if err != nil {
return err return err
} }
@@ -397,8 +353,7 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
t.logger.Info("msg", "Chain link established", t.logger.Info("msg", "Chain link established",
"component", "tcp_chain_sink", "component", "tcp_chain_sink",
"target", t.addr, "target", t.addr,
"node", t.node, "node", t.node)
"tls", t.tlsConfig != nil)
return nil return nil
} }
+4 -46
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall"
"time" "time"
"logwisp/internal/config" "logwisp/internal/config"
@@ -62,7 +61,6 @@ const (
DefaultFileSourcePattern = "*" DefaultFileSourcePattern = "*"
DefaultFileSourceCheckIntervalMS = 100 DefaultFileSourceCheckIntervalMS = 100
MinFileSourceCheckIntervalMS = 10 MinFileSourceCheckIntervalMS = 10
DefaultFileSourceFrom = "end"
) )
// NewFileSourcePlugin creates a file source through plugin factory // NewFileSourcePlugin creates a file source through plugin factory
@@ -92,11 +90,6 @@ func NewFileSourcePlugin(
} else if opts.CheckIntervalMS < MinFileSourceCheckIntervalMS { } else if opts.CheckIntervalMS < MinFileSourceCheckIntervalMS {
return nil, fmt.Errorf("check_interval_ms: must be >= %d", MinFileSourceCheckIntervalMS) return nil, fmt.Errorf("check_interval_ms: must be >= %d", MinFileSourceCheckIntervalMS)
} }
if opts.From == "" {
opts.From = DefaultFileSourceFrom
} else if err := lconfig.OneOf("start", "end")(opts.From); err != nil {
return nil, fmt.Errorf("from: %w", err)
}
// Create and return plugin instance // Create and return plugin instance
fs := &FileSource{ fs := &FileSource{
@@ -123,9 +116,7 @@ func NewFileSourcePlugin(
"component", "file_source", "component", "file_source",
"instance_id", id, "instance_id", id,
"directory", opts.Directory, "directory", opts.Directory,
"pattern", opts.Pattern, "pattern", opts.Pattern)
"raw", opts.Raw,
"from", opts.From)
return fs, nil return fs, nil
} }
@@ -270,13 +261,7 @@ func (fs *FileSource) ensureWatcher(path string) {
return return
} }
w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", fs.publish, fs.logger) w := newFileWatcher(path, fs.publish, fs.logger)
// A rotation renames the file out from under its watcher, so the same inode
// reappears here under the archive name. Resume where it was left: from the
// start would re-emit every record the file has already delivered.
if position, ok := fs.readPosition(path); ok {
w.position = position
}
fs.watchers[path] = w fs.watchers[path] = w
fs.logger.Debug("msg", "Created file watcher", fs.logger.Debug("msg", "Created file watcher",
@@ -299,38 +284,10 @@ func (fs *FileSource) ensureWatcher(path string) {
} }
} }
fs.removeWatcher(path, w)
}()
}
// readPosition reports how far a running watcher has read the file now at path.
// Callers hold fs.mu.
func (fs *FileSource) readPosition(path string) (int64, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
for _, w := range fs.watchers {
if position, ok := w.readTo(stat.Ino); ok {
return position, true
}
}
return 0, false
}
// removeWatcher removes only the watcher that finished. A deleted file can be
// recreated before its old watcher observes stop; in that case ensureWatcher
// has already installed a replacement under the same path, which must survive.
func (fs *FileSource) removeWatcher(path string, watcher *fileWatcher) {
fs.mu.Lock() fs.mu.Lock()
if fs.watchers[path] == watcher {
delete(fs.watchers, path) delete(fs.watchers, path)
}
fs.mu.Unlock() fs.mu.Unlock()
}()
} }
// cleanupWatchers stops and removes watchers for files that no longer exist. // cleanupWatchers stops and removes watchers for files that no longer exist.
@@ -404,3 +361,4 @@ func globToRegex(glob string) string {
regex = strings.ReplaceAll(regex, `\?`, `.`) regex = strings.ReplaceAll(regex, `\?`, `.`)
return "^" + regex + "$" return "^" + regex + "$"
} }
-84
View File
@@ -1,84 +0,0 @@
package file
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"logwisp/internal/core"
)
func TestStoppedWatcherReturnsNormally(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
watcher := newFileWatcher(path, true, true, func(_ core.LogEntry) {}, nil)
watcher.stop()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := watcher.watch(ctx); err != nil {
t.Fatalf("stopped watcher returned error: %v", err)
}
}
func TestRemoveWatcherPreservesReplacement(t *testing.T) {
oldWatcher := &fileWatcher{}
replacement := &fileWatcher{}
source := &FileSource{
watchers: map[string]*fileWatcher{
"session.jsonl": replacement,
},
}
source.removeWatcher("session.jsonl", oldWatcher)
if got := source.watchers["session.jsonl"]; got != replacement {
t.Fatalf("replacement watcher = %p, want %p", got, replacement)
}
source.removeWatcher("session.jsonl", replacement)
if _, exists := source.watchers["session.jsonl"]; exists {
t.Fatal("finished watcher was not removed")
}
}
// A rotated file reappears under its archive name with the same inode. Its
// replacement watcher resumes where the original stopped, so a `from = "start"`
// source does not re-emit every record the file already delivered.
func TestRotatedFileResumesInsteadOfReplaying(t *testing.T) {
dir := t.TempDir()
active := filepath.Join(dir, "session.jsonl")
if err := os.WriteFile(active, []byte("one\ntwo\n"), 0o600); err != nil {
t.Fatal(err)
}
info, err := os.Stat(active)
if err != nil {
t.Fatal(err)
}
inode := info.Sys().(*syscall.Stat_t).Ino
archive := filepath.Join(dir, "session_260916_120000.jsonl")
if err := os.Rename(active, archive); err != nil {
t.Fatal(err)
}
for name, watcher := range map[string]*fileWatcher{
"still tailing the renamed inode": {inode: inode, position: 8},
"already moved on from it": {inode: 99, prevInode: inode, prevPosition: 8},
} {
source := &FileSource{watchers: map[string]*fileWatcher{active: watcher}}
position, ok := source.readPosition(archive)
if !ok || position != 8 {
t.Errorf("%s: position = %d, ok = %v, want 8, true", name, position, ok)
}
}
unrelated := &FileSource{watchers: map[string]*fileWatcher{active: {inode: 99}}}
if _, ok := unrelated.readPosition(archive); ok {
t.Error("a file no watcher has read was treated as rotated")
}
}
+30 -89
View File
@@ -34,7 +34,6 @@ type WatcherInfo struct {
type fileWatcher struct { type fileWatcher struct {
directory string directory string
callback func(core.LogEntry) callback func(core.LogEntry)
raw bool
position int64 position int64
size int64 size int64
inode uint64 inode uint64
@@ -42,25 +41,17 @@ type fileWatcher struct {
mu sync.Mutex mu sync.Mutex
stopped bool stopped bool
rotationSeq int64 rotationSeq int64
prevInode uint64
prevPosition int64
entriesRead atomic.Uint64 entriesRead atomic.Uint64
lastReadTime atomic.Value // time.Time lastReadTime atomic.Value // time.Time
logger *log.Logger logger *log.Logger
} }
// newFileWatcher creates a new watcher for a specific file path. // newFileWatcher creates a new watcher for a specific file path
// A start position of 0 reads an existing file whole; -1 seeks to its end. func newFileWatcher(directory string, callback func(core.LogEntry), logger *log.Logger) *fileWatcher {
func newFileWatcher(directory string, raw, fromStart bool, callback func(core.LogEntry), logger *log.Logger) *fileWatcher {
position := int64(-1)
if fromStart {
position = 0
}
w := &fileWatcher{ w := &fileWatcher{
directory: directory, directory: directory,
callback: callback, callback: callback,
raw: raw, position: -1,
position: position,
logger: logger, logger: logger,
} }
w.lastReadTime.Store(time.Time{}) w.lastReadTime.Store(time.Time{})
@@ -69,8 +60,8 @@ func newFileWatcher(directory string, raw, fromStart bool, callback func(core.Lo
// watch starts the main monitoring loop for the file // watch starts the main monitoring loop for the file
func (w *fileWatcher) watch(ctx context.Context) error { func (w *fileWatcher) watch(ctx context.Context) error {
if err := w.initPosition(); err != nil { if err := w.seekToEnd(); err != nil {
return fmt.Errorf("initPosition failed: %w", err) return fmt.Errorf("seekToEnd failed: %w", err)
} }
ticker := time.NewTicker(core.FileWatcherPollInterval) ticker := time.NewTicker(core.FileWatcherPollInterval)
@@ -82,7 +73,7 @@ func (w *fileWatcher) watch(ctx context.Context) error {
return ctx.Err() return ctx.Err()
case <-ticker.C: case <-ticker.C:
if w.isStopped() { if w.isStopped() {
return nil return fmt.Errorf("watcher stopped")
} }
if err := w.checkFile(); err != nil { if err := w.checkFile(); err != nil {
// Log error but continue watching // Log error but continue watching
@@ -222,9 +213,6 @@ func (w *fileWatcher) checkFile() error {
w.mu.Lock() w.mu.Lock()
w.rotationSeq++ w.rotationSeq++
seq := w.rotationSeq seq := w.rotationSeq
// Retained for the source: the renamed file is about to be discovered
// under its archive name, and only this says how much of it was read.
w.prevInode, w.prevPosition = oldInode, oldPos
w.inode = currentInode w.inode = currentInode
w.position = 0 // Reset position on rotation w.position = 0 // Reset position on rotation
w.mu.Unlock() w.mu.Unlock()
@@ -309,9 +297,8 @@ func (w *fileWatcher) checkFile() error {
return nil return nil
} }
// initPosition records the file's metadata and, unless the watcher was created // seekToEnd sets the initial read position to the end of the file
// to read from the start, sets the initial read position to the end func (w *fileWatcher) seekToEnd() error {
func (w *fileWatcher) initPosition() error {
file, err := os.Open(w.directory) file, err := os.Open(w.directory)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -335,6 +322,8 @@ func (w *fileWatcher) initPosition() error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
// Keep existing position (including 0)
// First time initialization seeks to the end of the file
if w.position == -1 { if w.position == -1 {
pos, err := file.Seek(0, io.SeekEnd) pos, err := file.Seek(0, io.SeekEnd)
if err != nil { if err != nil {
@@ -352,23 +341,6 @@ func (w *fileWatcher) initPosition() error {
return nil return nil
} }
// readTo reports how far this watcher read the given inode: the file it tails
// now, or the one a rotation renamed out from under it.
func (w *fileWatcher) readTo(inode uint64) (int64, bool) {
if inode == 0 {
return 0, false
}
w.mu.Lock()
defer w.mu.Unlock()
switch inode {
case w.inode:
return w.position, true
case w.prevInode:
return w.prevPosition, true
}
return 0, false
}
// isStopped checks if the watcher has been instructed to stop // isStopped checks if the watcher has been instructed to stop
func (w *fileWatcher) isStopped() bool { func (w *fileWatcher) isStopped() bool {
w.mu.Lock() w.mu.Lock()
@@ -376,67 +348,36 @@ func (w *fileWatcher) isStopped() bool {
return w.stopped return w.stopped
} }
// parseLine converts a line into an entry, as JSON when nothing would be lost // parseLine attempts to parse a line as JSON, falling back to plain text
func (w *fileWatcher) parseLine(line string) core.LogEntry { func (w *fileWatcher) parseLine(line string) core.LogEntry {
if w.raw { var jsonLog struct {
// Newline restored: sinks write the payload as it stands Time string `json:"time"`
Level string `json:"level"`
Message string `json:"msg"`
Fields json.RawMessage `json:"fields"`
}
if err := json.Unmarshal([]byte(line), &jsonLog); err == nil {
timestamp, err := time.Parse(time.RFC3339Nano, jsonLog.Time)
if err != nil {
timestamp = time.Now()
}
return core.LogEntry{ return core.LogEntry{
Time: time.Now(), Time: timestamp,
Source: filepath.Base(w.directory), Source: filepath.Base(w.directory),
Level: source.ExtractLogLevel(line), Level: jsonLog.Level,
Message: line + "\n", Message: jsonLog.Message,
Fields: jsonLog.Fields,
} }
} }
if entry, ok := w.parseJSON(line); ok { level := source.ExtractLogLevel(line)
return entry
}
return core.LogEntry{ return core.LogEntry{
Time: time.Now(), Time: time.Now(),
Source: filepath.Base(w.directory), Source: filepath.Base(w.directory),
Level: source.ExtractLogLevel(line), Level: level,
Message: line, Message: line,
} }
} }
// parseJSON decodes a line into the entry envelope. A top-level key LogEntry
// cannot carry refuses the whole line, so a richer record reaches the pipeline
// as text rather than silently reduced to the four keys kept here.
func (w *fileWatcher) parseJSON(line string) (core.LogEntry, bool) {
if len(line) == 0 || line[0] != '{' {
return core.LogEntry{}, false
}
var obj map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &obj); err != nil || len(obj) == 0 {
return core.LogEntry{}, false
}
entry := core.LogEntry{Time: time.Now(), Source: filepath.Base(w.directory)}
for key, val := range obj {
var err error
switch key {
case "time":
var ts string
if json.Unmarshal(val, &ts) == nil {
if t, terr := time.Parse(time.RFC3339Nano, ts); terr == nil {
entry.Time = t
}
}
case "level":
err = json.Unmarshal(val, &entry.Level)
case "msg":
err = json.Unmarshal(val, &entry.Message)
case "fields":
entry.Fields = val
default:
return core.LogEntry{}, false
}
if err != nil {
return core.LogEntry{}, false
}
}
return entry, true
}
+28 -116
View File
@@ -3,10 +3,8 @@ package httpchain
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
@@ -15,14 +13,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/source" "logwisp/internal/source"
"logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -53,13 +49,7 @@ type HTTPChainSource struct {
server *http.Server server *http.Server
logger *log.Logger logger *log.Logger
// TLS // Session cache: one session per remote host + declared node
tlsConfig *tls.Config
// Authorization
auth *authz.Policy
// Session cache: one session per remote host + node + authenticated identity
sessions map[string]string // key -> sessionID sessions map[string]string // key -> sessionID
sessionsMu sync.Mutex sessionsMu sync.Mutex
@@ -105,14 +95,6 @@ func NewHTTPChainSourcePlugin(
if opts.ReadTimeoutMS <= 0 { if opts.ReadTimeoutMS <= 0 {
opts.ReadTimeoutMS = DefaultHTTPChainSourceReadTimeoutMS opts.ReadTimeoutMS = DefaultHTTPChainSourceReadTimeoutMS
} }
tlsCfg, err := tlsx.Server(opts.TLS)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
if err != nil {
return nil, err
}
s := &HTTPChainSource{ s := &HTTPChainSource{
id: id, id: id,
@@ -121,8 +103,6 @@ func NewHTTPChainSourcePlugin(
subscribers: make([]chan core.LogEntry, 0), subscribers: make([]chan core.LogEntry, 0),
sessions: make(map[string]string), sessions: make(map[string]string),
logger: logger, logger: logger,
tlsConfig: tlsCfg,
auth: authPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -131,36 +111,17 @@ func NewHTTPChainSourcePlugin(
"instance_id", id, "instance_id", id,
"host", opts.Host, "host", opts.Host,
"port", opts.Port, "port", opts.Port,
"ingest_path", opts.IngestPath, "ingest_path", opts.IngestPath)
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "http_chain_source",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if authPolicy.BindsNode() {
logger.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", "http_chain_source",
"instance_id", id,
"node_binding", authPolicy.NodeBinding(),
"trust_node", opts.TrustNode)
}
return s, nil return s, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (s *HTTPChainSource) Capabilities() []core.Capability { func (s *HTTPChainSource) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} // CapTLS/CapAuth added when transport security lands
if s.tlsConfig != nil { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
core.CapMultiSession,
} }
if s.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes peers, not just the CA
}
return caps
} }
// Subscribe returns a channel for receiving log entries // Subscribe returns a channel for receiving log entries
@@ -189,19 +150,12 @@ func (s *HTTPChainSource) Start() error {
Handler: mux, Handler: mux,
ReadTimeout: time.Duration(s.config.ReadTimeoutMS) * time.Millisecond, ReadTimeout: time.Duration(s.config.ReadTimeoutMS) * time.Millisecond,
ReadHeaderTimeout: HTTPChainReadHeaderTimeout, ReadHeaderTimeout: HTTPChainReadHeaderTimeout,
// TLS handshake bounded by min(ReadTimeout, ReadHeaderTimeout) // Future: TLSConfig for transport security
ErrorLog: tlsx.HTTPErrorLog(s.logger, "http_chain_source"),
} }
s.startTime = time.Now() s.startTime = time.Now()
serve := s.server.Serve
if s.tlsConfig != nil {
s.server.TLSConfig = s.tlsConfig
serve = func(l net.Listener) error { return s.server.ServeTLS(l, "", "") }
}
go func() { go func() {
if err := serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Error("msg", "HTTP chain server terminated", s.logger.Error("msg", "HTTP chain server terminated",
"component", "http_chain_source", "component", "http_chain_source",
"instance_id", s.id, "instance_id", s.id,
@@ -250,19 +204,6 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
cachedSessions := len(s.sessions) cachedSessions := len(s.sessions)
s.sessionsMu.Unlock() s.sessionsMu.Unlock()
details := map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"ingest_path": s.config.IngestPath,
"tls": s.tlsConfig != nil,
"total_requests": s.totalRequests.Load(),
"rejected_requests": s.rejectedRequests.Load(),
"parse_errors": s.parseErrors.Load(),
"cached_sessions": cachedSessions,
"trust_node": s.config.TrustNode,
}
maps.Copy(details, s.auth.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
Type: "http_chain", Type: "http_chain",
@@ -270,7 +211,16 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
DroppedEntries: s.droppedEntries.Load(), DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime, StartTime: s.startTime,
LastEntryTime: lastEntry, LastEntryTime: lastEntry,
Details: details, Details: map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"ingest_path": s.config.IngestPath,
"total_requests": s.totalRequests.Load(),
"rejected_requests": s.rejectedRequests.Load(),
"parse_errors": s.parseErrors.Load(),
"cached_sessions": cachedSessions,
"trust_node": s.config.TrustNode,
},
} }
} }
@@ -279,22 +229,6 @@ func (s *HTTPChainSource) GetStats() source.SourceStats {
func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) { func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
s.totalRequests.Add(1) s.totalRequests.Add(1)
// Authorize before the body is read: an unauthorized sender should not get
// to stream max_body_bytes into the process. 403 is distinct from the 400
// used for protocol errors, so a sender can tell "not allowed" from
// "malformed batch".
ident, err := s.auth.Authorize(r.TLS)
if err != nil {
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(chain.HeaderProtocol) != strconv.Itoa(chain.ProtocolVersion) { if r.Header.Get(chain.HeaderProtocol) != strconv.Itoa(chain.ProtocolVersion) {
s.rejectedRequests.Add(1) s.rejectedRequests.Add(1)
http.Error(w, "unsupported protocol version", http.StatusBadRequest) http.Error(w, "unsupported protocol version", http.StatusBadRequest)
@@ -305,22 +239,10 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
remoteHost = host remoteHost = host
} }
declaredNode := r.Header.Get(chain.HeaderNode) connNode := r.Header.Get(chain.HeaderNode)
connNode, err := s.auth.ResolveNode(declaredNode, remoteHost, s.config.TrustNode, ident) if connNode == "" || !s.config.TrustNode {
if err != nil { connNode = remoteHost
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by node binding",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"declared_node", declaredNode,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
return
} }
// force relabels every entry, so a sender cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
body := http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes) body := http.MaxBytesReader(w, r.Body, s.config.MaxBodyBytes)
scanner := bufio.NewScanner(body) scanner := bufio.NewScanner(body)
@@ -332,7 +254,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
if len(line) == 0 { if len(line) == 0 {
continue continue
} }
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode) entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
if err != nil { if err != nil {
// Content error within a clean transfer: skip line, keep batch // Content error within a clean transfer: skip line, keep batch
s.parseErrors.Add(1) s.parseErrors.Add(1)
@@ -359,17 +281,15 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
for _, entry := range entries { for _, entry := range entries {
s.publish(entry) s.publish(entry)
} }
s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, r.TLS, ident)) s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode))
w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries))) w.Header().Set(chain.HeaderAccepted, strconv.Itoa(len(entries)))
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// sessionFor returns the cached session for a remote+node+identity, // sessionFor returns the cached session for a remote+node, recreating after idle expiry
// recreating after idle expiry. Identity is part of the key so two peers func (s *HTTPChainSource) sessionFor(remoteHost, node string) string {
// sharing a remote address never share a session. key := remoteHost + "|" + node
func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.ConnectionState, ident authz.Identity) string {
key := remoteHost + "|" + node + "|" + ident.Name
s.sessionsMu.Lock() s.sessionsMu.Lock()
defer s.sessionsMu.Unlock() defer s.sessionsMu.Unlock()
@@ -378,18 +298,10 @@ func (s *HTTPChainSource) sessionFor(remoteHost, node string, cs *tls.Connection
return id return id
} }
} }
meta := map[string]any{ sess := s.proxy.CreateSession(remoteHost, map[string]any{
"type": "http_chain", "type": "http_chain",
"node": node, "node": node,
} })
if cs != nil {
meta["tls"] = true
if cn := tlsx.PeerCN(*cs); cn != "" {
meta["tls_peer_cn"] = cn
}
}
ident.Apply(meta)
sess := s.proxy.CreateSession(remoteHost, meta)
s.sessions[key] = sess.ID s.sessions[key] = sess.ID
return sess.ID return sess.ID
} }
+44 -120
View File
@@ -3,24 +3,21 @@ package tcpchain
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/tls" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"net" "net"
"strconv" "strconv"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"logwisp/internal/authz"
"logwisp/internal/chain" "logwisp/internal/chain"
"logwisp/internal/config" "logwisp/internal/config"
"logwisp/internal/core" "logwisp/internal/core"
"logwisp/internal/plugin" "logwisp/internal/plugin"
"logwisp/internal/session" "logwisp/internal/session"
"logwisp/internal/source" "logwisp/internal/source"
"logwisp/internal/tlsx"
lconfig "github.com/lixenwraith/config" lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/log" "github.com/lixenwraith/log"
@@ -48,13 +45,6 @@ type TCPChainSource struct {
conns map[net.Conn]struct{} conns map[net.Conn]struct{}
logger *log.Logger logger *log.Logger
// TLS
tlsConfig *tls.Config
tlsHandshakeErrors atomic.Uint64
// Authorization
auth *authz.Policy
mu sync.RWMutex mu sync.RWMutex
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -92,14 +82,6 @@ func NewTCPChainSourcePlugin(
if opts.HelloTimeoutMS <= 0 { if opts.HelloTimeoutMS <= 0 {
opts.HelloTimeoutMS = DefaultChainSourceHelloTimeoutMS opts.HelloTimeoutMS = DefaultChainSourceHelloTimeoutMS
} }
tlsCfg, err := tlsx.Server(opts.TLS)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
if err != nil {
return nil, err
}
s := &TCPChainSource{ s := &TCPChainSource{
id: id, id: id,
@@ -108,8 +90,6 @@ func NewTCPChainSourcePlugin(
subscribers: make([]chan core.LogEntry, 0), subscribers: make([]chan core.LogEntry, 0),
conns: make(map[net.Conn]struct{}), conns: make(map[net.Conn]struct{}),
logger: logger, logger: logger,
tlsConfig: tlsCfg,
auth: authPolicy,
} }
s.lastEntryTime.Store(time.Time{}) s.lastEntryTime.Store(time.Time{})
@@ -117,36 +97,17 @@ func NewTCPChainSourcePlugin(
"component", "tcp_chain_source", "component", "tcp_chain_source",
"instance_id", id, "instance_id", id,
"host", opts.Host, "host", opts.Host,
"port", opts.Port, "port", opts.Port)
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
if authPolicy.Unrestricted() {
logger.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
"component", "tcp_chain_source",
"instance_id", id,
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
}
if authPolicy.BindsNode() {
logger.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
"component", "tcp_chain_source",
"instance_id", id,
"node_binding", authPolicy.NodeBinding(),
"trust_node", opts.TrustNode)
}
return s, nil return s, nil
} }
// Capabilities returns supported capabilities // Capabilities returns supported capabilities
func (s *TCPChainSource) Capabilities() []core.Capability { func (s *TCPChainSource) Capabilities() []core.Capability {
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession} // CapTLS/CapAuth added when transport security lands
if s.tlsConfig != nil { return []core.Capability{
caps = append(caps, core.CapTLS) core.CapSessionAware,
core.CapMultiSession,
} }
if s.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes peers, not just the CA
}
return caps
} }
// Subscribe returns a channel for receiving log entries // Subscribe returns a channel for receiving log entries
@@ -161,15 +122,11 @@ func (s *TCPChainSource) Subscribe() <-chan core.LogEntry {
// Start binds the listener and begins accepting connections // Start binds the listener and begins accepting connections
func (s *TCPChainSource) Start() error { func (s *TCPChainSource) Start() error {
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10)) addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only. TLS-wrapped when configured; handshake runs explicitly in // IPv4-only
// handleConn under tlsx.HandshakeTimeout, pre-hello.
ln, err := net.Listen("tcp4", addr) ln, err := net.Listen("tcp4", addr)
if err != nil { if err != nil {
return fmt.Errorf("listen %s: %w", addr, err) return fmt.Errorf("listen %s: %w", addr, err)
} }
if s.tlsConfig != nil {
ln = tls.NewListener(ln, s.tlsConfig)
}
s.listener = ln s.listener = ln
s.ctx, s.cancel = context.WithCancel(context.Background()) s.ctx, s.cancel = context.WithCancel(context.Background())
s.startTime = time.Now() s.startTime = time.Now()
@@ -215,18 +172,6 @@ func (s *TCPChainSource) Stop() {
// GetStats returns the source's statistics // GetStats returns the source's statistics
func (s *TCPChainSource) GetStats() source.SourceStats { func (s *TCPChainSource) GetStats() source.SourceStats {
lastEntry, _ := s.lastEntryTime.Load().(time.Time) lastEntry, _ := s.lastEntryTime.Load().(time.Time)
details := map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"tls": s.tlsConfig != nil,
"tls_handshake_errors": s.tlsHandshakeErrors.Load(),
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
}
maps.Copy(details, s.auth.Stats())
return source.SourceStats{ return source.SourceStats{
ID: s.id, ID: s.id,
Type: "tcp_chain", Type: "tcp_chain",
@@ -234,7 +179,14 @@ func (s *TCPChainSource) GetStats() source.SourceStats {
DroppedEntries: s.droppedEntries.Load(), DroppedEntries: s.droppedEntries.Load(),
StartTime: s.startTime, StartTime: s.startTime,
LastEntryTime: lastEntry, LastEntryTime: lastEntry,
Details: details, Details: map[string]any{
"host": s.config.Host,
"port": s.config.Port,
"active_connections": s.activeConns.Load(),
"rejected_conns": s.rejectedConns.Load(),
"parse_errors": s.parseErrors.Load(),
"trust_node": s.config.TrustNode,
},
} }
} }
@@ -286,35 +238,6 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
s.activeConns.Add(-1) s.activeConns.Add(-1)
}() }()
var tlsState *tls.ConnectionState
if tc, ok := conn.(*tls.Conn); ok {
hctx, cancel := context.WithTimeout(s.ctx, tlsx.HandshakeTimeout)
err := tc.HandshakeContext(hctx)
cancel()
if err != nil {
s.tlsHandshakeErrors.Add(1)
s.logger.Warn("msg", "TLS handshake failed",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
cs := tc.ConnectionState()
tlsState = &cs
}
// Authorize before a preamble is parsed on an unauthorized peer's behalf
ident, err := s.auth.Authorize(tlsState)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is // Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates // unrecoverable after ErrTooLong, connection terminates
@@ -338,44 +261,25 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
return return
} }
fallbackNode := remote connNode := hello.Node
if connNode == "" || !s.config.TrustNode {
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil { if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
fallbackNode = host connNode = host
} else {
connNode = remote
} }
connNode, err := s.auth.ResolveNode(hello.Node, fallbackNode, s.config.TrustNode, ident)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by node binding",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"declared_node", hello.Node,
"error", err)
return
} }
// force relabels every entry, so an edge cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
meta := map[string]any{ sess := s.proxy.CreateSession(remote, map[string]any{
"type": "tcp_chain", "type": "tcp_chain",
"node": connNode, "node": connNode,
} })
if tlsState != nil {
meta["tls"] = true
if cn := tlsx.PeerCN(*tlsState); cn != "" {
meta["tls_peer_cn"] = cn
}
}
ident.Apply(meta)
sess := s.proxy.CreateSession(remote, meta)
sessID = sess.ID sessID = sess.ID
s.logger.Info("msg", "Chain connection established", s.logger.Info("msg", "Chain connection established",
"component", "tcp_chain_source", "component", "tcp_chain_source",
"remote_addr", remote, "remote_addr", remote,
"node", connNode, "node", connNode)
"auth_identity", ident.Name)
idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond idle := time.Duration(s.config.ReadTimeoutMS) * time.Millisecond
for { for {
@@ -399,7 +303,7 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
} }
s.proxy.UpdateActivity(sessID) s.proxy.UpdateActivity(sessID)
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode) entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
if err != nil { if err != nil {
s.parseErrors.Add(1) s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry", s.logger.Debug("msg", "Dropped malformed chain entry",
@@ -411,6 +315,26 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
} }
} }
// parseEntry decodes a canonical LogEntry line and applies the node policy
func (s *TCPChainSource) parseEntry(line []byte, connNode string) (core.LogEntry, bool) {
var entry core.LogEntry
if err := json.Unmarshal(line, &entry); err != nil {
s.parseErrors.Add(1)
s.logger.Debug("msg", "Dropped malformed chain entry",
"component", "tcp_chain_source",
"error", err)
return core.LogEntry{}, false
}
if entry.Time.IsZero() {
entry.Time = time.Now()
}
if entry.Node == "" || !s.config.TrustNode {
entry.Node = connNode
}
entry.RawSize = int64(len(line))
return entry, true
}
// publish sends a log entry to all subscribers // publish sends a log entry to all subscribers
func (s *TCPChainSource) publish(entry core.LogEntry) { func (s *TCPChainSource) publish(entry core.LogEntry) {
s.mu.RLock() s.mu.RLock()
-177
View File
@@ -1,177 +0,0 @@
// Package tlsx builds crypto/tls configurations from config.TLSOptions.
// It is the single seam between declarative TLS config and the stdlib;
// each network plugin calls exactly one constructor.
package tlsx
import (
"crypto/tls"
"crypto/x509"
"fmt"
stdlog "log"
"os"
"strings"
"time"
"logwisp/internal/config"
"github.com/lixenwraith/log"
)
// HandshakeTimeout bounds TLS handshakes on both accept and dial paths
const HandshakeTimeout = 10 * time.Second
// Server builds the *tls.Config for listener plugins
// (tcp/http sinks, tcp_chain/http_chain sources). Returns (nil, nil) when disabled.
func Server(o *config.TLSOptions) (*tls.Config, error) {
if o == nil || !o.Enabled {
return nil, nil
}
if o.CertFile == "" || o.KeyFile == "" {
return nil, fmt.Errorf("tls: cert_file and key_file are required for listeners")
}
cert, err := tls.LoadX509KeyPair(o.CertFile, o.KeyFile)
if err != nil {
return nil, fmt.Errorf("tls: load keypair: %w", err)
}
mv, err := minVersion(o.MinVersion)
if err != nil {
return nil, err
}
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: mv,
}
if o.ClientAuth {
if o.ClientCAFile == "" {
return nil, fmt.Errorf("tls: client_auth requires client_ca_file")
}
pool, err := loadPool(o.ClientCAFile)
if err != nil {
return nil, err
}
cfg.ClientCAs = pool
cfg.ClientAuth = tls.RequireAndVerifyClientCert
}
return cfg, nil
}
// Client builds the *tls.Config for dialer plugins (tcp_chain/http_chain
// sinks). host seeds ServerName when no override is set; Go verifies IP SANs
// when host is an address. Returns (nil, nil) when disabled.
func Client(o *config.TLSOptions, host string) (*tls.Config, error) {
if o == nil || !o.Enabled {
return nil, nil
}
mv, err := minVersion(o.MinVersion)
if err != nil {
return nil, err
}
cfg := &tls.Config{
MinVersion: mv,
ServerName: o.ServerName,
InsecureSkipVerify: o.InsecureSkipVerify,
}
if cfg.ServerName == "" {
cfg.ServerName = host
}
if o.CAFile != "" {
pool, err := loadPool(o.CAFile)
if err != nil {
return nil, err
}
cfg.RootCAs = pool
}
if (o.CertFile == "") != (o.KeyFile == "") {
return nil, fmt.Errorf("tls: cert_file and key_file must be set together")
}
if o.CertFile != "" {
cert, err := tls.LoadX509KeyPair(o.CertFile, o.KeyFile)
if err != nil {
return nil, fmt.Errorf("tls: load keypair: %w", err)
}
cfg.Certificates = []tls.Certificate{cert}
}
return cfg, nil
}
// Identity modes for PeerIdentity, mirroring the auth.identity config values.
// Validity is enforced at policy construction in internal/authz.
const (
IdentityCN = "cn"
IdentitySANDNS = "san_dns"
IdentitySANURI = "san_uri"
IdentitySANEmail = "san_email"
)
// PeerCN returns the subject CN of the verified peer leaf, "" if none
func PeerCN(cs tls.ConnectionState) string {
return PeerIdentity(cs, IdentityCN)
}
// PeerIdentity returns the field named by mode from the verified peer leaf,
// "" when the certificate does not carry it or mode is unknown. The chain,
// signature, and validity window are already checked by the handshake, so
// this is pure field selection.
func PeerIdentity(cs tls.ConnectionState, mode string) string {
if len(cs.PeerCertificates) == 0 {
return ""
}
leaf := cs.PeerCertificates[0]
switch mode {
case "", IdentityCN:
return leaf.Subject.CommonName
case IdentitySANDNS:
if len(leaf.DNSNames) > 0 {
return leaf.DNSNames[0]
}
case IdentitySANURI:
if len(leaf.URIs) > 0 {
return leaf.URIs[0].String()
}
case IdentitySANEmail:
if len(leaf.EmailAddresses) > 0 {
return leaf.EmailAddresses[0]
}
}
return ""
}
// HTTPErrorLog adapts the structured logger for http.Server.ErrorLog so TLS
// handshake failures don't bypass log routing straight to stderr (which would
// violate the console sanitization policy).
func HTTPErrorLog(l *log.Logger, component string) *stdlog.Logger {
return stdlog.New(errLogWriter{l: l, component: component}, "", 0)
}
type errLogWriter struct {
l *log.Logger
component string
}
func (w errLogWriter) Write(p []byte) (int, error) {
w.l.Warn("msg", strings.TrimSpace(string(p)), "component", w.component)
return len(p), nil
}
func loadPool(file string) (*x509.CertPool, error) {
pemBytes, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("tls: read CA file: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemBytes) {
return nil, fmt.Errorf("tls: no certificates found in %s", file)
}
return pool, nil
}
func minVersion(s string) (uint16, error) {
switch s {
case "", "1.3":
return tls.VersionTLS13, nil
case "1.2":
return tls.VersionTLS12, nil
default:
return 0, fmt.Errorf("tls: min_version %q (valid: \"1.2\", \"1.3\")", s)
}
}
+4 -4
View File
@@ -222,14 +222,14 @@ check() { # label condition_result
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)" tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out") #nt=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
#nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out") #nh=$(grep -c '"node":"edge-http"' <<< "$tcp_out")
nt=$(grep -c 'edge-tcp/' <<< "$tcp_out") nt=$(grep -c '"source":"edge-tcp/' <<< "$tcp_out")
nh=$(grep -c 'edge-http/' <<< "$tcp_out") nh=$(grep -c '"source":"edge-http/' <<< "$tcp_out")
check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 )) check "tcp sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
# 2. HTTP chain: edge-http -> relay -> SSE sink # 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)" sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
nt=$(grep -c '^data:.*edge-tcp/' <<< "$sse_out") nt=$(grep -c '^data:.*"node":"edge-tcp"' <<< "$sse_out")
nh=$(grep -c '^data:.*edge-http/' <<< "$sse_out") nh=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out")
check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 )) check "http sink: aggregated edge-tcp ($nt) + edge-http ($nh)" $(( nt >= 1 && nh >= 1 ))
# 3. HTTP sink status endpoint # 3. HTTP sink status endpoint
+2 -2
View File
@@ -224,12 +224,12 @@ check() { # label condition_result
# 1. TCP chain: edge-tcp -> relay -> tcp sink # 1. TCP chain: edge-tcp -> relay -> tcp sink
tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)" tcp_out="$(tcp_read "$PORT_TCP_SINK" 4)"
#n=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out") #n=$(grep -c '"node":"edge-tcp"' <<< "$tcp_out")
n=$(grep -c 'edge-tcp/' <<< "$tcp_out") n=$(grep -c '"source":"edge-tcp/' <<< "$tcp_out")
check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $(( n >= 1 )) check "tcp path: entries on :$PORT_TCP_SINK with node=edge-tcp ($n lines)" $(( n >= 1 ))
# 2. HTTP chain: edge-http -> relay -> SSE sink # 2. HTTP chain: edge-http -> relay -> SSE sink
sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)" sse_out="$(curl -sN --max-time 4 "http://127.0.0.1:$PORT_HTTP_SINK/stream" || true)"
n=$(grep -c '^data:.*edge-http/' <<< "$sse_out") n=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out")
check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $(( n >= 1 )) check "http path: SSE events on :$PORT_HTTP_SINK with node=edge-http ($n events)" $(( n >= 1 ))
# 3. HTTP sink status endpoint # 3. HTTP sink status endpoint
-513
View File
@@ -1,513 +0,0 @@
#!/usr/bin/env bash
# logwisp mTLS authentication test
#
# Scenario 1 — chained instances, client authenticates with mTLS:
# edge-01 cert --> tcp_chain sink --> :15811 tcp_chain src --> file sink
# edge-01 cert --> http_chain sink --> :15812 http_chain src --> file sink
# edge-99 cert --> tcp_chain sink --> :15811 rejected by the allow list
#
# Scenario 2 — a viewer client reads a streaming sink over mTLS:
# viewer-01 cert --> :15813 tcp sink (openssl s_client)
# viewer-01 cert --> :15814 http sink (curl, /stream and /status)
# rogue cert --> both, rejected by the allow list
#
# Also covers: node binding (a peer holding the edge-01 certificate cannot
# label its entries anything else), dialer-side server identity pinning, and
# a peer presenting no certificate at all.
#
# Usage:
# ./mtls-chain-test.sh manual mode: relay + edges up, guide printed
# ./mtls-chain-test.sh --auto automated checks and teardown
# ./mtls-chain-test.sh --keep (with --auto) skip teardown on success
#
# Requires: bash 5+, coreutils (timeout), openssl, curl. Linux dev host only.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run-mtls"
CONF="$RUN/conf"
LOG="$RUN/log"
PKI="$RUN/pki"
OUT="$RUN/out"
PORT_TCP_CHAIN=15811
PORT_HTTP_CHAIN=15812
PORT_TCP_SINK=15813
PORT_HTTP_SINK=15814
AUTO=0; KEEP=0
for a in "$@"; do case "$a" in
--auto) AUTO=1 ;;
--keep) KEEP=1 ;;
*) echo "unknown arg: $a" >&2; exit 1 ;;
esac; done
PIDS=()
cleanup() {
local rc=$?
trap - EXIT INT TERM
if (( ${#PIDS[@]} )); then
echo "--- teardown: stopping ${#PIDS[@]} daemon(s)"
kill -TERM "${PIDS[@]}" 2>/dev/null
local deadline=$(( SECONDS + 10 ))
for pid in "${PIDS[@]}"; do
while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 0.2; done
kill -KILL "$pid" 2>/dev/null
done
fi
exit "$rc"
}
trap cleanup EXIT INT TERM
port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&-; }
wait_port() { # port timeout_s
local i; for (( i=0; i < $2 * 10; i++ )); do
port_open "$1" && return 0
sleep 0.1
done
return 1
}
start_daemon() { # name conf
"$BIN" -c "$CONF/$2" > "$LOG/$1.out" 2>&1 &
PIDS+=($!)
echo "started $1 (pid $!)"
}
# --- Preflight ---
[[ -x "$BIN" ]] || { echo "binary not found: $BIN (build: go build -o bin/logwisp ./cmd/logwisp)" >&2; exit 1; }
command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl not found" >&2; exit 1; }
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
port_open "$p" && { echo "port $p already in use" >&2; exit 1; }
done
rm -rf "$RUN"
mkdir -p "$CONF" "$LOG" "$PKI" "$OUT"
# --- PKI ---
# One CA for every peer: the point of the test is that CA membership alone is
# no longer sufficient, so the identities must all be issued by the same CA.
gen_key() { openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$1" 2>/dev/null; }
gen_leaf() { # name CN eku [SAN]
local name=$1 cn=$2 eku=$3 san=${4:-}
gen_key "$PKI/$name.key"
openssl req -new -key "$PKI/$name.key" -out "$PKI/$name.csr" -subj "/CN=$cn" 2>/dev/null
local ext="extendedKeyUsage=$eku"
[[ -n $san ]] && ext+=$'\n'"subjectAltName=$san"
printf '%s\n' "$ext" > "$PKI/$name.ext"
openssl x509 -req -in "$PKI/$name.csr" -CA "$PKI/ca.crt" -CAkey "$PKI/ca.key" \
-CAcreateserial -out "$PKI/$name.crt" -days 2 -extfile "$PKI/$name.ext" 2>/dev/null
}
echo "--- generating test PKI in $PKI"
gen_key "$PKI/ca.key"
openssl req -x509 -new -key "$PKI/ca.key" -days 2 -out "$PKI/ca.crt" \
-subj "/CN=LogWisp Test CA" 2>/dev/null
gen_leaf relay relay.internal serverAuth "IP:127.0.0.1,DNS:relay.internal"
gen_leaf edge-01 edge-01 clientAuth
gen_leaf edge-99 edge-99 clientAuth
gen_leaf viewer-01 viewer-01 clientAuth
gen_leaf rogue rogue-viewer clientAuth
[[ -s "$PKI/rogue.crt" ]] || { echo "PKI generation failed" >&2; exit 1; }
# --- Config generation ---
# Relay: both ingest ports authorize edge-01 only and bind the node label to
# the certificate identity; both streaming sinks authorize viewer-01 only.
cat > "$CONF/relay.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "info"
[[pipelines]]
name = "relay_tcp"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_tcp"
type = "tcp_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
identity = "cn"
allow = ["edge-01"]
node_binding = "force"
[[pipelines.plugin_sinks]]
id = "file_tcp"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "tcp_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_tcp"
type = "tcp"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
[[pipelines]]
name = "relay_http"
[pipelines.flow.format]
type = "json"
sanitizer_policy = "json"
[[pipelines.plugin_sources]]
id = "in_http"
type = "http_chain"
[pipelines.plugin_sources.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sources.config.auth]
type = "mtls"
allow = ["edge-01"]
node_binding = "force"
[[pipelines.plugin_sinks]]
id = "file_http"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$OUT"
name = "http_chain"
flush_interval_ms = 200
[[pipelines.plugin_sinks]]
id = "out_http"
type = "http"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_SINK
[pipelines.plugin_sinks.config.tls]
enabled = true
cert_file = "$PKI/relay.crt"
key_file = "$PKI/relay.key"
client_auth = true
client_ca_file = "$PKI/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["viewer-01"]
EOF
# edge_tcp holds the edge-01 certificate but declares node "edge-tcp":
# node_binding = "force" must relabel its entries to "edge-01".
cat > "$CONF/edge_tcp.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_tcp"
[[pipelines]]
name = "edge_tcp"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-tcp"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
cat > "$CONF/edge_http.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_http"
[[pipelines]]
name = "edge_http"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "http_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_HTTP_CHAIN
node = "edge-http"
flush_interval_ms = 500
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["relay.internal"]
EOF
# edge_rogue holds a CA-issued certificate the relay does not authorize, and
# claims to be edge-01 on top of it.
cat > "$CONF/edge_rogue.toml" <<EOF
status_reporter = false
[logging]
output = "file"
level = "info"
[logging.file]
directory = "$LOG"
name = "edge_rogue"
[[pipelines]]
name = "edge_rogue"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-01"
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-99.crt"
key_file = "$PKI/edge-99.key"
EOF
# edge_pinfail pins a server identity the relay does not have: the dialer must
# refuse the handshake even though the certificate chains to the trusted CA.
cat > "$CONF/edge_pinfail.toml" <<EOF
status_reporter = false
[logging]
output = "stdout"
level = "debug"
[[pipelines]]
name = "edge_pinfail"
[[pipelines.plugin_sources]]
id = "rand"
type = "random"
[pipelines.plugin_sources.config]
interval_ms = 200
format = "txt"
length = 24
[[pipelines.plugin_sinks]]
id = "to_relay"
type = "tcp_chain"
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = $PORT_TCP_CHAIN
node = "edge-pinfail"
backoff_max_ms = 1000
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "$PKI/ca.crt"
cert_file = "$PKI/edge-01.crt"
key_file = "$PKI/edge-01.key"
[pipelines.plugin_sinks.config.auth]
type = "mtls"
allow = ["some-other-relay.internal"]
EOF
# --- Guide ---
cat <<EOF
================================================================
logwisp mTLS auth test — port map
$PORT_TCP_CHAIN relay ingest (tcp_chain, mTLS, allow = edge-01)
$PORT_HTTP_CHAIN relay ingest (http_chain, mTLS, allow = edge-01)
$PORT_TCP_SINK TCP sink (mTLS, allow = viewer-01)
$PORT_HTTP_SINK HTTP sink (mTLS, allow = viewer-01)
Read the TCP sink as an authorized viewer:
openssl s_client -quiet -connect 127.0.0.1:$PORT_TCP_SINK \\
-CAfile $PKI/ca.crt -cert $PKI/viewer-01.crt -key $PKI/viewer-01.key
Read the HTTP sink:
curl -N --noproxy '*' --cacert $PKI/ca.crt \\
--cert $PKI/viewer-01.crt --key $PKI/viewer-01.key \\
https://127.0.0.1:$PORT_HTTP_SINK/stream
Swap in rogue.crt/rogue.key for either and the policy refuses it.
Ingested entries land in $OUT/ (node label forced to the certificate CN).
Logs: $LOG/
================================================================
EOF
# --- Startup ---
start_daemon relay relay.toml
for p in $PORT_TCP_CHAIN $PORT_HTTP_CHAIN $PORT_TCP_SINK $PORT_HTTP_SINK; do
wait_port "$p" 10 || { echo "FAIL: relay port $p not listening (see $LOG/relay.out)"; exit 1; }
done
start_daemon edge_tcp edge_tcp.toml
start_daemon edge_http edge_http.toml
start_daemon edge_rogue edge_rogue.toml
start_daemon edge_pinfail edge_pinfail.toml
if (( AUTO == 0 )); then
echo "--- daemons running; Ctrl-C to stop"
while :; do sleep 1; done
fi
echo "--- settling 4s (connect + first http_chain flush)"
sleep 4
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
# Viewer helpers
tcp_view() { # cert_basename secs
timeout "$2" openssl s_client -quiet \
-connect 127.0.0.1:$PORT_TCP_SINK -CAfile "$PKI/ca.crt" \
-cert "$PKI/$1.crt" -key "$PKI/$1.key" </dev/null 2>/dev/null || true
}
http_get() { # path cert_basename|"" -> "<http_code>|<body>"
local path=$1 name=${2:-}
local args=(-s -o /dev/null -w '%{http_code}' --max-time 5 --noproxy '*'
--cacert "$PKI/ca.crt")
[[ -n $name ]] && args+=(--cert "$PKI/$name.crt" --key "$PKI/$name.key")
curl "${args[@]}" "https://127.0.0.1:$PORT_HTTP_SINK$path" 2>/dev/null || true
}
relay_log="$LOG/relay.out"
ingested() { cat "$OUT"/${1}* 2>/dev/null; }
echo "=== Scenario 1: chained instances over mTLS ==="
# 1. An authorized edge delivers entries into the relay's file sink
tcp_file="$(ingested tcp_chain)"
n=$(grep -c 'edge-01/' <<< "$tcp_file")
check "tcp_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
http_file="$(ingested http_chain)"
n=$(grep -c 'edge-01/' <<< "$http_file")
check "http_chain: authorized edge-01 entries reached the file sink ($n lines)" $(( n >= 1 ))
# 2. node_binding = "force" overrode the label the sender configured
n=$(grep -c 'edge-tcp/' <<< "$tcp_file")
check "node binding: sender's own label \"edge-tcp\" was not honored ($n lines)" $(( n == 0 ))
n=$(grep -c 'edge-http/' <<< "$http_file")
check "node binding: sender's own label \"edge-http\" was not honored ($n lines)" $(( n == 0 ))
# 3. An identity outside the allow list is refused, even claiming to be edge-01
n=$(grep -c 'Connection rejected by auth policy' "$relay_log")
check "allow list: unauthorized edge-99 connection rejected ($n rejections)" $(( n >= 1 ))
n=$(grep -c 'edge-99' <<< "$tcp_file")
check "allow list: no edge-99 entry was ingested" $(( n == 0 ))
# 4. A peer with no certificate cannot complete the handshake
timeout 5 openssl s_client -connect 127.0.0.1:$PORT_TCP_CHAIN \
-CAfile "$PKI/ca.crt" </dev/null >/dev/null 2>&1
sleep 0.5
n=$(grep -c 'TLS handshake failed' "$relay_log")
check "client_auth: a peer with no certificate was refused ($n handshake errors)" $(( n >= 1 ))
# 5. Dialer-side pinning: the relay's identity is not the one edge_pinfail pins
n=$(grep -c 'is not allowed' "$LOG/edge_pinfail.out")
check "server pinning: dialer refused a CA-valid server it does not pin ($n refusals)" $(( n >= 1 ))
n=$(grep -c 'edge-pinfail' <<< "$tcp_file")
check "server pinning: pin-failing edge delivered nothing" $(( n == 0 ))
echo "=== Scenario 2: viewer clients on mTLS-gated sinks ==="
# 6. TCP sink: authorized viewer streams, rogue gets nothing
out="$(tcp_view viewer-01 4)"
n=$(grep -c 'edge-01/' <<< "$out")
check "tcp sink: viewer-01 streamed entries ($n lines)" $(( n >= 1 ))
out="$(tcp_view rogue 4)"
n=$(grep -c '"message"' <<< "$out")
check "tcp sink: rogue viewer received no entries" $(( n == 0 ))
# 7. HTTP sink: stream and status both gated
code="$(http_get /status viewer-01)"
check "http sink: /status served to viewer-01 (HTTP $code)" $([[ $code == 200 ]] && echo 1 || echo 0)
code="$(http_get /status rogue)"
check "http sink: /status refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
code="$(http_get /stream rogue)"
check "http sink: /stream refused to rogue viewer (HTTP $code)" $([[ $code == 403 ]] && echo 1 || echo 0)
# curl reports 000 when the handshake itself fails, which is what a client
# with no certificate must hit
code="$(http_get /status)"
check "http sink: client with no certificate failed the handshake (curl $code)" \
$([[ $code == 000 ]] && echo 1 || echo 0)
sse="$(timeout 4 curl -sN --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/stream" 2>/dev/null || true)"
n=$(grep -c '^data:.*edge-01/' <<< "$sse")
check "http sink: viewer-01 received SSE events ($n events)" $(( n >= 1 ))
# 8. The status endpoint reports the policy and its rejection count
status="$(curl -s --max-time 5 --noproxy '*' --cacert "$PKI/ca.crt" \
--cert "$PKI/viewer-01.crt" --key "$PKI/viewer-01.key" \
"https://127.0.0.1:$PORT_HTTP_SINK/status" 2>/dev/null || true)"
n=$(grep -c 'mtls' <<< "$status")
check "http sink: status endpoint reports the auth policy" $(( n >= 1 ))
rej=$(grep -o '"auth_rejected"[ :]*[0-9]*' <<< "$status" | grep -o '[0-9]*$' || echo 0)
check "http sink: status endpoint counts auth rejections (auth_rejected=$rej)" $(( rej >= 1 ))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
(( KEEP )) && { echo "--keep: daemons left running (pids: ${PIDS[*]})"; PIDS=(); }
else
echo "RESULT: FAILURES — inspect $LOG/*.out and $LOG/*.log"
fi
exit "$fail"
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env bash
# logwisp file source pass-through test
#
# file src (raw, from=start) --> raw format --> file sink byte-exact relay
# file src (defaults) --> raw format --> file sink no key dropped
#
# The fixture is a record whose envelope is wider than time/level/msg/fields,
# which is what the narrow JSON branch used to reduce to an empty message.
#
# Usage: ./passthrough-test.sh
# Requires: bash 5+, coreutils (timeout). Linux dev host only.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BIN="${LOGWISP_BIN:-$SCRIPT_DIR/../bin/logwisp}"
RUN="$SCRIPT_DIR/run/passthrough"
[[ -x $BIN ]] || { echo "no binary at $BIN; run make build" >&2; exit 1; }
rm -rf "$RUN"
mkdir -p "$RUN/in" "$RUN/out-raw" "$RUN/out-parsed"
cat > "$RUN/in/wide.jsonl" <<'EOF'
{"time":"2026-09-08T21:35:44.178372768-04:00","level":"INFO","sub":"app","run":0,"tick":0,"frame":0,"fields":{"msg":"init begin","mode":"play"}}
{"time":"2026-09-08T21:35:44.178581366-04:00","level":"PROC","run":0,"tick":0,"frame":0,"fields":{"seq":1,"seed":1788917744178374836}}
plain text line, not JSON at all
EOF
conf() { # sink_dir raw
cat <<EOF
quiet = true
status_reporter = false
[logging]
output = "stderr"
level = "error"
[[pipelines]]
name = "passthrough"
[pipelines.flow.format]
type = "raw"
sanitizer_policy = "raw"
[[pipelines.plugin_sources]]
id = "wide"
type = "file"
[pipelines.plugin_sources.config]
directory = "$RUN/in"
pattern = "*.jsonl"
raw = $2
from = "start"
[[pipelines.plugin_sinks]]
id = "out"
type = "file"
[pipelines.plugin_sinks.config]
directory = "$1"
name = "relay"
flush_interval_ms = 100
EOF
}
conf "$RUN/out-raw" true > "$RUN/raw.toml"
conf "$RUN/out-parsed" false > "$RUN/parsed.toml"
for c in raw parsed; do
timeout 5 "$BIN" -c "$RUN/$c.toml" > "$RUN/$c.out" 2>&1
done
fail=0
check() { # label condition_result
if (( $2 )); then echo "PASS: $1"; else echo "FAIL: $1"; fail=1; fi
}
# 1. raw = true relays the file byte for byte
if diff -q "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" > /dev/null; then
check "raw = true: output identical to input" 1
else
check "raw = true: output identical to input" 0
diff "$RUN/in/wide.jsonl" "$RUN/out-raw/relay.log" | head -6
fi
# 2. the default parse keeps every key, JSON branch refused on the wide envelope
n=$(grep -c '"sub":"app"' "$RUN/out-parsed/relay.log")
check "defaults: wide envelope reaches the sink whole ($n line(s) carry sub)" $(( n == 1 ))
n=$(grep -c '1788917744178374836' "$RUN/out-parsed/relay.log")
check "defaults: large integers are not re-encoded through float64 ($n)" $(( n == 1 ))
echo "================================================================"
if (( fail == 0 )); then
echo "RESULT: ALL PASS"
else
echo "RESULT: FAILURES — inspect $RUN/"
fi
exit "$fail"