Compare commits
17
Commits
f0aca019f3
..
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
b8982e4e44 | ||
|
|
7b782a79ef | ||
|
|
5934a2e35f | ||
|
|
dd665bb339 | ||
|
|
5fbd5c71cf | ||
|
|
296b351883 | ||
|
|
85dc10b805 | ||
|
|
5fea79458f | ||
|
|
b8dd591b4b | ||
|
|
87e57784da | ||
|
|
ebb5aa3bfe | ||
|
|
e5c157625e | ||
|
|
5353367e4f | ||
|
|
61d0269dcf | ||
|
|
46a436baa0 | ||
|
|
d38908e0f1 | ||
|
|
7f4862d9a2 |
@@ -9,7 +9,9 @@ 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
@@ -0,0 +1,40 @@
|
|||||||
|
# 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"]
|
||||||
@@ -33,7 +33,7 @@ install: build
|
|||||||
|
|
||||||
# Uninstall the binary
|
# Uninstall the binary
|
||||||
uninstall:
|
uninstall:
|
||||||
rm -f $(BINDIR)/$(BINARY_PATH)
|
rm -f $(BINDIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
# Clean build artifacts
|
# Clean build artifacts
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -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.25-00ADD8?style=flat&logo=go" alt="Go"></a>
|
<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://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,78 +16,130 @@
|
|||||||
|
|
||||||
# LogWisp
|
# 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.
|
A pipeline-based log transport and processing system written in Go. LogWisp
|
||||||
|
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
|
||||||
|
|
||||||
### Core Capabilities
|
### Pipeline
|
||||||
- **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
|
|
||||||
|
|
||||||
### Data Processing
|
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
||||||
- **Pattern-based Filtering**: Chainable include/exclude filters with regex support
|
in one process
|
||||||
- **Multiple Formatters**: Raw, JSON, and template-based text formatting
|
- **Fan-in and fan-out**: many sources and many sinks per pipeline
|
||||||
- **Rate Limiting**: Pipeline rate control
|
- **Never blocks**: a stalled sink drops its own events and is counted, rather
|
||||||
|
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
|
||||||
|
|
||||||
### Security & Reliability
|
### Inputs
|
||||||
- **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
|
|
||||||
|
|
||||||
### Operational Features
|
`file` (directory tail with rotation detection and JSON line parsing),
|
||||||
- **Status Monitoring**: Real-time statistics and health endpoints
|
`console` (stdin), `random` (synthetic generator), `null`, and the chain ingest
|
||||||
- **Signal Handling**: Graceful shutdown and configuration reload via signals
|
listeners `tcp_chain` and `http_chain`.
|
||||||
- **Background Mode**: Daemon operation with proper signal handling
|
|
||||||
- **Quiet Mode**: Silent operation for automated deployments
|
### Outputs
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
Available in `doc/` directory.
|
| Document | Contents |
|
||||||
|
|----------|----------|
|
||||||
|
| [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 |
|
||||||
|
|
||||||
- [Installation Guide](doc/installation.md) - Platform setup and service configuration
|
A fully annotated configuration covering every option ships as
|
||||||
- [Architecture Overview](doc/architecture.md) - System design and component interaction
|
[`config/logwisp.toml`](config/logwisp.toml).
|
||||||
- [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
|
||||||
|
|
||||||
Install LogWisp and create a basic configuration:
|
```bash
|
||||||
|
make
|
||||||
|
```
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
# logwisp.toml
|
||||||
[[pipelines]]
|
[[pipelines]]
|
||||||
name = "default"
|
name = "default"
|
||||||
|
|
||||||
[[pipelines.sources]]
|
[pipelines.flow.format]
|
||||||
type = "directory"
|
type = "json"
|
||||||
[pipelines.sources.directory]
|
sanitizer_policy = "json"
|
||||||
path = "./"
|
|
||||||
|
[[pipelines.plugin_sources]]
|
||||||
|
id = "app_logs"
|
||||||
|
type = "file"
|
||||||
|
[pipelines.plugin_sources.config]
|
||||||
|
directory = "/var/log/myapp"
|
||||||
pattern = "*.log"
|
pattern = "*.log"
|
||||||
|
|
||||||
[[pipelines.sinks]]
|
[[pipelines.plugin_sinks]]
|
||||||
|
id = "stdout"
|
||||||
type = "console"
|
type = "console"
|
||||||
[pipelines.sinks.console]
|
[pipelines.plugin_sinks.config]
|
||||||
target = "stdout"
|
target = "stdout"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with: `logwisp -c config.toml`
|
```bash
|
||||||
|
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 Version**: 1.25+ (for building from source)
|
- **Go**: 1.27.1+ to build from source
|
||||||
|
|
||||||
|
Network sources and sinks bind and dial over IPv4 only.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
###############################################################################
|
||||||
|
### 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
|
||||||
+76
-41
@@ -1,73 +1,108 @@
|
|||||||
# LogWisp
|
# LogWisp Documentation
|
||||||
|
|
||||||
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.
|
LogWisp is a pipeline-based log transport and processing system written in Go.
|
||||||
|
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.
|
||||||
|
|
||||||
## Features
|
## Documentation Map
|
||||||
|
|
||||||
### Core Capabilities
|
| Document | Contents |
|
||||||
- **Pipeline Architecture**: Independent processing pipelines with source(s) → filter → format → sink(s) flow
|
|----------|----------|
|
||||||
- **Multiple Input Sources**: File monitoring, console (stdin), random log generation, null
|
| [Installation](installation.md) | Building, installing, and running as a service |
|
||||||
- **Flexible Output Sinks**: Console, file, HTTP SSE, TCP streaming, null
|
| [Architecture](architecture.md) | Component model, data flow, concurrency, back-pressure |
|
||||||
- **Real-time Processing**: Sub-millisecond latency with configurable buffering
|
| [Configuration](configuration.md) | TOML structure, precedence, environment and CLI overrides |
|
||||||
- **Hot Configuration Reload**: Update pipelines without service restart
|
| [Sources](sources.md) | Every input plugin and its options |
|
||||||
- **Session Management**: Built-in session tracking for multiple client connections
|
| [Sinks](sinks.md) | Every output plugin and its options |
|
||||||
|
| [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 |
|
||||||
|
|
||||||
### Data Processing
|
A fully annotated configuration covering every option lives at
|
||||||
- **Pattern-based Filtering**: Chainable include/exclude filters with regex support
|
[`config/logwisp.toml`](../config/logwisp.toml).
|
||||||
- **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
|
|
||||||
|
|
||||||
### Security & Reliability
|
## Capabilities
|
||||||
- **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
|
|
||||||
|
|
||||||
## Documentation
|
### Pipeline
|
||||||
|
|
||||||
- [Installation Guide](installation.md) - Platform setup and service configuration
|
- Independent named pipelines, each `sources → flow → sinks`
|
||||||
- [Architecture Overview](architecture.md) - System design and component interaction
|
- Fan-in (many sources per pipeline) and fan-out (many sinks per pipeline)
|
||||||
- [Configuration Reference](configuration.md) - TOML structure and configuration methods
|
- Non-blocking sink dispatch: a stalled sink drops its own events and never
|
||||||
- [Input Sources](sources.md) - Available source types and configurations
|
stalls the pipeline or its sibling sinks
|
||||||
- [Output Sinks](sinks.md) - Sink types and output options
|
- Hot reload of pipeline configuration via `SIGHUP`/`SIGUSR1` or a file watch
|
||||||
- [Filters](filters.md) - Pattern-based log filtering
|
|
||||||
- [Formatters](formatters.md) - Log formatting and transformation
|
### Inputs
|
||||||
- [Networking & Security](networking.md) - Network features (Note: TLS and Auth are currently placeholders in the new architecture)
|
|
||||||
- [Command Line Interface](cli.md) - CLI flags and subcommands
|
`file` (directory tail with rotation detection), `console` (stdin),
|
||||||
- [Operations Guide](operations.md) - Running and maintaining LogWisp
|
`random` (synthetic generator), `null`, and the chain ingest listeners
|
||||||
|
`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 = "default_source"
|
id = "app_logs"
|
||||||
type = "file"
|
type = "file"
|
||||||
[pipelines.plugin_sources.config]
|
[pipelines.plugin_sources.config]
|
||||||
directory = "./"
|
directory = "/var/log/myapp"
|
||||||
pattern = "*.log"
|
pattern = "*.log"
|
||||||
|
|
||||||
[[pipelines.plugin_sinks]]
|
[[pipelines.plugin_sinks]]
|
||||||
id = "default_sink"
|
id = "stdout"
|
||||||
type = "console"
|
type = "console"
|
||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
target = "stdout"
|
target = "stdout"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with: `logwisp -c config.toml`
|
```bash
|
||||||
|
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 Version**: 1.25+ (for building from source)
|
- **Go**: 1.26+ to build from source
|
||||||
|
|
||||||
|
Network sources and sinks bind and dial over IPv4 only.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
BSD 3-Clause License
|
BSD 3-Clause.
|
||||||
|
|||||||
+163
-136
@@ -1,171 +1,198 @@
|
|||||||
# Architecture Overview
|
# Architecture Overview
|
||||||
|
|
||||||
LogWisp implements a pipeline-based architecture for flexible log processing and distribution.
|
LogWisp moves log entries through independent pipelines. Everything else —
|
||||||
|
plugins, sessions, TLS, statistics — hangs off that spine.
|
||||||
|
|
||||||
## Core Concepts
|
## Component Hierarchy
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Service (Main Process)
|
main
|
||||||
├── Pipeline 1
|
└── Service
|
||||||
│ ├── Plugin Sources (1 or more)
|
├── Pipeline "app"
|
||||||
│ ├── Flow
|
│ ├── Registry instance tracking, single-instance enforcement
|
||||||
│ │ ├── Heartbeat Generator (optional)
|
│ ├── Session Manager per-pipeline connection/session bookkeeping
|
||||||
│ │ ├── Rate Limiter (optional)
|
│ ├── Sources[] plugin instances, keyed by id
|
||||||
│ │ ├── Filter Chain (optional)
|
│ ├── Flow
|
||||||
│ │ └── Formatter (optional)
|
│ │ ├── Rate Limiter optional, token bucket
|
||||||
│ └── Plugin Sinks (1 or more)
|
│ │ ├── Filter Chain optional, ordered
|
||||||
├── Pipeline 2
|
│ │ ├── Formatter raw | txt | json, with sanitizer
|
||||||
│ └── [Similar structure]
|
│ │ └── Heartbeat optional generator
|
||||||
└── Status Reporter (optional)
|
│ └── Sinks[] plugin instances, keyed by id
|
||||||
|
├── 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
|
||||||
|
|
||||||
### Processing Stages
|
### Entry lifecycle
|
||||||
|
|
||||||
1. **Source Stage**: Plugin sources monitor inputs and generate log entries
|
1. **Source** produces a `core.LogEntry` and publishes it to every subscriber
|
||||||
2. **Flow - Rate Limiting**: Optional pipeline-level rate control
|
channel it has handed out. Publication is non-blocking: a full subscriber
|
||||||
3. **Flow - Filtering**: Pattern-based inclusion/exclusion
|
channel increments the source's `dropped_entries` counter.
|
||||||
4. **Flow - Formatting**: Transform entries to desired output format with sanitization
|
2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at
|
||||||
5. **Distribution**: Fan-out to multiple plugin sinks
|
any stage ends the entry's life and increments `flow.total_dropped`.
|
||||||
|
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.
|
||||||
|
|
||||||
### Entry Lifecycle
|
`LogEntry` fields:
|
||||||
|
|
||||||
Log entries flow through the pipeline as `core.LogEntry` structures containing:
|
| Field | Purpose |
|
||||||
- **Time**: Entry timestamp
|
|-------|---------|
|
||||||
- **Level**: Log level (DEBUG, INFO, WARN, ERROR)
|
| `Time` | Entry timestamp |
|
||||||
- **Source**: Origin identifier
|
| `Node` | Origin node label for chained topologies; stamped at the first hop, preserved by relays |
|
||||||
- **Message**: Log content
|
| `Source` | Origin identifier within the node (filename, plugin id, …) |
|
||||||
- **Fields**: Additional metadata (JSON)
|
| `Level` | `DEBUG`/`INFO`/`WARN`/`ERROR`/`TRACE`, when detected |
|
||||||
- **RawSize**: Original entry size
|
| `Message` | Log content |
|
||||||
|
| `Fields` | Optional structured metadata as raw JSON |
|
||||||
|
| `RawSize` | Original byte size, used by the entry-size cap |
|
||||||
|
|
||||||
### Buffering Strategy
|
Carrying `Entry` alongside `Payload` is what makes chain sinks
|
||||||
|
format-independent: a `tcp_chain` or `http_chain` sink re-serializes the
|
||||||
|
structured entry rather than shipping whatever text the local formatter chose.
|
||||||
|
|
||||||
Each component maintains internal buffers to handle burst traffic:
|
### Back-pressure and drops
|
||||||
- Sources: Configurable buffer size (default 1000 entries)
|
|
||||||
- Sinks: Independent buffers per sink
|
|
||||||
- Network components: Additional TCP/HTTP buffers
|
|
||||||
|
|
||||||
*"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."*
|
There is exactly one drop policy and it is not configurable: **never block**.
|
||||||
|
|
||||||
## Component Types
|
| Stage | Full-buffer behaviour | Counter |
|
||||||
|
|-------|----------------------|---------|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
### Sources (Input)
|
The `tcp_chain` sink is the one deliberate exception. It holds a line across
|
||||||
|
reconnects until it is written or the process shuts down, so a downstream
|
||||||
- **File Source**: File system directory monitoring with rotation detection
|
outage propagates backwards as a full input buffer and surfaces as
|
||||||
- **Console Source**: Standard input processing (stdin)
|
`total_dropped_by_sink` on the pipeline rather than as silent data loss inside
|
||||||
- **Random Source**: Generates random log entries for testing
|
the sink. The `http_chain` sink retries a batch with backoff, and drops it only
|
||||||
- **Null Source**: Discards logs, used for testing
|
on a non-retryable response or on shutdown (`dropped_batches`).
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
### Goroutine Architecture
|
- One goroutine per source drains that source's subscription and feeds the flow.
|
||||||
|
- 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.
|
||||||
|
|
||||||
- Each source runs in dedicated goroutines for monitoring
|
### Shutdown ordering
|
||||||
- Sinks operate independently with their own processing loops
|
|
||||||
- Network listeners use optimized event loops (gnet for TCP)
|
|
||||||
- Pipeline processing uses channel-based communication
|
|
||||||
|
|
||||||
### Synchronization
|
`Pipeline.Stop` is deliberately ordered so in-flight data drains:
|
||||||
|
|
||||||
- Atomic counters for statistics
|
1. Stop all sources concurrently; each closes its subscriber channels.
|
||||||
- Read-write mutexes for configuration access
|
2. Wait for the run loop, which ends when every subscription channel closes.
|
||||||
- Context-based cancellation for graceful shutdown
|
3. Stop all sinks concurrently.
|
||||||
- Wait groups for coordinated startup/shutdown
|
|
||||||
|
|
||||||
## Network Architecture
|
## Network Architecture
|
||||||
|
|
||||||
### Connection Patterns
|
All listeners bind `tcp4` and all dialers dial `tcp4`. IPv6 clients cannot
|
||||||
|
connect; this is deliberate, not an oversight.
|
||||||
|
|
||||||
**Chaining Design**:
|
| Plugin | Role | Protocol |
|
||||||
- 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 |
|
||||||
|
|
||||||
**Monitoring Design**:
|
TLS is built in exactly one place, `internal/tlsx`, which exposes
|
||||||
- TCP Sink: Debugging interface
|
`Server(opts)` for listeners and `Client(opts, host)` for dialers. See
|
||||||
- HTTP Sink: Browser-based live monitoring
|
[Security](security.md).
|
||||||
|
|
||||||
### Protocol Support
|
## Sessions
|
||||||
|
|
||||||
- HTTP/1.1 and HTTP/2 for HTTP connections
|
Each pipeline owns a `session.Manager`. Plugins receive a `session.Proxy`
|
||||||
- Raw TCP connections
|
scoped to their instance id, so one plugin cannot see or remove another's
|
||||||
|
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
|
||||||
|
|
||||||
### Memory Management
|
- Every buffer is bounded; the drop-not-block policy keeps memory flat under
|
||||||
|
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.
|
||||||
|
|
||||||
- Bounded buffers prevent unbounded growth
|
## Performance Notes
|
||||||
- Automatic garbage collection via Go runtime
|
|
||||||
- Connection limits prevent resource exhaustion
|
|
||||||
|
|
||||||
### File Management
|
- In-memory entry processing is sub-millisecond; the formatter mutex is the
|
||||||
|
only shared serialization point in the hot path.
|
||||||
- Automatic rotation based on size thresholds
|
- File tailing detects new content within roughly 100 ms (fixed poll), while
|
||||||
- Retention policies for old log files
|
`check_interval_ms` governs how quickly a *newly created* file is noticed.
|
||||||
- Minimum disk space checks before writing
|
- `http_chain` trades latency for efficiency: entries wait up to
|
||||||
|
`flush_interval_ms` (default 1 s) before a batch is sent.
|
||||||
### Connection Management
|
- Scale out with more pipelines per process, more sinks per pipeline, or more
|
||||||
|
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
@@ -0,0 +1,236 @@
|
|||||||
|
# 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`.
|
||||||
+130
-142
@@ -1,196 +1,184 @@
|
|||||||
# Command Line Interface
|
# Command Line Interface
|
||||||
|
|
||||||
LogWisp CLI reference for commands and options.
|
|
||||||
|
|
||||||
## Synopsis
|
|
||||||
|
|
||||||
```bash
|
|
||||||
logwisp [command] [options]
|
|
||||||
logwisp [options]
|
|
||||||
```
|
```
|
||||||
|
logwisp [options]
|
||||||
## Commands
|
logwisp help | -h | --help
|
||||||
|
|
||||||
### 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Output includes:
|
LogWisp has no subcommands. Earlier releases shipped `logwisp auth` and
|
||||||
- Version number
|
`logwisp tls` for credential and certificate generation; both were removed
|
||||||
- Build date
|
during the restructure. Use `openssl` or your PKI tooling instead — see
|
||||||
- Git commit hash
|
[Security](security.md).
|
||||||
- Go version
|
|
||||||
|
|
||||||
## Global Options
|
## Options
|
||||||
|
|
||||||
### Configuration Options
|
Any scalar configuration key is settable as a flag using its TOML path:
|
||||||
|
|
||||||
|
```
|
||||||
|
--<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, --config` | Configuration file path | `./logwisp.toml` |
|
| `-c <path>` | Configuration file | `./logwisp.toml` |
|
||||||
| `-q, --quiet` | Suppress console output | false |
|
| `--config=<path>` | Configuration file (equals form only) | `./logwisp.toml` |
|
||||||
| `--status-reporter` | Status logging | true |
|
| `--quiet` | Suppress all application output | `false` |
|
||||||
| `--auto-reload` | Enable config hot reload | false |
|
| `--status_reporter=<bool>` | Periodic status logging | `true` |
|
||||||
|
| `--auto_reload=<bool>` | Reload config when the file changes | `false` |
|
||||||
|
| `--version` | Print version and exit | — |
|
||||||
|
| `-h`, `--help`, `help` | Print usage and exit | — |
|
||||||
|
|
||||||
### Logging Options
|
> `--config <path>` with a space is not recognized. The path resolver
|
||||||
|
> 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.
|
||||||
|
|
||||||
| Flag | Description | Values |
|
### Logging
|
||||||
|------|-------------|--------|
|
|
||||||
| `--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 |
|
|
||||||
|
|
||||||
### Pipeline Options
|
| Flag | Values |
|
||||||
|
|------|--------|
|
||||||
|
| `--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 |
|
||||||
|
|
||||||
Configure pipelines via CLI (N = array index, 0-based).
|
`--logging.console.target` is accepted but has no effect; the console
|
||||||
|
destination is derived from `--logging.output`.
|
||||||
|
|
||||||
**Pipeline Configuration:**
|
### Pipelines
|
||||||
|
|
||||||
| Flag | Description |
|
Pipelines, sources, sinks, and filters **cannot** be configured from the command
|
||||||
|------|-------------|
|
line. Array-indexed paths such as `--pipelines.0.name=app` or
|
||||||
| `--pipelines.N.name` | Pipeline name |
|
`--pipelines.0.plugin_sinks.0.type=null` are reported as unrecognized and
|
||||||
| `--pipelines.N.plugin_sources.N.type` | Source type |
|
ignored:
|
||||||
| `--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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### String Flags
|
Use a configuration file. Older documentation described CLI pipeline overrides
|
||||||
|
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
|
||||||
|
|
||||||
All flags can be set via environment:
|
Configuration paths map to environment variables by replacing `.` with `_` and
|
||||||
|
uppercasing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export LOGWISP_QUIET=true
|
export QUIET=true
|
||||||
export LOGWISP_LOGGING_LEVEL=debug
|
export LOGGING_LEVEL=debug
|
||||||
export LOGWISP_PIPELINES_0_NAME=myapp
|
export LOGGING_FILE_DIRECTORY=/var/log/logwisp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Precedence
|
> The `LOGWISP_` prefix is **not** currently applied to these — see
|
||||||
|
> [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.
|
||||||
|
|
||||||
1. Command-line flags (highest)
|
The two variables that do carry the prefix are read directly by the path
|
||||||
|
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 (lowest)
|
4. Built-in defaults
|
||||||
|
|
||||||
## Exit Codes
|
## Signals
|
||||||
|
|
||||||
| Code | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| 0 | Success |
|
|
||||||
| 1 | General error |
|
|
||||||
| 2 | Configuration file not found |
|
|
||||||
| 137 | SIGKILL received |
|
|
||||||
|
|
||||||
## Signal Handling
|
|
||||||
|
|
||||||
| Signal | Action |
|
| Signal | Action |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| SIGINT (Ctrl+C) | Graceful shutdown |
|
| `SIGINT` | Graceful shutdown |
|
||||||
| SIGTERM | Graceful shutdown |
|
| `SIGTERM` | Graceful shutdown |
|
||||||
| SIGHUP | Reload configuration |
|
| `SIGHUP` | Reload configuration |
|
||||||
| SIGUSR1 | Reload configuration |
|
| `SIGUSR1` | Reload configuration |
|
||||||
| SIGKILL | Immediate termination |
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `0` | Clean shutdown, or `--version` / `--help` |
|
||||||
|
| `1` | General error: config load or validation failure, logger init failure, service bootstrap failure |
|
||||||
|
| `2` | Explicitly requested configuration file not found |
|
||||||
|
|
||||||
|
Exit code 2 applies only when the file was named explicitly (`-c`,
|
||||||
|
`--config=`, or the `LOGWISP_CONFIG_*` variables). A missing discovered default
|
||||||
|
is not an error, and LogWisp starts on built-in defaults.
|
||||||
|
|
||||||
|
## Built-in Defaults
|
||||||
|
|
||||||
|
With no configuration file present, LogWisp runs one pipeline named
|
||||||
|
`default_pipeline`: a `random` source with `special = true`, JSON formatting,
|
||||||
|
a rate limit of 5 entries/second with a burst of 10 and `policy = "drop"`, and a
|
||||||
|
`console` sink on stdout. It is a self-demonstrating idle mode, not a useful
|
||||||
|
production configuration.
|
||||||
|
|
||||||
|
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 Mode
|
**Development**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verbose logging to console
|
# verbose, everything to stderr
|
||||||
logwisp --logging.output=stderr --logging.level=debug
|
logwisp -c dev.toml --logging.output=stderr --logging.level=debug
|
||||||
|
|
||||||
# Quick test with stdin
|
# no config at all: synthetic generator to stdout
|
||||||
logwisp --pipelines.0.plugin_sources.0.type=console --pipelines.0.plugin_sinks.0.type=console
|
logwisp
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production Deployment
|
**Configuration check**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Background with file logging
|
# starts the service; a config error exits non-zero before any pipeline runs
|
||||||
logwisp --background --config /etc/logwisp/prod.toml --logging.output=file
|
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug
|
||||||
|
|
||||||
# Systemd service
|
|
||||||
ExecStart=/usr/local/bin/logwisp --config /etc/logwisp/config.toml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debugging
|
There is no dry-run or validate-only mode. The closest approximation is starting
|
||||||
|
with debug logging and stopping once the pipelines report as started.
|
||||||
|
|
||||||
|
**Production**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check configuration
|
logwisp -c /etc/logwisp/logwisp.toml --logging.output=file
|
||||||
logwisp --config test.toml --logging.level=debug --disable-status-reporter
|
|
||||||
|
|
||||||
# Dry run (verify config only)
|
|
||||||
logwisp --config test.toml --quiet
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Help System
|
Run under a supervisor (systemd, rc.d) rather than backgrounding it — there is
|
||||||
|
no `--background` flag; earlier releases had one and it was removed. See
|
||||||
|
[Installation](installation.md).
|
||||||
|
|
||||||
### General Help
|
**Reload**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
logwisp --help
|
kill -HUP $(pidof logwisp)
|
||||||
logwisp -h
|
kill -USR1 $(pidof logwisp)
|
||||||
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
|
|
||||||
|
|||||||
+220
-138
@@ -1,203 +1,285 @@
|
|||||||
# Configuration Reference
|
# Configuration Reference
|
||||||
|
|
||||||
LogWisp configuration uses TOML format with flexible override mechanisms.
|
LogWisp is configured with TOML. A complete annotated file listing every option
|
||||||
|
and its default ships as [`config/logwisp.toml`](../config/logwisp.toml).
|
||||||
|
|
||||||
## Configuration Precedence
|
## Configuration Precedence
|
||||||
|
|
||||||
Configuration sources are evaluated in order:
|
Sources are merged in this order, highest priority first:
|
||||||
1. **Command-line flags** (highest priority)
|
|
||||||
2. **Environment variables**
|
1. Command-line flags
|
||||||
3. **Configuration file**
|
2. Environment variables
|
||||||
4. **Built-in defaults** (lowest priority)
|
3. Configuration file
|
||||||
|
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
|
||||||
|
|
||||||
LogWisp searches for configuration in order:
|
The path is resolved before any other configuration is read:
|
||||||
1. Path specified via `--config` flag
|
|
||||||
2. Path from `LOGWISP_CONFIG_FILE` environment variable
|
1. `-c <path>` on the command line
|
||||||
3. `~/.config/logwisp/logwisp.toml`
|
2. `--config=<path>` on the command line
|
||||||
4. `./logwisp.toml` in current directory
|
3. `$LOGWISP_CONFIG_FILE`, joined onto `$LOGWISP_CONFIG_DIR` when both are set
|
||||||
|
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 | Suppress console output |
|
| `quiet` | bool | `false` | Disable all application logging and console diagnostics |
|
||||||
| `status_reporter` | bool | true | Periodic status logging |
|
| `status_reporter` | bool | `true` | Emit a periodic status report every 30 s at DEBUG level |
|
||||||
| `auto_reload` | bool | false | Enable file watch for auto-reload |
|
| `auto_reload` | bool | `false` | Watch the config file and reload pipelines on change |
|
||||||
|
|
||||||
## Logging Configuration
|
`--version` prints version information and exits; it is not a persistent
|
||||||
|
setting.
|
||||||
|
|
||||||
LogWisp's internal operational logging:
|
Note that `status_reporter` writes at DEBUG level, so it produces nothing unless
|
||||||
|
`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"
|
||||||
name = "logwisp"
|
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
|
||||||
|
|
||||||
- **file**: Write to log files only
|
| Mode | Behaviour |
|
||||||
- **stdout**: Write to standard output
|
|------|-----------|
|
||||||
- **stderr**: Write to standard error
|
| `file` | Files only |
|
||||||
- **split**: INFO/DEBUG to stdout, WARN/ERROR to stderr
|
| `stdout` | Standard output only |
|
||||||
- **all**: Write to both file and console
|
| `stderr` | Standard error only |
|
||||||
- **none**: Disable all logging
|
| `split` | DEBUG/INFO to stdout, WARN/ERROR to stderr |
|
||||||
|
| `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 = "pipeline-name"
|
name = "app" # required, unique across pipelines
|
||||||
|
|
||||||
# Rate limiting (optional)
|
# --- flow: everything between sources and sinks ---
|
||||||
[pipelines.flow.rate_limit]
|
[pipelines.flow.rate_limit]
|
||||||
rate = 1000.0
|
rate = 1000.0
|
||||||
burst = 2000.0
|
burst = 2000.0
|
||||||
policy = "drop" # pass|drop
|
policy = "drop"
|
||||||
max_entry_size_bytes = 0 # 0=unlimited
|
max_entry_size_bytes = 65536
|
||||||
|
|
||||||
# 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"]
|
||||||
|
|
||||||
# Sinks (required, 1+)
|
[pipelines.flow.format]
|
||||||
|
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 = "my_sink"
|
id = "sse"
|
||||||
type = "http"
|
type = "http"
|
||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
# ... sink-specific config
|
port = 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
All configuration options support environment variable overrides:
|
Environment overrides are derived from the TOML path: `.` becomes `_` and the
|
||||||
|
result is uppercased.
|
||||||
|
|
||||||
### Naming Convention
|
| TOML path | Environment variable |
|
||||||
|
|
||||||
- Prefix: `LOGWISP_`
|
|
||||||
- Path separator: `_` (underscore)
|
|
||||||
- Array indices: Numeric suffix (0-based)
|
|
||||||
- Case: UPPERCASE
|
|
||||||
|
|
||||||
### Mapping Examples
|
|
||||||
|
|
||||||
| TOML Path | Environment Variable |
|
|
||||||
|-----------|---------------------|
|
|-----------|---------------------|
|
||||||
| `quiet` | `LOGWISP_QUIET` |
|
| `quiet` | `QUIET` |
|
||||||
| `logging.level` | `LOGWISP_LOGGING_LEVEL` |
|
| `status_reporter` | `STATUS_REPORTER` |
|
||||||
| `pipelines[0].name` | `LOGWISP_PIPELINES_0_NAME` |
|
| `logging.level` | `LOGGING_LEVEL` |
|
||||||
| `pipelines[0].plugin_sources[0].type` | `LOGWISP_PIPELINES_0_PLUGIN_SOURCES_0_TYPE` |
|
| `logging.file.directory` | `LOGGING_FILE_DIRECTORY` |
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
All configuration options can be overridden via CLI flags:
|
Any scalar configuration path is settable as a flag using its TOML path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
logwisp --quiet \
|
logwisp --logging.level=debug --status_reporter=false
|
||||||
--logging.level=debug \
|
logwisp --logging.level debug # space form also works
|
||||||
--pipelines.0.name=myapp \
|
logwisp --quiet # bare flag means true
|
||||||
--pipelines.0.plugin_sources.0.type=console
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Validation
|
Unrecognized flags are reported on stderr before the logger exists and are then
|
||||||
|
ignored:
|
||||||
|
|
||||||
LogWisp validates configuration at startup:
|
```
|
||||||
- Rpipelines non-empty, name non-empty, ≥1 source, ≥1 sink, logging enum values.equired fields presence
|
Warning: unrecognized flags ignored: [pipelines.0.name]
|
||||||
|
```
|
||||||
|
|
||||||
Partial check in plugin constructor:
|
> Array-indexed paths are **not** settable from the command line.
|
||||||
- Type correctness
|
> `--pipelines.0.name=x`, `--pipelines.0.plugin_sinks.0.type=null`, and similar
|
||||||
- Port conflicts
|
> flags are reported as unrecognized and ignored. Pipelines, sources, sinks, and
|
||||||
- Path accessibility
|
> filters can only be defined in the configuration file. Older documentation
|
||||||
- Pattern compilation
|
> claimed otherwise.
|
||||||
- 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 via command line:
|
or send `SIGHUP` / `SIGUSR1`.
|
||||||
```bash
|
|
||||||
logwisp --auto-reload
|
|
||||||
```
|
|
||||||
|
|
||||||
Reload triggers:
|
Reload rebuilds the whole service: a new service is constructed from the new
|
||||||
- File modification detection
|
configuration first, and only if that succeeds is the old one shut down. A
|
||||||
- SIGHUP or SIGUSR1 signals
|
configuration error therefore leaves the running service untouched.
|
||||||
|
|
||||||
Reloadable items:
|
| Reloaded | Not reloaded |
|
||||||
- Pipeline configurations
|
|----------|--------------|
|
||||||
- Sources and sinks
|
| Pipelines, sources, sinks | `logging.*` (applied once at startup) |
|
||||||
- Filters and formatters
|
| Filters, formatters, rate limits, heartbeats | `quiet` |
|
||||||
- Rate limits
|
| `status_reporter` | `auto_reload` (the watcher is not restarted) |
|
||||||
|
|
||||||
Non-reloadable (requires restart):
|
Because the rebuild is total, listeners close and reopen and every connected
|
||||||
- Logging configuration
|
client is disconnected. Chain sinks reconnect on their own backoff schedule.
|
||||||
- Global settings
|
|
||||||
|
|
||||||
## Default Configuration
|
## Type Reference
|
||||||
|
|
||||||
Minimal working configuration:
|
| TOML type | Go type | Command-line / environment form |
|
||||||
|
|-----------|---------|-------------------------------|
|
||||||
```toml
|
| String | `string` | Plain text |
|
||||||
[[pipelines]]
|
| Integer | `int64` | Decimal string |
|
||||||
name = "default"
|
| Float | `float64` | Decimal string |
|
||||||
|
| Boolean | `bool` | `true` / `false`, or a bare flag for `true` |
|
||||||
[[pipelines.plugin_sources]]
|
| Array | `[]T` | Not settable outside the file |
|
||||||
id = "default_source"
|
| Table | struct | Nested path with `.` (flags) or `_` (environment) |
|
||||||
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 `_` |
|
|
||||||
|
|||||||
+157
-150
@@ -1,185 +1,192 @@
|
|||||||
# Filters
|
# Filters
|
||||||
|
|
||||||
LogWisp filters control which log entries pass through the pipeline using pattern matching.
|
Filters decide which entries continue through a pipeline. They run in the flow,
|
||||||
|
after rate limiting and before formatting.
|
||||||
## Filter Types
|
|
||||||
|
|
||||||
### Include Filter
|
|
||||||
|
|
||||||
Only entries matching patterns pass through.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[pipelines.flow.filters]]
|
[[pipelines.flow.filters]]
|
||||||
type = "include"
|
type = "include"
|
||||||
logic = "or" # or|and
|
logic = "or"
|
||||||
patterns = [
|
patterns = ["ERROR", "WARN"]
|
||||||
"ERROR",
|
|
||||||
"WARN",
|
|
||||||
"CRITICAL"
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Exclude Filter
|
## Options
|
||||||
|
|
||||||
Entries matching patterns are dropped.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[[pipelines.flow.filters]]
|
|
||||||
type = "exclude"
|
|
||||||
patterns = [
|
|
||||||
"DEBUG",
|
|
||||||
"TRACE",
|
|
||||||
"health-check"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Options
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `type` | string | Required | Filter type (include/exclude) |
|
| `type` | string | `include` | `include` (only matches pass) or `exclude` (matches are dropped) |
|
||||||
| `logic` | string | "or" | Pattern matching logic (or/and) |
|
| `logic` | string | `or` | `or` (any pattern matches) or `and` (every pattern matches) |
|
||||||
| `patterns` | []string | Required | Pattern list |
|
| `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
|
||||||
|
|
||||||
|
### include
|
||||||
|
|
||||||
|
Only matching entries pass. Everything else is dropped.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[pipelines.flow.filters]]
|
||||||
|
type = "include"
|
||||||
|
patterns = ["ERROR", "WARN", "FATAL"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### exclude
|
||||||
|
|
||||||
|
Matching entries are dropped. Everything else passes.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[pipelines.flow.filters]]
|
||||||
|
type = "exclude"
|
||||||
|
patterns = ["/healthz", "TRACE"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logic
|
||||||
|
|
||||||
|
### or (default)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
logic = "or"
|
||||||
|
patterns = ["ERROR", "WARN"]
|
||||||
|
# passes: "ERROR in module" "WARN: low memory"
|
||||||
|
# blocks: "INFO: started"
|
||||||
|
```
|
||||||
|
|
||||||
|
### and
|
||||||
|
|
||||||
|
```toml
|
||||||
|
logic = "and"
|
||||||
|
patterns = ["database", "ERROR"]
|
||||||
|
# passes: "ERROR: database connection failed"
|
||||||
|
# blocks: "ERROR: file not found"
|
||||||
|
```
|
||||||
|
|
||||||
|
With `logic = "and"` on an `exclude` filter, an entry is dropped only when it
|
||||||
|
matches *every* pattern.
|
||||||
|
|
||||||
|
## Filter Chains
|
||||||
|
|
||||||
|
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
|
||||||
|
# 1. keep only production traffic
|
||||||
|
[[pipelines.flow.filters]]
|
||||||
|
type = "include"
|
||||||
|
patterns = ["prod-", "production"]
|
||||||
|
|
||||||
|
# 2. of that, keep only failures
|
||||||
|
[[pipelines.flow.filters]]
|
||||||
|
type = "include"
|
||||||
|
patterns = ["ERROR", "EXCEPTION", "FATAL"]
|
||||||
|
|
||||||
|
# 3. minus known noise
|
||||||
|
[[pipelines.flow.filters]]
|
||||||
|
type = "exclude"
|
||||||
|
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
|
## Pattern Syntax
|
||||||
|
|
||||||
Patterns support regular expression 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.
|
||||||
|
|
||||||
### Basic Patterns
|
| Need | Pattern |
|
||||||
- **Literal match**: `"ERROR"` - matches "ERROR" anywhere
|
|------|---------|
|
||||||
- **Case-insensitive**: `"(?i)error"` - matches "error", "ERROR", "Error"
|
| Literal substring | `ERROR` |
|
||||||
- **Word boundary**: `"\\berror\\b"` - matches whole word only
|
| Case-insensitive | `(?i)error` |
|
||||||
|
| Whole word | `\\berror\\b` |
|
||||||
|
| Alternation | `ERROR\|WARN\|FATAL` |
|
||||||
|
| Character class | `[0-9]{3}` |
|
||||||
|
| Anchors | `^ERROR`, `ERROR$` |
|
||||||
|
| Any characters | `.*exception.*` |
|
||||||
|
|
||||||
### Advanced Patterns
|
Remember that TOML basic strings process escapes, so a regex backslash needs
|
||||||
- **Alternation**: `"ERROR|WARN|FATAL"`
|
doubling: `"\\berror\\b"`. TOML literal strings avoid the issue:
|
||||||
- **Character classes**: `"[0-9]{3}"`
|
`'\berror\b'`.
|
||||||
- **Wildcards**: `".*exception.*"`
|
|
||||||
- **Line anchors**: `"^ERROR"` (start), `"ERROR$"` (end)
|
|
||||||
|
|
||||||
### Special Characters
|
Anchors apply to the assembled match text, which begins with the source name —
|
||||||
Escape special regex characters with backslash:
|
so `^ERROR` will not match an entry whose source is non-empty. Use
|
||||||
- `.` → `\\.`
|
`\\bERROR\\b` instead unless you mean to anchor on the source.
|
||||||
- `*` → `\\*`
|
|
||||||
- `[` → `\\[`
|
|
||||||
- `(` → `\\(`
|
|
||||||
|
|
||||||
## Filter Logic
|
## Common Recipes
|
||||||
|
|
||||||
### OR Logic (default)
|
**Severity floor**
|
||||||
Entry passes if ANY pattern matches:
|
|
||||||
```toml
|
|
||||||
logic = "or"
|
|
||||||
patterns = ["ERROR", "WARN"]
|
|
||||||
# Passes: "ERROR in module", "WARN: low memory"
|
|
||||||
# Blocks: "INFO: started"
|
|
||||||
```
|
|
||||||
|
|
||||||
### AND Logic
|
|
||||||
Entry passes only if ALL patterns match:
|
|
||||||
```toml
|
|
||||||
logic = "and"
|
|
||||||
patterns = ["database", "ERROR"]
|
|
||||||
# Passes: "ERROR: database connection failed"
|
|
||||||
# Blocks: "ERROR: file not found"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Filter Chain
|
|
||||||
|
|
||||||
Multiple filters execute sequentially:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# First filter: Include errors and warnings
|
|
||||||
[[pipelines.flow.filters]]
|
[[pipelines.flow.filters]]
|
||||||
type = "include"
|
type = "include"
|
||||||
patterns = ["ERROR", "WARN"]
|
patterns = ["ERROR", "FATAL", "CRITICAL"]
|
||||||
|
|
||||||
# Second filter: Exclude test environments
|
|
||||||
[[pipelines.flow.filters]]
|
|
||||||
type = "exclude"
|
|
||||||
patterns = ["test-env", "staging"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Processing order:
|
**Noise reduction**
|
||||||
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
|
```toml
|
||||||
[[pipelines.flow.filters]]
|
[[pipelines.flow.filters]]
|
||||||
type = "include"
|
type = "exclude"
|
||||||
patterns = ["app1", "app2", "app3"]
|
patterns = ["/healthz", "/metrics", "\\bping\\b"]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Noise Reduction
|
**Secret suppression** — see [Security](security.md); filters are the only
|
||||||
|
redaction mechanism LogWisp currently offers.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[pipelines.flow.filters]]
|
[[pipelines.flow.filters]]
|
||||||
type = "exclude"
|
type = "exclude"
|
||||||
patterns = [
|
patterns = ["password", "api[_-]?key", "authorization", "bearer ", "secret", "token"]
|
||||||
"health-check",
|
|
||||||
"ping",
|
|
||||||
"/metrics",
|
|
||||||
"heartbeat"
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Filtering
|
Note this drops the whole entry, it does not redact part of it.
|
||||||
```toml
|
|
||||||
[[pipelines.flow.filters]]
|
|
||||||
type = "exclude"
|
|
||||||
patterns = [
|
|
||||||
"password",
|
|
||||||
"token",
|
|
||||||
"api[_-]key",
|
|
||||||
"secret"
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-stage Filtering
|
**Per-application routing** — run one pipeline per application, each with its
|
||||||
```toml
|
own include filter, rather than trying to route inside one pipeline. Sinks fan
|
||||||
# Include production logs
|
out to *all* sinks in a pipeline; there is no conditional routing.
|
||||||
[[pipelines.flow.filters]]
|
|
||||||
type = "include"
|
|
||||||
patterns = ["prod-", "production"]
|
|
||||||
|
|
||||||
# Include only errors
|
## Statistics
|
||||||
[[pipelines.flow.filters]]
|
|
||||||
type = "include"
|
|
||||||
patterns = ["ERROR", "EXCEPTION", "FATAL"]
|
|
||||||
|
|
||||||
# Exclude known issues
|
Each filter reports `type`, `logic`, `pattern_count`, `total_processed`,
|
||||||
[[pipelines.flow.filters]]
|
`total_matched`, and `total_dropped`. The chain reports `filter_count`,
|
||||||
type = "exclude"
|
`total_processed`, and `total_passed`; the pipeline derives
|
||||||
patterns = ["ECONNRESET", "broken pipe"]
|
`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.
|
||||||
|
|||||||
+145
-142
@@ -1,180 +1,183 @@
|
|||||||
# Formatters
|
# Formatters
|
||||||
|
|
||||||
LogWisp formatters transform log entries before output to sinks.
|
The formatter is the last flow stage. It turns a `core.LogEntry` into the byte
|
||||||
|
payload that sinks write, applying a sanitizer policy on the way.
|
||||||
|
|
||||||
## Formatter Types
|
```toml
|
||||||
|
[pipelines.flow.format]
|
||||||
|
type = "json"
|
||||||
|
sanitizer_policy = "json"
|
||||||
|
flags = 0
|
||||||
|
timestamp_format = ""
|
||||||
|
```
|
||||||
|
|
||||||
### Raw Formatter
|
One formatter serves the whole pipeline. Sinks receive an identical payload;
|
||||||
|
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.
|
||||||
|
|
||||||
Outputs the log message as-is with optional newline.
|
Omitting `[pipelines.flow.format]` entirely selects `raw`.
|
||||||
|
|
||||||
|
## 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Options:**
|
Fastest option, and the right one when you are relaying text that is already in
|
||||||
|
its final form. Note that it also bypasses sanitization, so control characters
|
||||||
|
in the source data reach your sinks intact.
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
Byte-exact transport needs a source that does not split the line: the `console`
|
||||||
|--------|------|---------|-------------|
|
source, or the `file` source with `raw = true`. Both put the whole line —
|
||||||
| `add_new_line` | bool | true | Append newline to messages |
|
newline included — in the message and leave `fields` empty. The `file` source's
|
||||||
| `type` | string | "raw" | raw, json, or txt |
|
JSON branch splits a line into message and fields, so `raw` reassembles it as
|
||||||
| `flags` | int64 | 0 | log/formatter flags override |
|
`<msg> <fields>` rather than reproducing the original object.
|
||||||
| `sanitizer_policy` | string | | Sanitizer policy (e.g. "json", "raw", "txt", "shell") |
|
|
||||||
|
|
||||||
### JSON Formatter
|
### txt
|
||||||
|
|
||||||
Produces structured JSON output.
|
Human-readable line output with a timestamp and level.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.format]
|
[pipelines.flow.format]
|
||||||
type = "json"
|
type = "txt"
|
||||||
|
sanitizer_policy = "txt"
|
||||||
|
timestamp_format = "2006-01-02 15:04:05"
|
||||||
|
```
|
||||||
|
|
||||||
+[pipelines.flow.format]
|
### json
|
||||||
type = "json"
|
|
||||||
|
Structured output, the natural choice for downstream ingestion.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[pipelines.flow.format]
|
||||||
|
type = "json"
|
||||||
sanitizer_policy = "json"
|
sanitizer_policy = "json"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Output Structure:**
|
Output has the shape:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{"time":"2026-01-02T15:04:05.123Z","level":"ERROR","trace":"edge-01/app.log","fields":["connection refused"]}
|
||||||
"timestamp": "2024-01-01T12:00:00Z",
|
|
||||||
"level": "ERROR",
|
|
||||||
"source": "app",
|
|
||||||
"message": "Connection failed"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Text Formatter
|
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.
|
||||||
|
|
||||||
Template-based text formatting.
|
## Flags
|
||||||
|
|
||||||
```toml
|
`flags` is a bitmask passed to the underlying formatter. Leave it at `0` unless
|
||||||
[pipelines.flow.format]
|
you need to override the defaults.
|
||||||
type = "txt"
|
|
||||||
sanitizer_policy = "txt"
|
| Value | Name | Effect |
|
||||||
timestamp_format = "2006-01-02T15:04:05.000Z07:00"
|
|-------|------|--------|
|
||||||
|
| `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
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Options:**
|
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.
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
## Structured Fields
|
||||||
|--------|------|---------|-------------|
|
|
||||||
| `timestamp_format` | string | "" | Time format override |
|
|
||||||
|
|
||||||
**Default Template:**
|
When an entry carries `Fields` (raw JSON) and `FlagRaw` is not set, the
|
||||||
```
|
formatter parses it and switches to structured rendering by adding the
|
||||||
[{{.Timestamp | FmtTime}}] [{{.Level | ToUpper}}] {{.Source}} - {{.Message}}{{ if .Fields }} {{.Fields}}{{ end }}
|
`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`.
|
||||||
|
|
||||||
## Template Functions
|
## Choosing a Configuration
|
||||||
|
|
||||||
Available functions in text templates:
|
| 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 |
|
||||||
|
|
||||||
| Function | Description | Example |
|
## Formatting and Chain Links
|
||||||
|----------|-------------|---------|
|
|
||||||
| `FmtTime` | Format timestamp | `{{.Timestamp \| FmtTime}}` |
|
|
||||||
| `ToUpper` | Convert to uppercase | `{{.Level \| ToUpper}}` |
|
|
||||||
| `ToLower` | Convert to lowercase | `{{.Source \| ToLower}}` |
|
|
||||||
| `TrimSpace` | Remove whitespace | `{{.Message \| TrimSpace}}` |
|
|
||||||
|
|
||||||
## Template Variables
|
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.
|
||||||
|
|
||||||
Available variables in templates:
|
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.
|
||||||
|
|
||||||
| Variable | Type | Description |
|
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`.
|
||||||
| `.Timestamp` | time.Time | Entry timestamp |
|
A non-zero `synthesized` count means something upstream lost structure.
|
||||||
| `.Level` | string | Log level |
|
|
||||||
| `.Source` | string | Source identifier |
|
|
||||||
| `.Message` | string | Log message |
|
|
||||||
| `.Fields` | string | Additional fields (JSON) |
|
|
||||||
|
|
||||||
## Time Format Strings
|
## Performance
|
||||||
|
|
||||||
Common Go time format patterns:
|
Relative cost, cheapest first: `raw` (passthrough) → `txt` (line assembly) →
|
||||||
|
`json` (serialization). Sanitization adds a scan of the message; the `raw`
|
||||||
|
policy skips it.
|
||||||
|
|
||||||
| Pattern | Example Output |
|
The formatter holds a mutex because the underlying implementation reuses an
|
||||||
|---------|---------------|
|
internal buffer and is not goroutine-safe. It is the only shared serialization
|
||||||
| `2006-01-02T15:04:05Z07:00` | 2024-01-02T15:04:05Z |
|
point in the hot path, and the reason a single pipeline formats entries one at
|
||||||
| `2006-01-02 15:04:05` | 2024-01-02 15:04:05 |
|
a time.
|
||||||
| `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
|
|
||||||
[pipelines.flow.format]
|
|
||||||
type = "json"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Human-Readable Logs
|
|
||||||
```toml
|
|
||||||
[pipelines.flow.format]
|
|
||||||
type = "txt"
|
|
||||||
timestamp_format = "15:04:05"
|
|
||||||
```
|
|
||||||
|
|||||||
+124
-62
@@ -1,49 +1,80 @@
|
|||||||
# Installation Guide
|
# Installation Guide
|
||||||
|
|
||||||
LogWisp installation and service configuration for Linux and FreeBSD systems.
|
## Requirements
|
||||||
|
|
||||||
## Installation Methods
|
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
|
||||||
|
- **Architecture**: amd64
|
||||||
|
- **Go**: 1.27.1 or newer, to build from source
|
||||||
|
|
||||||
### Pre-built Binaries
|
## Building from Source
|
||||||
|
|
||||||
Download the latest release binary for your platform and install to `/usr/local/bin`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux amd64
|
git clone https://github.com/lixenwraith/logwisp.git
|
||||||
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
|
||||||
go build -o logwisp ./src/cmd/logwisp
|
make
|
||||||
sudo install -m 755 logwisp /usr/local/bin/
|
sudo make install # installs to $PREFIX/bin, default /usr/local/bin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Go Install Method
|
The Makefile works with both GNU make and BSD make. Targets:
|
||||||
|
|
||||||
Install directly using Go (version information will not be embedded):
|
| Target | Effect |
|
||||||
|
|--------|--------|
|
||||||
|
| `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 install github.com/yourusername/logwisp/cmd/logwisp@latest
|
go build -o bin/logwisp ./cmd/logwisp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Service Configuration
|
`go install github.com/lixenwraith/logwisp/cmd/logwisp@latest` also works, with
|
||||||
|
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)
|
||||||
|
|
||||||
Create systemd service file `/etc/systemd/system/logwisp.service`:
|
`/etc/systemd/system/logwisp.service`:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -55,30 +86,47 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Setup service user and directories:
|
`ExecReload` gives you `systemctl reload logwisp` for configuration and
|
||||||
|
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 /bin/false logwisp
|
sudo useradd -r -s /usr/sbin/nologin 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 logwisp
|
sudo systemctl enable --now 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)
|
||||||
|
|
||||||
Create rc script `/usr/local/etc/rc.d/logwisp`:
|
`/usr/local/etc/rc.d/logwisp`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
@@ -92,8 +140,9 @@ Create rc script `/usr/local/etc/rc.d/logwisp`:
|
|||||||
name="logwisp"
|
name="logwisp"
|
||||||
rcvar="${name}_enable"
|
rcvar="${name}_enable"
|
||||||
pidfile="/var/run/${name}.pid"
|
pidfile="/var/run/${name}.pid"
|
||||||
command="/usr/local/bin/logwisp"
|
procname="/usr/local/bin/logwisp"
|
||||||
command_args="-c /usr/local/etc/logwisp/logwisp.toml"
|
command="/usr/sbin/daemon"
|
||||||
|
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"}
|
||||||
@@ -101,7 +150,7 @@ load_rc_config $name
|
|||||||
run_rc_command "$1"
|
run_rc_command "$1"
|
||||||
```
|
```
|
||||||
|
|
||||||
Setup service:
|
Setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo chmod +x /usr/local/etc/rc.d/logwisp
|
sudo chmod +x /usr/local/etc/rc.d/logwisp
|
||||||
@@ -112,45 +161,59 @@ sudo sysrc logwisp_enable="YES"
|
|||||||
sudo service logwisp start
|
sudo service logwisp start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Layout
|
||||||
|
|
||||||
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/` |
|
||||||
| Working Directory | `/var/lib/logwisp/` | `/var/db/logwisp/` |
|
| TLS material | `/etc/logwisp/tls/` | `/usr/local/etc/logwisp/tls/` |
|
||||||
| Log Files | `/var/log/logwisp/` | `/var/log/logwisp/` |
|
| Working directory | `/var/lib/logwisp/` | `/var/db/logwisp/` |
|
||||||
| PID File | `/var/run/logwisp.pid` | `/var/run/logwisp.pid` |
|
| Application logs | `/var/log/logwisp/` | `/var/log/logwisp/` |
|
||||||
|
|
||||||
## Post-Installation Verification
|
Key files should be mode `0600` and owned by the service account.
|
||||||
|
|
||||||
Verify the installation:
|
## Verification
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check version
|
logwisp --version
|
||||||
logwisp version
|
|
||||||
|
|
||||||
# Test configuration
|
# start in the foreground with debug logging and watch pipelines come up
|
||||||
logwisp -c /etc/logwisp/logwisp.toml --disable-status-reporter
|
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug --logging.output=stderr
|
||||||
|
|
||||||
# Check service status (Linux)
|
sudo systemctl status logwisp # Linux
|
||||||
sudo systemctl status logwisp
|
sudo service logwisp status # FreeBSD
|
||||||
|
|
||||||
# Check service status (FreeBSD)
|
|
||||||
sudo service logwisp status
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Uninstallation
|
Expect `Created source instance`, `Created sink instance`, and
|
||||||
|
`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 15801–15804. The pass-through test binds nothing. Generated configuration
|
||||||
|
and logs land in `test/run/`.
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl stop logwisp
|
sudo systemctl disable --now logwisp
|
||||||
sudo systemctl disable logwisp
|
sudo rm /usr/local/bin/logwisp /etc/systemd/system/logwisp.service
|
||||||
sudo rm /usr/local/bin/logwisp
|
sudo systemctl daemon-reload
|
||||||
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
|
||||||
```
|
```
|
||||||
@@ -160,8 +223,7 @@ 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
|
sudo rm /usr/local/bin/logwisp /usr/local/etc/rc.d/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
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
# mTLS as Authentication
|
||||||
|
|
||||||
|
**Status:** implemented. Phases 1–3 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.
|
||||||
+172
-59
@@ -1,95 +1,208 @@
|
|||||||
# Networking
|
# Networking
|
||||||
|
|
||||||
*Note: Under redesign*
|
Everything LogWisp does over a socket, and the knobs that shape it. For
|
||||||
|
certificates and trust see [Security](security.md); for multi-node topologies
|
||||||
|
see [Chaining](chaining.md).
|
||||||
|
|
||||||
## TLS Configuration
|
## Address Family
|
||||||
|
|
||||||
*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.*
|
**All listeners bind `tcp4` and all dialers dial `tcp4`.** IPv6 is not
|
||||||
|
supported, deliberately. An IPv6 client cannot connect and will simply see a
|
||||||
|
connection failure.
|
||||||
|
|
||||||
## Connection Management
|
When testing locally use `127.0.0.1`, not `localhost` — the latter may resolve
|
||||||
|
to `::1` and appear as an unexplained connection refusal.
|
||||||
|
|
||||||
### TCP Keep-Alive
|
## Network Plugins
|
||||||
|
|
||||||
```toml
|
| Plugin | Role | Protocol | Purpose |
|
||||||
[[pipelines.plugin_sinks]]
|
|--------|------|----------|---------|
|
||||||
id = "tcp_out"
|
| `tcp` sink | Listener | Raw stream | Broadcast formatted payloads to clients |
|
||||||
type = "tcp"
|
| `http` sink | Listener | HTTP SSE | Browser-friendly live stream plus status JSON |
|
||||||
[pipelines.plugin_sinks.config]
|
| `tcp_chain` source | Listener | Chain v1 | Ingest a persistent NDJSON stream |
|
||||||
keep_alive = true
|
| `http_chain` source | Listener | Chain v1 | Ingest NDJSON batches over POST |
|
||||||
keep_alive_period_ms = 30000 # 30 seconds
|
| `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"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Connection Timeouts
|
## 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]]
|
keep_alive = true
|
||||||
id = "http_out"
|
keep_alive_period_ms = 30000
|
||||||
type = "http"
|
|
||||||
[pipelines.plugin_sinks.config]
|
|
||||||
write_timeout_ms = 10000 # 10 seconds
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Heartbeat Configuration
|
This is kernel-level keep-alive; it detects a dead peer but does not keep an
|
||||||
|
application-level stream flowing. For that, use a heartbeat.
|
||||||
|
|
||||||
Keep connections alive with periodic heartbeats. Note that Heartbeat is a flow-level feature in the new architecture.
|
## Heartbeats
|
||||||
|
|
||||||
|
Heartbeats are a **flow-level** feature, not a per-sink one. Enabling one
|
||||||
|
injects a synthetic entry into the pipeline at a fixed interval; it reaches
|
||||||
|
every sink and traverses chain links as an ordinary structured entry.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.flow.heartbeat]
|
[pipelines.flow.heartbeat]
|
||||||
enabled = true
|
enabled = true
|
||||||
interval_ms = 30000
|
interval_ms = 30000
|
||||||
include_timestamp = true
|
include_timestamp = true
|
||||||
include_stats = false
|
include_stats = false
|
||||||
format = "comment" # comment|event|json
|
format = "txt" # txt | json | raw
|
||||||
```
|
```
|
||||||
|
|
||||||
## Network Protocols
|
Use it to keep idle SSE clients, TCP clients, and chain links from being
|
||||||
|
reaped by intermediate NAT or proxy timeouts, and to make an idle pipeline
|
||||||
|
visibly alive.
|
||||||
|
|
||||||
### HTTP/HTTPS
|
> `format = "comment"` (SSE `:` comment framing) is rejected by validation
|
||||||
|
> despite appearing in older documentation and in a still-present code branch.
|
||||||
|
> A pipeline configured with it fails to start.
|
||||||
|
|
||||||
- HTTP/1.1 support
|
## Reconnection
|
||||||
- Persistent connections
|
|
||||||
- Server-Sent Events (SSE)
|
|
||||||
|
|
||||||
### TCP
|
Chain sinks reconnect on their own. Both use exponential backoff between
|
||||||
|
`backoff_min_ms` and `backoff_max_ms` with ±20 % jitter, and both are
|
||||||
|
interruptible by shutdown.
|
||||||
|
|
||||||
- Raw TCP sockets
|
```toml
|
||||||
- Newline-delimited protocol
|
backoff_min_ms = 500
|
||||||
|
backoff_max_ms = 30000
|
||||||
|
```
|
||||||
|
|
||||||
## Port Configuration
|
The connection is established lazily, so an edge node starts cleanly even when
|
||||||
|
its relay is down and connects as soon as the relay appears. Reconnect counts
|
||||||
|
are reported in the sink's `reconnects` statistic.
|
||||||
|
|
||||||
### Default Ports
|
Server-side sinks (`tcp`, `http`) do not reconnect; clients are expected to
|
||||||
|
retry. Browsers reconnect SSE streams automatically.
|
||||||
|
|
||||||
| Service | Default Port | Protocol |
|
## Protocol Details
|
||||||
|---------|--------------|----------|
|
|
||||||
| HTTP Sink | 8080 | HTTP |
|
|
||||||
| TCP Sink | 9090 | TCP |
|
|
||||||
|
|
||||||
### Port Conflict Prevention
|
**HTTP sink (SSE)** — HTTP/1.1 in plaintext; HTTP/2 is negotiated via ALPN when
|
||||||
|
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: *`.
|
||||||
|
|
||||||
LogWisp validates port usage at startup:
|
**TCP sink** — raw payload bytes, no framing added by the sink. Whether entries
|
||||||
- Detects port conflicts across pipelines
|
are newline-delimited depends on the formatter.
|
||||||
- Prevents duplicate bindings
|
|
||||||
|
**Chain transports** — see [Chaining](chaining.md) for the hello preamble,
|
||||||
|
headers, and entry encoding.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
**Connection refused**
|
||||||
|
- 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.
|
||||||
|
|
||||||
**Connection Refused**
|
**TLS handshake failure**
|
||||||
- Check firewall rules
|
- `client didn't provide a certificate` — the listener has `client_auth = true`
|
||||||
- Verify service is running
|
and the dialer has no `cert_file`/`key_file`.
|
||||||
- Confirm correct port/host
|
- `certificate signed by unknown authority` — the dialer's `ca_file` does not
|
||||||
|
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`.
|
||||||
|
|
||||||
**TLS Handshake Failure**
|
**Rejected after a successful handshake**
|
||||||
- Verify certificate validity
|
- `auth: identity "..." is not allowed` — the certificate is valid and chains to
|
||||||
- Check certificate chain
|
the CA, but the identity is not in `auth.allow` / `auth.allow_patterns`. On
|
||||||
- Confirm TLS versions match
|
TCP the connection is closed; on HTTP the answer is `403`.
|
||||||
|
- `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`.
|
||||||
|
|
||||||
**Rate Limit Exceeded**
|
**Entries not arriving over a chain link**
|
||||||
- Adjust rate limit parameters
|
- Check the sink's `connected` statistic and its `reconnects` count.
|
||||||
- Add IP to whitelist
|
- Check the source's `auth_rejected` — an allow-list miss looks exactly like a
|
||||||
- Implement client-side throttling
|
network fault from the sender's side.
|
||||||
|
- 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.
|
||||||
|
|
||||||
**Connection Timeout**
|
**Entries arriving under an unexpected node label**
|
||||||
- Increase timeout values
|
- `auth.node_binding` defaults to `force` when `auth.type = "mtls"`, which
|
||||||
- Check network latency
|
relabels every entry with the sender's certificate identity. If a dashboard
|
||||||
- Verify keep-alive settings
|
suddenly shows a different label, that is why. Use `node_binding = "assert"`
|
||||||
|
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).
|
||||||
|
|||||||
+215
-222
@@ -1,327 +1,320 @@
|
|||||||
# Operations Guide
|
# Operations Guide
|
||||||
|
|
||||||
Running, monitoring, and maintaining LogWisp in production.
|
Running, monitoring, and maintaining LogWisp.
|
||||||
|
|
||||||
*Note: TLS, acccess control under redesign*
|
## Starting
|
||||||
|
|
||||||
## Starting LogWisp
|
|
||||||
|
|
||||||
### Manual Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Foreground with default config
|
# foreground, explicit 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Service Management
|
There is no built-in daemon mode. Run LogWisp in the foreground under a
|
||||||
|
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 Management
|
## Configuration Changes
|
||||||
|
|
||||||
### Hot Reload
|
### Hot reload
|
||||||
|
|
||||||
Enable automatic configuration reload:
|
|
||||||
```toml
|
```toml
|
||||||
config_auto_reload = true
|
auto_reload = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via command line:
|
or send a signal:
|
||||||
```bash
|
|
||||||
logwisp --config-auto-reload
|
|
||||||
```
|
|
||||||
|
|
||||||
Trigger manual reload:
|
|
||||||
```bash
|
```bash
|
||||||
kill -HUP $(pidof logwisp)
|
kill -HUP $(pidof logwisp)
|
||||||
# or
|
|
||||||
kill -USR1 $(pidof logwisp)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration Validation
|
Reload constructs a new service from the new configuration **before** tearing
|
||||||
|
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 --config test.toml --quiet --status-reporter=false
|
logwisp -c candidate.toml --logging.level=debug --logging.output=stderr
|
||||||
```
|
```
|
||||||
|
|
||||||
Check for errors:
|
Success looks like `Created source instance`, `Created sink instance`, and
|
||||||
- Port conflicts
|
`Starting pipeline` for each pipeline. Failures name the pipeline and the
|
||||||
- Invalid patterns
|
offending key:
|
||||||
- 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
|
||||||
|
|
||||||
Built-in periodic status logging (30-second intervals):
|
Enabled by default, every 30 seconds. It logs at **DEBUG**, so it produces
|
||||||
|
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
|
||||||
disable_status_reporter = true
|
status_reporter = true
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "debug"
|
||||||
```
|
```
|
||||||
|
|
||||||
### HTTP Status Endpoint
|
It emits a service summary and then walks each pipeline, flattening scalar
|
||||||
|
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 http://localhost:8080/status | jq .
|
curl -s http://127.0.0.1:8080/status | jq .
|
||||||
```
|
```
|
||||||
|
|
||||||
Response structure:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"uptime": "2h15m30s",
|
"service": "LogWisp",
|
||||||
"pipelines": {
|
"version": "v0.16.0",
|
||||||
"default": {
|
"instance_id": "sse",
|
||||||
"sources": 1,
|
"server": {
|
||||||
"sinks": 2,
|
"type": "http",
|
||||||
"processed": 15234,
|
"host": "0.0.0.0",
|
||||||
"filtered": 523,
|
"port": 8080,
|
||||||
"dropped": 12
|
"tls": false,
|
||||||
}
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Metrics Collection
|
This endpoint is scoped to one sink, not to the whole process, and it is
|
||||||
|
**unauthenticated**. Bind it to a trusted interface.
|
||||||
|
|
||||||
Track via logs:
|
### Metrics worth watching
|
||||||
- Total entries processed
|
|
||||||
- Entries filtered
|
| Metric | Where | Meaning if rising |
|
||||||
- Entries dropped
|
|--------|-------|-------------------|
|
||||||
- Active connections
|
| `dropped_entries` | source | Downstream cannot keep up with the source |
|
||||||
- Buffer utilization
|
| `total_dropped` | flow | Rate limit or filters are discarding entries (often intended) |
|
||||||
|
| `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 Operational Logs
|
LogWisp's own operational log:
|
||||||
|
|
||||||
Configuration for LogWisp's own logs:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[logging]
|
[logging]
|
||||||
output = "file"
|
output = "file"
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|
||||||
[logging.file]
|
[logging.file]
|
||||||
directory = "/var/log/logwisp"
|
directory = "/var/log/logwisp"
|
||||||
name = "logwisp"
|
name = "logwisp"
|
||||||
max_size_mb = 100
|
max_size_mb = 100
|
||||||
retention_hours = 168
|
max_total_size_mb = 1000
|
||||||
|
retention_hours = 168.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Log Rotation
|
Rotation is automatic on size, with a total-size cap and a retention window.
|
||||||
|
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.
|
||||||
|
|
||||||
Automatic rotation based on:
|
Production level: `info`, or `warn` on a busy relay. Avoid `debug` under load:
|
||||||
- File size threshold
|
the filter stage logs several lines per entry evaluated.
|
||||||
- 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
|
||||||
|
|
||||||
### Buffer Sizing
|
### Buffers
|
||||||
|
|
||||||
Adjust buffers based on load:
|
Raise `buffer_size` when `total_dropped_by_sink` is climbing but the sink itself
|
||||||
|
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_sources]]
|
[pipelines.plugin_sinks.config]
|
||||||
id = "file_in"
|
buffer_size = 5000
|
||||||
type = "file"
|
client_buffer_size = 1024
|
||||||
[pipelines.plugin_sources.config]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate limiting
|
||||||
|
|
||||||
Protect against overload:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[pipelines.flow.rate_limit]
|
[pipelines.flow.rate_limit]
|
||||||
rate = 1000.0 # Entries per second
|
rate = 1000.0
|
||||||
burst = 2000.0 # Burst capacity
|
burst = 2000.0
|
||||||
policy = "drop" # Drop excess entries
|
policy = "drop"
|
||||||
|
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
|
||||||
|
|
||||||
### Common Issues
|
**Nothing appears at the sink**
|
||||||
|
|
||||||
**High Memory Usage**
|
Walk the pipeline in order and read the counters: source `total_entries` (is
|
||||||
- Check buffer sizes
|
anything being produced?), flow `total_dropped` (filters or rate limit?),
|
||||||
- Monitor goroutine count
|
pipeline `total_dropped_by_sink` (sink backed up?), sink `total_processed`.
|
||||||
- Review retention settings
|
|
||||||
|
|
||||||
**Dropped Entries**
|
**File source reads nothing**
|
||||||
- Increase buffer sizes
|
|
||||||
- Add rate limiting
|
|
||||||
- Check sink performance
|
|
||||||
|
|
||||||
**Connection Errors**
|
- The watcher seeks to end-of-file on start; only content appended afterwards is
|
||||||
- Verify network connectivity
|
read. Positions are in memory, so a restart re-seeks to end and anything
|
||||||
- Check firewall rules
|
written during the downtime is lost. `from = "start"` reads each file whole
|
||||||
- Review TLS certificates
|
instead, and replays it on every restart.
|
||||||
|
- `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.
|
||||||
|
|
||||||
### Debug Mode
|
**High memory use**
|
||||||
|
|
||||||
Enable detailed logging:
|
Buffers are bounded, so unbounded growth almost always means many buffers:
|
||||||
```bash
|
count sinks × `buffer_size`, plus clients × `client_buffer_size`. A `tcp_chain`
|
||||||
logwisp --logging.level=debug --logging.output=stderr
|
sink blocked on an unreachable downstream also holds its full input queue.
|
||||||
```
|
|
||||||
|
|
||||||
### Health Checks
|
**Chain link not delivering**
|
||||||
|
|
||||||
Implement external monitoring:
|
Check `connected` and `reconnects` on the sink, `parse_errors` on the source,
|
||||||
```bash
|
and remember `http_chain` waits up to `flush_interval_ms`. For TLS problems see
|
||||||
#!/bin/bash
|
[Networking](networking.md#troubleshooting).
|
||||||
# Health check script
|
|
||||||
if ! curl -sf http://localhost:8080/status > /dev/null; then
|
|
||||||
echo "LogWisp health check failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backup and Recovery
|
**Environment variable override has no effect**
|
||||||
|
|
||||||
### Configuration Backup
|
LogWisp currently reads these **without** the `LOGWISP_` prefix — `QUIET`,
|
||||||
|
`LOGGING_LEVEL`, and so on. Array-indexed paths cannot be set from the
|
||||||
```bash
|
environment or the command line at all.
|
||||||
# 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 Management
|
**Certificate rotation**
|
||||||
|
|
||||||
Monitor certificate expiration:
|
|
||||||
```bash
|
```bash
|
||||||
openssl x509 -in /path/to/cert.pem -noout -enddate
|
openssl x509 -in /etc/logwisp/tls/relay.crt -noout -enddate
|
||||||
```
|
```
|
||||||
|
|
||||||
Rotate certificates:
|
Certificates load at plugin construction, so rotation is: write the new files,
|
||||||
1. Generate new certificates
|
then `kill -HUP`. Automate the expiry check; nothing in LogWisp warns you.
|
||||||
2. Update configuration
|
|
||||||
3. Reload service (SIGHUP)
|
|
||||||
|
|
||||||
### Access Auditing
|
**Access review**
|
||||||
|
|
||||||
Monitor access patterns:
|
With `tls` alone, any certificate signed by the configured `client_ca_file` is
|
||||||
- Review connection logs
|
accepted, so "access review" means reviewing what your CA has issued. Add an
|
||||||
- Monitor rate limit hits
|
`auth` block with an explicit `allow` list and the review becomes the config
|
||||||
|
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
|
||||||
|
|
||||||
### Planned Maintenance
|
**Upgrades**
|
||||||
|
|
||||||
1. Notify users of maintenance window
|
1. Read the changelog for configuration-schema changes.
|
||||||
2. Stop accepting new connections
|
2. Start the new binary against the current configuration in a scratch
|
||||||
3. Drain existing connections
|
environment.
|
||||||
4. Perform maintenance
|
3. Stop the old process, install the new binary, start it.
|
||||||
5. Restart service
|
4. Confirm each pipeline started and that counters are advancing.
|
||||||
|
|
||||||
### Upgrade Process
|
**Backup**
|
||||||
|
|
||||||
1. Download new version
|
Configuration files and TLS material are the only durable state worth backing
|
||||||
2. Test with current configuration
|
up. LogWisp keeps no persistent runtime state: file read positions live in
|
||||||
3. Stop old version
|
memory, connections are re-established on restart, and in-flight entries are
|
||||||
4. Install new version
|
lost.
|
||||||
5. Start service
|
|
||||||
6. Verify operation
|
|
||||||
|
|
||||||
### Cleanup Tasks
|
**Redundancy**
|
||||||
|
|
||||||
Regular maintenance:
|
Because there is no persistence, availability comes from topology, not from
|
||||||
- Remove old log files
|
LogWisp itself. Give each edge two chain sinks pointing at two relays if you
|
||||||
- Clean temporary files
|
need to survive a relay outage, and accept that this duplicates entries
|
||||||
- Verify disk space
|
downstream.
|
||||||
- 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
|
|
||||||
|
|||||||
+392
-1
@@ -1,4 +1,395 @@
|
|||||||
# Security
|
# Security
|
||||||
|
|
||||||
*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.*
|
This page covers LogWisp's transport security: what it protects, how to
|
||||||
|
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 (90–825 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).
|
||||||
|
|||||||
+336
-113
@@ -1,154 +1,377 @@
|
|||||||
# Output Sinks
|
# Output Sinks
|
||||||
|
|
||||||
LogWisp sinks deliver processed log entries to various destinations.
|
Sinks consume `core.TransportEvent` values — a formatted `Payload` plus the
|
||||||
|
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.
|
||||||
|
|
||||||
## Sink Types
|
Registered types: `console`, `file`, `http`, `tcp`, `null`, `tcp_chain`,
|
||||||
|
`http_chain`.
|
||||||
|
|
||||||
### Console Sink
|
Dispatch into a sink is non-blocking. A sink whose input queue is full drops the
|
||||||
|
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 = "console_out"
|
id = "stdout"
|
||||||
type = "console"
|
type = "console"
|
||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
target = "stdout" # stdout|stderr|split
|
target = "stdout"
|
||||||
buffer_size = 1000
|
buffer_size = 1000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Options:**
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `target` | string | "stdout" | Output target (stdout/stderr/split) |
|
| `target` | string | `stdout` | `stdout` or `stderr` |
|
||||||
| `buffer_size` | int | 1000 | Internal buffer size |
|
| `buffer_size` | int | `1000` | Sink input queue depth |
|
||||||
|
|
||||||
**Target Modes:**
|
> `split` is **not** a valid target for this sink and is rejected at startup.
|
||||||
- **stdout**: All output to standard output
|
> Level-based splitting exists only for LogWisp's own application log
|
||||||
- **stderr**: All output to standard error
|
> (`logging.output = "split"`).
|
||||||
- **split**: INFO/DEBUG to stdout, WARN/ERROR to stderr
|
|
||||||
|
|
||||||
### File Sink
|
Payloads are written verbatim; the sink adds no framing. Whether entries are
|
||||||
|
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 = "file_out"
|
id = "archive"
|
||||||
type = "file"
|
type = "file"
|
||||||
[pipelines.plugin_sinks.config]
|
[pipelines.plugin_sinks.config]
|
||||||
directory = "./logs"
|
directory = "/var/log/logwisp"
|
||||||
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 = 500
|
min_disk_free_mb = 0
|
||||||
retention_hours = 168.0
|
retention_hours = 168.0
|
||||||
buffer_size = 1000
|
buffer_size = 1000
|
||||||
flush_interval_ms = 1000
|
flush_interval_ms = 100
|
||||||
```
|
```
|
||||||
|
|
||||||
**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 | Rotation threshold |
|
| `max_size_mb` | int | `100` | Rotate when the active file reaches this size |
|
||||||
| `max_total_size_mb` | int | 1000 | Total size limit |
|
| `max_total_size_mb` | int | `1000` | Cap across all rotated files |
|
||||||
| `min_disk_free_mb` | int | 500 | Minimum free disk space |
|
| `min_disk_free_mb` | int | `0` | Free-space floor before writing; `0` = no floor |
|
||||||
| `retention_hours` | float | 168 | Delete files older than |
|
| `retention_hours` | float | `168.0` | Delete rotated files older than this |
|
||||||
| `buffer_size` | int | 1000 | Internal buffer size |
|
| `buffer_size` | int | `1000` | Sink input queue depth |
|
||||||
| `flush_interval_ms` | int | 1000 | Force flush interval |
|
| `flush_interval_ms` | int | `100` | Forced flush interval |
|
||||||
|
|
||||||
**Features:**
|
> `min_disk_free_mb` has an unusual default. The constructor replaces only
|
||||||
- Automatic rotation on size
|
> *negative* values with `100`; leaving the key unset yields `0`, which means no
|
||||||
- Retention management
|
> free-space floor. Set it explicitly if you want one.
|
||||||
- Disk space monitoring
|
|
||||||
- Periodic flushing
|
|
||||||
|
|
||||||
### HTTP Sink
|
The sink drives an internal writer configured for raw output with timestamps and
|
||||||
|
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 = "http_out"
|
id = "discard"
|
||||||
type = "http"
|
|
||||||
[pipelines.plugin_sinks.config]
|
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 8080
|
|
||||||
stream_path = "/stream"
|
|
||||||
status_path = "/status"
|
|
||||||
buffer_size = 1000
|
|
||||||
client_buffer_size = 256
|
|
||||||
write_timeout_ms = 0
|
|
||||||
max_connections = 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configuration Options:**
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
|
||||||
|--------|------|---------|-------------|
|
|
||||||
| `host` | string | "0.0.0.0" | Bind address |
|
|
||||||
| `port` | int | Required | Listen port |
|
|
||||||
| `stream_path` | string | "/stream" | SSE stream endpoint |
|
|
||||||
| `status_path` | string | "/status" | Status endpoint |
|
|
||||||
| `buffer_size` | int | 1000 | Sink input queue size |
|
|
||||||
| `client_buffer_size` | int | 256 | Per-client send queue size |
|
|
||||||
| `write_timeout_ms` | int | 0 | Write deadline per event (0 = none) |
|
|
||||||
| `max_connections` | int | 0 | Concurrent connection cap (0 = unlimited) |
|
|
||||||
|
|
||||||
### TCP Sink
|
|
||||||
|
|
||||||
TCP streaming server for debugging and raw client forwarding.
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[[pipelines.plugin_sinks]]
|
|
||||||
id = "tcp_out"
|
|
||||||
type = "tcp"
|
|
||||||
[pipelines.plugin_sinks.config]
|
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 9090
|
|
||||||
buffer_size = 1000
|
|
||||||
client_buffer_size = 256
|
|
||||||
write_timeout_ms = 5000
|
|
||||||
keep_alive = true
|
|
||||||
keep_alive_period_ms = 30000
|
|
||||||
max_connections = 0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configuration Options:**
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
|
||||||
|--------|------|---------|-------------|
|
|
||||||
| `host` | string | "0.0.0.0" | Bind address |
|
|
||||||
| `port` | int | Required | Listen port |
|
|
||||||
| `buffer_size` | int | 1000 | Sink input queue size |
|
|
||||||
| `client_buffer_size` | int | 256 | Per-client send queue size |
|
|
||||||
| `write_timeout_ms` | int | 5000 | Write timeout |
|
|
||||||
| `keep_alive` | bool | true | Enable TCP keep-alive |
|
|
||||||
| `keep_alive_period_ms` | int | 30000 | Keep-alive interval |
|
|
||||||
| `max_connections` | int | 0 | Concurrent connection cap (0 = unlimited) |
|
|
||||||
```
|
|
||||||
|
|
||||||
### Null Sink
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[[pipelines.plugin_sinks]]
|
|
||||||
id = "null_out"
|
|
||||||
type = "null"
|
type = "null"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Buffer Management
|
No options. The input queue is fixed at 1000.
|
||||||
|
|
||||||
- Full input buffer: entry dropped for that sink only (counted per pipeline as `total_dropped_by_sink`)"
|
---
|
||||||
|
|
||||||
|
## http
|
||||||
|
|
||||||
|
Server-Sent Events stream plus a JSON status endpoint.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[pipelines.plugin_sinks]]
|
||||||
|
id = "sse"
|
||||||
|
type = "http"
|
||||||
|
[pipelines.plugin_sinks.config]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 8080
|
||||||
|
stream_path = "/stream"
|
||||||
|
status_path = "/status"
|
||||||
|
buffer_size = 1000
|
||||||
|
client_buffer_size = 256
|
||||||
|
write_timeout_ms = 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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only |
|
||||||
|
| `port` | int | **required** | Listen port |
|
||||||
|
| `stream_path` | string | `/stream` | SSE endpoint; must start with `/` |
|
||||||
|
| `status_path` | string | `/status` | Status endpoint; must start with `/` and differ from `stream_path` |
|
||||||
|
| `buffer_size` | int | `1000` | Sink input queue depth |
|
||||||
|
| `client_buffer_size` | int | `256` | Per-client send queue depth |
|
||||||
|
| `write_timeout_ms` | int | `0` | Per-event write deadline; `0` = none |
|
||||||
|
| `max_connections` | int | `0` | Concurrent stream cap; `0` = unlimited |
|
||||||
|
| `tls` | table | — | Listener TLS; see [Security](security.md) |
|
||||||
|
| `auth` | table | — | Client authorization; see [Security](security.md#the-auth-block) |
|
||||||
|
|
||||||
|
**Behaviour**
|
||||||
|
|
||||||
|
- Only `GET` is routed to either path; anything else gets `405`, `HEAD` on
|
||||||
|
`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
|
||||||
|
[[pipelines.plugin_sinks]]
|
||||||
|
id = "tap"
|
||||||
|
type = "tcp"
|
||||||
|
[pipelines.plugin_sinks.config]
|
||||||
|
host = "0.0.0.0"
|
||||||
|
port = 9090
|
||||||
|
buffer_size = 1000
|
||||||
|
client_buffer_size = 256
|
||||||
|
write_timeout_ms = 5000
|
||||||
|
keep_alive = true
|
||||||
|
keep_alive_period_ms = 30000
|
||||||
|
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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only |
|
||||||
|
| `port` | int | **required** | Listen port |
|
||||||
|
| `buffer_size` | int | `1000` | Sink input queue depth |
|
||||||
|
| `client_buffer_size` | int | `256` | Per-client send queue depth |
|
||||||
|
| `write_timeout_ms` | int | `5000` | Per-write deadline |
|
||||||
|
| `keep_alive` | bool | `true` | Enable TCP keep-alive on accepted connections |
|
||||||
|
| `keep_alive_period_ms` | int | `30000` | Keep-alive idle period |
|
||||||
|
| `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**
|
||||||
|
|
||||||
|
- 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
|
||||||
|
[[pipelines.plugin_sinks]]
|
||||||
|
id = "to_relay"
|
||||||
|
type = "tcp_chain"
|
||||||
|
[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 |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `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**
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
All sinks track:
|
Every sink reports: `id`, `type`, `total_processed`, `active_connections`,
|
||||||
- Total entries processed
|
`start_time`, `last_processed`, and a type-specific `details` map.
|
||||||
- Active connections
|
|
||||||
- Failed sends
|
|
||||||
- Retry attempts
|
|
||||||
- Last processed timestamp
|
|
||||||
|
|||||||
+247
-61
@@ -1,107 +1,293 @@
|
|||||||
# Input Sources
|
# Input Sources
|
||||||
|
|
||||||
LogWisp sources monitor various inputs and generate log entries for pipeline processing.
|
Sources produce `core.LogEntry` values for a pipeline. Every source is declared
|
||||||
|
as a `[[pipelines.plugin_sources]]` entry with an `id`, a `type`, and a
|
||||||
## Source Types
|
type-specific `config` table.
|
||||||
|
|
||||||
### Directory Source
|
|
||||||
|
|
||||||
Monitors a directory for log files matching a pattern. (type: `file`)
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[pipelines.plugin_sources]]
|
[[pipelines.plugin_sources]]
|
||||||
id = "file_in"
|
id = "app_logs"
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Options:**
|
Registered types: `file`, `console`, `random`, `null`, `tcp_chain`,
|
||||||
|
`http_chain`.
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
Publication from any source is non-blocking. When a subscriber channel is full
|
||||||
|--------|------|---------|-------------|
|
the entry is dropped and counted in `dropped_entries`.
|
||||||
| `directory` | string | Required | Directory to monitor |
|
|
||||||
| `pattern` | string | "*" | File pattern (glob) |
|
|
||||||
| `check_interval_ms` | int | 100 | File check interval in milliseconds |
|
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
### Stdin Source
|
## file
|
||||||
|
|
||||||
Reads log entries from standard input.
|
Tails every file in a directory whose name matches a glob.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[pipelines.plugin_sources]]
|
[[pipelines.plugin_sources]]
|
||||||
id = "console_in"
|
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 |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `directory` | string | **required** | Directory to scan; not recursive |
|
||||||
|
| `pattern` | string | `*` | Glob over filenames; `*` and `?` only |
|
||||||
|
| `check_interval_ms` | int | `100` | Directory rescan interval; minimum `10` |
|
||||||
|
| `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**
|
||||||
|
|
||||||
|
- `check_interval_ms` governs how often the directory is rescanned for new or
|
||||||
|
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
|
||||||
|
last read time, plus `active_watchers`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## console
|
||||||
|
|
||||||
|
Reads newline-delimited entries from standard input.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[pipelines.plugin_sources]]
|
||||||
|
id = "stdin"
|
||||||
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 | Internal buffer size |
|
| `buffer_size` | int | `1000` | Subscriber channel depth |
|
||||||
|
|
||||||
**Features:**
|
At most **one** instance per pipeline: the type is registered with
|
||||||
- Line-based processing
|
`MaxInstances: 1`, and a second instance is rejected at pipeline construction.
|
||||||
- Automatic level detection
|
The level is inferred from the line text, and `Source` is set to `console`.
|
||||||
- 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 = "random_in"
|
id = "generator"
|
||||||
type = "random"
|
type = "random"
|
||||||
[pipelines.plugin_sources.config]
|
[pipelines.plugin_sources.config]
|
||||||
interval_ms = 500
|
interval_ms = 500
|
||||||
jitter_ms = 0
|
jitter_ms = 0
|
||||||
format = "txt"
|
format = "txt"
|
||||||
length = 20
|
length = 20
|
||||||
special = false
|
special = false
|
||||||
```
|
```
|
||||||
|
|
||||||
**Configuration Options:**
|
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
|--------|------|---------|-------------|
|
||||||
| `interval_ms` | int | 500 | Generation interval |
|
| `interval_ms` | int | `500` | Emission period |
|
||||||
| `jitter_ms` | int | 0 | Random jitter interval |
|
| `jitter_ms` | int | `0` | Symmetric jitter; clamped to `interval_ms`, must be non-negative |
|
||||||
| `format` | string | "txt" | "txt", "json", "raw" |
|
| `format` | string | `txt` | `raw` (message only), `txt` (bracketed line), `json` (JSON object as the message) |
|
||||||
| `length` | int | 20 | Log length |
|
| `length` | int | `20` | Message length in characters |
|
||||||
| `special` | bool | false | Include special characters |
|
| `special` | bool | `false` | Inject control and non-ASCII characters |
|
||||||
|
|
||||||
## Source Statistics
|
`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.
|
||||||
|
|
||||||
All sources track:
|
---
|
||||||
- Total entries received
|
|
||||||
- Dropped entries (buffer full)
|
|
||||||
- Invalid entries
|
|
||||||
- Last entry timestamp
|
|
||||||
- Active connections (network sources)
|
|
||||||
- Source-specific metrics
|
|
||||||
|
|
||||||
### Null Source
|
## null
|
||||||
|
|
||||||
|
Produces nothing. Useful as a placeholder so a sink-only pipeline satisfies the
|
||||||
|
"at least one source" requirement.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[[pipelines.plugin_sources]]
|
[[pipelines.plugin_sources]]
|
||||||
id = "null_in"
|
id = "void"
|
||||||
type = "null"
|
type = "null"
|
||||||
[pipelines.plugin_sources.config]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Buffer Management
|
No options.
|
||||||
|
|
||||||
Each source maintains internal buffers:
|
---
|
||||||
- Default size: 1000 entries
|
|
||||||
- Drop policy when full
|
## tcp_chain
|
||||||
- Configurable per source
|
|
||||||
- Non-blocking writes
|
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 |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `host` | string | `0.0.0.0` | Bind address; IPv4 only |
|
||||||
|
| `port` | int | **required** | Listen port, 1–65535 |
|
||||||
|
| `buffer_size` | int | `1000` | Subscriber channel depth |
|
||||||
|
| `max_connections` | int | `0` | Concurrent connection cap; `0` = unlimited |
|
||||||
|
| `read_timeout_ms` | int | `0` | Per-connection idle read deadline; `0` = none |
|
||||||
|
| `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
|
||||||
|
|
||||||
|
Every source reports: `id`, `type`, `total_entries`, `dropped_entries`,
|
||||||
|
`start_time`, `last_entry_time`, and a type-specific `details` map. These appear
|
||||||
|
in the status reporter output and in the `http` sink's status endpoint.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
module logwisp
|
module logwisp
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.1
|
||||||
|
|
||||||
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.20260717175128-82eea9846ccd
|
github.com/lixenwraith/log v0.1.1-0.20260801090951-2c40643523b3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ 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.20260717175128-82eea9846ccd h1:06Rk4DLvJW1kdeclH5HwywizgtMI64PHNE/2sBeuiwA=
|
github.com/lixenwraith/log v0.1.1-0.20260724174821-e688c5a07207 h1:EnDMcnFkgTwTGo1ojoJ85/b6aH3yxlBUAg6AefaBcrI=
|
||||||
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.20260724174821-e688c5a07207/go.mod h1:fvGT3IxlJQLJ1is4OP8Iet8gVzTJTGcuT90FFB2WBtc=
|
||||||
|
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
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=
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-57
@@ -198,6 +198,8 @@ 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
|
||||||
@@ -208,27 +210,31 @@ 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 {
|
||||||
Host string `toml:"host"`
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
BufferSize int64 `toml:"buffer_size"`
|
Port int64 `toml:"port"`
|
||||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
BufferSize int64 `toml:"buffer_size"`
|
||||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
|
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||||
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
|
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // per-connection idle deadline, 0 = none
|
||||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
|
||||||
// Future: TLS/auth options
|
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// 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 {
|
||||||
Host string `toml:"host"`
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
IngestPath string `toml:"ingest_path"`
|
Port int64 `toml:"port"`
|
||||||
BufferSize int64 `toml:"buffer_size"`
|
IngestPath string `toml:"ingest_path"`
|
||||||
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
|
BufferSize int64 `toml:"buffer_size"`
|
||||||
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
|
MaxBodyBytes int64 `toml:"max_body_bytes"` // per-request cap
|
||||||
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
|
||||||
// Future: TLS/auth options
|
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// Future: password auth block
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sink Options ---
|
// --- Sink Options ---
|
||||||
@@ -273,59 +279,127 @@ 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 {
|
||||||
Host string `toml:"host"`
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
Port int64 `toml:"port"`
|
||||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
|
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||||
KeepAlive bool `toml:"keep_alive"`
|
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-write deadline
|
||||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
KeepAlive bool `toml:"keep_alive"`
|
||||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||||
// Future: TLS (cert_file/key_file/client_ca), auth (token/mTLS) blocks
|
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// 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 {
|
||||||
Host string `toml:"host"`
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
StreamPath string `toml:"stream_path"`
|
Port int64 `toml:"port"`
|
||||||
StatusPath string `toml:"status_path"`
|
StreamPath string `toml:"stream_path"`
|
||||||
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
StatusPath string `toml:"status_path"`
|
||||||
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
BufferSize int64 `toml:"buffer_size"` // sink input queue
|
||||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
|
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
|
||||||
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
|
||||||
// Future: TLS (server.TLSConfig), auth middleware options
|
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// 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 {
|
||||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Host string `toml:"host"`
|
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
BufferSize int64 `toml:"buffer_size"`
|
Port int64 `toml:"port"`
|
||||||
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
|
BufferSize int64 `toml:"buffer_size"`
|
||||||
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
|
DialTimeoutMS int64 `toml:"dial_timeout_ms"`
|
||||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
WriteTimeoutMS int64 `toml:"write_timeout_ms"`
|
||||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||||
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||||
KeepAlive bool `toml:"keep_alive"`
|
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
|
||||||
// Future: TLS/auth options
|
KeepAlive bool `toml:"keep_alive"`
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// 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 {
|
||||||
Node string `toml:"node"` // origin label, default: os.Hostname()
|
TLS *TLSOptions `toml:"tls"`
|
||||||
Host string `toml:"host"`
|
Node string `toml:"node"` // origin label, default: os.Hostname()
|
||||||
Port int64 `toml:"port"`
|
Host string `toml:"host"`
|
||||||
IngestPath string `toml:"ingest_path"`
|
Port int64 `toml:"port"`
|
||||||
BufferSize int64 `toml:"buffer_size"`
|
IngestPath string `toml:"ingest_path"`
|
||||||
MaxBatchCount int64 `toml:"max_batch_count"`
|
BufferSize int64 `toml:"buffer_size"`
|
||||||
MaxBatchBytes int64 `toml:"max_batch_bytes"`
|
MaxBatchCount int64 `toml:"max_batch_count"`
|
||||||
FlushIntervalMS int64 `toml:"flush_interval_ms"`
|
MaxBatchBytes int64 `toml:"max_batch_bytes"`
|
||||||
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
|
FlushIntervalMS int64 `toml:"flush_interval_ms"`
|
||||||
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
RequestTimeoutMS int64 `toml:"request_timeout_ms"` // covers dial + write + response
|
||||||
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
BackoffMinMS int64 `toml:"backoff_min_ms"`
|
||||||
// Future: TLS/auth options
|
BackoffMaxMS int64 `toml:"backoff_max_ms"`
|
||||||
|
Auth *AuthOptions `toml:"auth"`
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ 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
|
||||||
|
|||||||
+30
-43
@@ -90,57 +90,44 @@ 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) {
|
||||||
// Map logwisp LogEntry to formatter args
|
return a.serialize(entry, a.flags), nil
|
||||||
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) {
|
||||||
level := mapLevel(entry.Level)
|
return a.serialize(entry, customFlags), nil
|
||||||
src := sourceLabel(entry)
|
}
|
||||||
|
|
||||||
var args []any
|
// serialize renders an entry under the given flags. The returned slice is a
|
||||||
if len(entry.Fields) > 0 {
|
// copy: the underlying formatter reuses one buffer and sinks retain payloads.
|
||||||
var fields map[string]any
|
func (a *FormatterAdapter) serialize(entry core.LogEntry, flags int64) []byte {
|
||||||
if err := json.Unmarshal(entry.Fields, &fields); err == nil && len(fields) > 0 {
|
args, flags := formatArgs(entry, flags)
|
||||||
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(customFlags, entry.Time, level, src, args))
|
out := bytes.Clone(a.formatter.Format(flags, entry.Time, mapLevel(entry.Level), sourceLabel(entry), args))
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
return out, nil
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -153,11 +153,16 @@ 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, core.CapTLS, core.CapAuth:
|
case core.CapNetLimit:
|
||||||
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:
|
||||||
@@ -169,17 +174,36 @@ 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, core.CapTLS, core.CapAuth:
|
case core.CapNetLimit:
|
||||||
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:
|
||||||
@@ -191,6 +215,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+170
-43
@@ -2,9 +2,11 @@ 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"
|
||||||
@@ -13,11 +15,13 @@ 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"
|
||||||
@@ -63,6 +67,14 @@ 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{}
|
||||||
@@ -70,8 +82,6 @@ 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
|
||||||
@@ -122,6 +132,14 @@ 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,
|
||||||
@@ -133,6 +151,9 @@ 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{})
|
||||||
|
|
||||||
@@ -142,17 +163,29 @@ 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 {
|
||||||
// CapTLS/CapAuth appended when transport security lands
|
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||||
return []core.Capability{
|
if h.tlsConfig != nil {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
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
|
||||||
@@ -163,8 +196,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 extension point: wrap ln with tls.NewListener (or set
|
// TLS is applied via server.TLSConfig + ServeTLS below, not by wrapping
|
||||||
// server.TLSConfig and use ServeTLS); single seam, handlers unchanged.
|
// ln; net/http then owns handshake, ALPN (h2), and per-conn errors.
|
||||||
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)
|
||||||
@@ -174,24 +207,39 @@ 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)
|
||||||
|
|
||||||
// Auth extension point: wrap mux with auth middleware once credentials
|
// One wrapper covers stream and status, and keeps the handlers themselves
|
||||||
// land, e.g. handler = authMiddleware(cfg)(handler)
|
// unaware of authorization
|
||||||
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.
|
||||||
// Per-write deadlines via ResponseController in handleStream.
|
// net/http bounds the TLS handshake by min(ReadHeaderTimeout,
|
||||||
|
// 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 := h.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := 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,
|
||||||
@@ -245,9 +293,9 @@ func (h *HTTPSink) shutdown() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeClient unregisters a client; the first caller closes the send
|
// removeClient unregisters a client; the first caller closes the send channel.
|
||||||
// channel and removes the session. Broker (stale-session eviction) and
|
// Broker (stale-session eviction) and stream handler (disconnect) may race here
|
||||||
// stream handler (disconnect) may race here safely.
|
// safely. The session is the handler's, released when it returns.
|
||||||
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]
|
||||||
@@ -257,7 +305,6 @@ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +359,19 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
rc := http.NewResponseController(w)
|
rc := http.NewResponseController(w)
|
||||||
remote := r.RemoteAddr
|
remote := r.RemoteAddr
|
||||||
|
|
||||||
sess := h.proxy.CreateSession(remote, map[string]any{
|
meta := 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),
|
||||||
@@ -322,20 +379,18 @@ 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",
|
||||||
@@ -360,11 +415,24 @@ 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 {
|
||||||
@@ -372,9 +440,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return // broker evicted (stale session)
|
return // broker evicted (stale session)
|
||||||
}
|
}
|
||||||
if h.writeTimeout > 0 {
|
h.armWrite(rc)
|
||||||
_ = rc.SetWriteDeadline(time.Now().Add(h.writeTimeout))
|
|
||||||
}
|
|
||||||
if err := writeSSE(w, payload); err != nil {
|
if err := writeSSE(w, payload); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -382,6 +448,15 @@ 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:
|
||||||
@@ -392,6 +467,14 @@ 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{
|
||||||
@@ -399,12 +482,17 @@ func (h *HTTPSink) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
"version": version.Short(),
|
"version": version.Short(),
|
||||||
"instance_id": h.id,
|
"instance_id": h.id,
|
||||||
"server": map[string]any{
|
"server": map[string]any{
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"host": h.config.Host,
|
"host": h.config.Host,
|
||||||
"port": h.config.Port,
|
"port": h.config.Port,
|
||||||
"active_clients": h.activeClients.Load(),
|
"tls": h.tlsConfig != nil,
|
||||||
"buffer_size": h.config.BufferSize,
|
"auth": h.auth.Describe(),
|
||||||
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
"active_clients": h.activeClients.Load(),
|
||||||
|
"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()),
|
||||||
},
|
},
|
||||||
"endpoints": map[string]string{
|
"endpoints": map[string]string{
|
||||||
"stream": h.config.StreamPath,
|
"stream": h.config.StreamPath,
|
||||||
@@ -414,6 +502,7 @@ 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(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +513,23 @@ 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",
|
||||||
@@ -431,20 +537,41 @@ 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: map[string]any{
|
Details: details,
|
||||||
"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) {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,12 +16,14 @@ 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"
|
||||||
@@ -54,6 +57,12 @@ 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
|
||||||
@@ -128,6 +137,20 @@ 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{
|
||||||
@@ -139,16 +162,26 @@ func NewHTTPChainSinkPlugin(
|
|||||||
MaxIdleConnsPerHost: 2,
|
MaxIdleConnsPerHost: 2,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
DisableCompression: true,
|
DisableCompression: true,
|
||||||
// Future: TLSClientConfig; HTTP/2 via ALPN once TLS lands
|
TLSClientConfig: tlsCfg, // nil = plaintext
|
||||||
|
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{
|
||||||
id: id,
|
id: id,
|
||||||
proxy: proxy,
|
proxy: proxy,
|
||||||
config: opts,
|
config: opts,
|
||||||
node: node,
|
node: node,
|
||||||
// Future: "https" scheme with TLS
|
tlsEnabled: tlsCfg != nil,
|
||||||
url: "http://" + addr + opts.IngestPath,
|
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
|
||||||
|
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{}),
|
||||||
@@ -171,16 +204,23 @@ 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 {
|
||||||
// CapTLS/CapAuth added when transport security lands
|
caps := []core.Capability{core.CapSessionAware}
|
||||||
return []core.Capability{
|
if t.tlsEnabled {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
}
|
}
|
||||||
|
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
|
||||||
@@ -225,20 +265,24 @@ 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: map[string]any{
|
Details: details,
|
||||||
"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(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-30
@@ -2,19 +2,23 @@ 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"
|
||||||
@@ -58,6 +62,14 @@ 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{}
|
||||||
@@ -65,8 +77,6 @@ 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
|
||||||
@@ -115,6 +125,14 @@ 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,
|
||||||
@@ -126,6 +144,8 @@ 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{})
|
||||||
|
|
||||||
@@ -133,17 +153,29 @@ 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 {
|
||||||
// CapTLS/CapAuth appended when transport security lands
|
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||||
return []core.Capability{
|
if t.tlsConfig != nil {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
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
|
||||||
@@ -151,10 +183,9 @@ func (t *TCPSink) Input() chan<- core.TransportEvent {
|
|||||||
return t.input
|
return t.input
|
||||||
}
|
}
|
||||||
|
|
||||||
// listen creates the server listener.
|
// listen creates the server listener, TLS-wrapped when configured.
|
||||||
// TLS extension point: wrap the returned listener with tls.NewListener here
|
// Handshake is deferred: tls.NewListener conns handshake explicitly in
|
||||||
// once cert config lands; no other code path changes. mTLS peer identity is
|
// handleConn under tlsx.HandshakeTimeout, post max_connections admission.
|
||||||
// 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 {
|
||||||
@@ -164,7 +195,14 @@ func (t *TCPSink) listen() (net.Listener, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// IPv4-only, parity with existing network sinks
|
// IPv4-only, parity with existing network sinks
|
||||||
return lc.Listen(context.Background(), "tcp4", t.addr)
|
ln, err := 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
|
||||||
@@ -249,8 +287,9 @@ func (t *TCPSink) acceptLoop() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth extension point: credential/peer verification runs here,
|
// Certificate authorization runs in handleConn post-handshake,
|
||||||
// pre-registration (password preamble read or TLS peer cert check)
|
// pre-registration. Password-auth extension point: preamble
|
||||||
|
// verification belongs at the same place.
|
||||||
|
|
||||||
t.wg.Add(1)
|
t.wg.Add(1)
|
||||||
go t.handleConn(conn)
|
go t.handleConn(conn)
|
||||||
@@ -263,11 +302,58 @@ func (t *TCPSink) handleConn(conn net.Conn) {
|
|||||||
defer t.wg.Done()
|
defer t.wg.Done()
|
||||||
remote := conn.RemoteAddr().String()
|
remote := conn.RemoteAddr().String()
|
||||||
|
|
||||||
sess := t.proxy.CreateSession(remote, map[string]any{
|
// Counted from accept: max_connections bounds concurrent handshakes too
|
||||||
|
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),
|
||||||
@@ -280,11 +366,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() {
|
||||||
@@ -294,11 +380,6 @@ 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
|
||||||
@@ -377,6 +458,18 @@ 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",
|
||||||
@@ -384,13 +477,6 @@ 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: map[string]any{
|
Details: details,
|
||||||
"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(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ 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"
|
||||||
@@ -12,12 +14,14 @@ 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"
|
||||||
@@ -48,6 +52,10 @@ 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
|
||||||
@@ -122,6 +130,19 @@ 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,
|
||||||
@@ -130,6 +151,8 @@ 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,
|
||||||
@@ -152,16 +175,23 @@ 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 {
|
||||||
// CapTLS/CapAuth added when transport security lands
|
caps := []core.Capability{core.CapSessionAware}
|
||||||
return []core.Capability{
|
if t.tlsConfig != nil {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
}
|
}
|
||||||
|
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
|
||||||
@@ -210,6 +240,17 @@ 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",
|
||||||
@@ -217,14 +258,7 @@ func (t *TCPChainSink) GetStats() sink.SinkStats {
|
|||||||
ActiveConnections: active,
|
ActiveConnections: active,
|
||||||
StartTime: t.startTime,
|
StartTime: t.startTime,
|
||||||
LastProcessed: lastProc,
|
LastProcessed: lastProc,
|
||||||
Details: map[string]any{
|
Details: details,
|
||||||
"target": t.addr,
|
|
||||||
"node": t.node,
|
|
||||||
"connected": t.connected.Load(),
|
|
||||||
"reconnects": t.reconnects.Load(),
|
|
||||||
"write_errors": t.writeErrors.Load(),
|
|
||||||
"synthesized": t.synthesized.Load(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,18 +355,28 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// connect performs a single dial + hello attempt
|
// connect performs a single dial (+ TLS handshake) + hello attempt
|
||||||
func (t *TCPChainSink) connect(ctx context.Context) error {
|
func (t *TCPChainSink) connect(ctx context.Context) error {
|
||||||
d := net.Dialer{Timeout: t.dialTimeout}
|
nd := net.Dialer{Timeout: t.dialTimeout}
|
||||||
if t.config.KeepAlive {
|
if t.config.KeepAlive {
|
||||||
d.KeepAliveConfig = net.KeepAliveConfig{
|
nd.KeepAliveConfig = net.KeepAliveConfig{
|
||||||
Enable: true,
|
Enable: true,
|
||||||
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
|
Idle: time.Duration(t.config.KeepAlivePeriodMS) * time.Millisecond,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4-only
|
var conn net.Conn
|
||||||
conn, err := d.DialContext(ctx, "tcp4", t.addr)
|
var err error
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -353,7 +397,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"logwisp/internal/config"
|
"logwisp/internal/config"
|
||||||
@@ -61,6 +62,7 @@ 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
|
||||||
@@ -90,6 +92,11 @@ 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{
|
||||||
@@ -116,7 +123,9 @@ 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
|
||||||
}
|
}
|
||||||
@@ -261,7 +270,13 @@ func (fs *FileSource) ensureWatcher(path string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w := newFileWatcher(path, fs.publish, fs.logger)
|
w := newFileWatcher(path, fs.config.Raw, fs.config.From == "start", 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",
|
||||||
@@ -284,12 +299,40 @@ func (fs *FileSource) ensureWatcher(path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mu.Lock()
|
fs.removeWatcher(path, w)
|
||||||
delete(fs.watchers, path)
|
|
||||||
fs.mu.Unlock()
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
if fs.watchers[path] == watcher {
|
||||||
|
delete(fs.watchers, path)
|
||||||
|
}
|
||||||
|
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.
|
||||||
func (fs *FileSource) cleanupWatchers() {
|
func (fs *FileSource) cleanupWatchers() {
|
||||||
fs.mu.Lock()
|
fs.mu.Lock()
|
||||||
@@ -361,4 +404,3 @@ func globToRegex(glob string) string {
|
|||||||
regex = strings.ReplaceAll(regex, `\?`, `.`)
|
regex = strings.ReplaceAll(regex, `\?`, `.`)
|
||||||
return "^" + regex + "$"
|
return "^" + regex + "$"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ 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
|
||||||
@@ -41,17 +42,25 @@ 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.
|
||||||
func newFileWatcher(directory string, callback func(core.LogEntry), logger *log.Logger) *fileWatcher {
|
// A start position of 0 reads an existing file whole; -1 seeks to its end.
|
||||||
|
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,
|
||||||
position: -1,
|
raw: raw,
|
||||||
|
position: position,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
w.lastReadTime.Store(time.Time{})
|
w.lastReadTime.Store(time.Time{})
|
||||||
@@ -60,8 +69,8 @@ func newFileWatcher(directory string, callback func(core.LogEntry), logger *log.
|
|||||||
|
|
||||||
// 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.seekToEnd(); err != nil {
|
if err := w.initPosition(); err != nil {
|
||||||
return fmt.Errorf("seekToEnd failed: %w", err)
|
return fmt.Errorf("initPosition failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(core.FileWatcherPollInterval)
|
ticker := time.NewTicker(core.FileWatcherPollInterval)
|
||||||
@@ -73,7 +82,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 fmt.Errorf("watcher stopped")
|
return nil
|
||||||
}
|
}
|
||||||
if err := w.checkFile(); err != nil {
|
if err := w.checkFile(); err != nil {
|
||||||
// Log error but continue watching
|
// Log error but continue watching
|
||||||
@@ -213,6 +222,9 @@ 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()
|
||||||
@@ -297,8 +309,9 @@ func (w *fileWatcher) checkFile() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seekToEnd sets the initial read position to the end of the file
|
// initPosition records the file's metadata and, unless the watcher was created
|
||||||
func (w *fileWatcher) seekToEnd() error {
|
// to read from the start, sets the initial read position to the end
|
||||||
|
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) {
|
||||||
@@ -322,8 +335,6 @@ func (w *fileWatcher) seekToEnd() 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 {
|
||||||
@@ -341,6 +352,23 @@ func (w *fileWatcher) seekToEnd() 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()
|
||||||
@@ -348,36 +376,67 @@ func (w *fileWatcher) isStopped() bool {
|
|||||||
return w.stopped
|
return w.stopped
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseLine attempts to parse a line as JSON, falling back to plain text
|
// parseLine converts a line into an entry, as JSON when nothing would be lost
|
||||||
func (w *fileWatcher) parseLine(line string) core.LogEntry {
|
func (w *fileWatcher) parseLine(line string) core.LogEntry {
|
||||||
var jsonLog struct {
|
if w.raw {
|
||||||
Time string `json:"time"`
|
// Newline restored: sinks write the payload as it stands
|
||||||
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: timestamp,
|
Time: time.Now(),
|
||||||
Source: filepath.Base(w.directory),
|
Source: filepath.Base(w.directory),
|
||||||
Level: jsonLog.Level,
|
Level: source.ExtractLogLevel(line),
|
||||||
Message: jsonLog.Message,
|
Message: line + "\n",
|
||||||
Fields: jsonLog.Fields,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
level := source.ExtractLogLevel(line)
|
if entry, ok := w.parseJSON(line); ok {
|
||||||
|
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: level,
|
Level: source.ExtractLogLevel(line),
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package httpchain
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,12 +15,14 @@ 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"
|
||||||
@@ -49,7 +53,13 @@ type HTTPChainSource struct {
|
|||||||
server *http.Server
|
server *http.Server
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
||||||
// Session cache: one session per remote host + declared node
|
// TLS
|
||||||
|
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
|
||||||
|
|
||||||
@@ -95,6 +105,14 @@ 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,
|
||||||
@@ -103,6 +121,8 @@ 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{})
|
||||||
|
|
||||||
@@ -111,17 +131,36 @@ 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 {
|
||||||
// CapTLS/CapAuth added when transport security lands
|
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||||
return []core.Capability{
|
if s.tlsConfig != nil {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
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
|
||||||
@@ -150,12 +189,19 @@ 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,
|
||||||
// Future: TLSConfig for transport security
|
// TLS handshake bounded by min(ReadTimeout, ReadHeaderTimeout)
|
||||||
|
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 := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := 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,
|
||||||
@@ -204,6 +250,19 @@ 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",
|
||||||
@@ -211,16 +270,7 @@ 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: map[string]any{
|
Details: details,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +279,22 @@ 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)
|
||||||
@@ -239,10 +305,22 @@ 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
|
||||||
}
|
}
|
||||||
connNode := r.Header.Get(chain.HeaderNode)
|
declaredNode := r.Header.Get(chain.HeaderNode)
|
||||||
if connNode == "" || !s.config.TrustNode {
|
connNode, err := s.auth.ResolveNode(declaredNode, remoteHost, s.config.TrustNode, ident)
|
||||||
connNode = remoteHost
|
if err != nil {
|
||||||
|
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)
|
||||||
@@ -254,7 +332,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, s.config.TrustNode)
|
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
|
||||||
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)
|
||||||
@@ -281,15 +359,17 @@ 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))
|
s.proxy.UpdateActivity(s.sessionFor(remoteHost, connNode, r.TLS, ident))
|
||||||
|
|
||||||
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, recreating after idle expiry
|
// sessionFor returns the cached session for a remote+node+identity,
|
||||||
func (s *HTTPChainSource) sessionFor(remoteHost, node string) string {
|
// recreating after idle expiry. Identity is part of the key so two peers
|
||||||
key := remoteHost + "|" + node
|
// sharing a remote address never share a session.
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -298,10 +378,18 @@ func (s *HTTPChainSource) sessionFor(remoteHost, node string) string {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sess := s.proxy.CreateSession(remoteHost, map[string]any{
|
meta := 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,24 @@ package tcpchain
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"crypto/tls"
|
||||||
"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"
|
||||||
@@ -45,6 +48,13 @@ 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
|
||||||
@@ -82,6 +92,14 @@ 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,
|
||||||
@@ -90,6 +108,8 @@ 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{})
|
||||||
|
|
||||||
@@ -97,17 +117,36 @@ 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 {
|
||||||
// CapTLS/CapAuth added when transport security lands
|
caps := []core.Capability{core.CapSessionAware, core.CapMultiSession}
|
||||||
return []core.Capability{
|
if s.tlsConfig != nil {
|
||||||
core.CapSessionAware,
|
caps = append(caps, core.CapTLS)
|
||||||
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
|
||||||
@@ -122,11 +161,15 @@ 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
|
// IPv4-only. TLS-wrapped when configured; handshake runs explicitly in
|
||||||
|
// 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()
|
||||||
@@ -172,6 +215,18 @@ 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",
|
||||||
@@ -179,14 +234,7 @@ 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: map[string]any{
|
Details: details,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +286,35 @@ 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
|
||||||
@@ -261,25 +338,44 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connNode := hello.Node
|
fallbackNode := remote
|
||||||
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)
|
||||||
|
|
||||||
sess := s.proxy.CreateSession(remote, map[string]any{
|
meta := 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 {
|
||||||
@@ -303,7 +399,7 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
|
|||||||
}
|
}
|
||||||
s.proxy.UpdateActivity(sessID)
|
s.proxy.UpdateActivity(sessID)
|
||||||
|
|
||||||
entry, err := chain.DecodeEntry(line, connNode, s.config.TrustNode)
|
entry, err := chain.DecodeEntry(line, connNode, trustEntryNode)
|
||||||
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",
|
||||||
@@ -315,26 +411,6 @@ 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()
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 '"source":"edge-tcp/' <<< "$tcp_out")
|
nt=$(grep -c 'edge-tcp/' <<< "$tcp_out")
|
||||||
nh=$(grep -c '"source":"edge-http/' <<< "$tcp_out")
|
nh=$(grep -c '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:.*"node":"edge-tcp"' <<< "$sse_out")
|
nt=$(grep -c '^data:.*edge-tcp/' <<< "$sse_out")
|
||||||
nh=$(grep -c '^data:.*"node":"edge-http"' <<< "$sse_out")
|
nh=$(grep -c '^data:.*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
@@ -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 '"source":"edge-tcp/' <<< "$tcp_out")
|
n=$(grep -c '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:.*"node":"edge-http"' <<< "$sse_out")
|
n=$(grep -c '^data:.*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
|
||||||
|
|||||||
Executable
+513
@@ -0,0 +1,513 @@
|
|||||||
|
#!/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"
|
||||||
Executable
+92
@@ -0,0 +1,92 @@
|
|||||||
|
#!/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"
|
||||||
Reference in New Issue
Block a user