v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 14:52:16 -04:00
1 parent 9ab8c356d2
commit 71d1b82f83
102 files changed
+7102 -2178

No files matched your search

+31 -20
View File
@@ -7,22 +7,32 @@ streams, or downstream LogWisp nodes.
## Documentation Map
| Document | Contents |
|----------|----------|
| [Installation](installation.md) | Building, installing, and running as a service |
| [Architecture](architecture.md) | Component model, data flow, concurrency, back-pressure |
| [Configuration](configuration.md) | TOML structure, precedence, environment and CLI overrides |
| [Sources](sources.md) | Every input plugin and its options |
| [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 |
| [Password Authentication](scram-auth-plan.md) | Design and rationale for Argon2id-SCRAM authentication; mTLS hardening |
| [CLI](cli.md) | Flags, signals, exit codes, `logwisp auth` |
| [Operations](operations.md) | Running, monitoring, tuning, troubleshooting |
- [Installation](installation.md): building, installing, and running as a
service
- [Deployment](deployment.md): `deploy/lw-deploy.sh` for edges, aggregators,
containers, services, jails
- [Architecture](architecture.md): component model, data flow, concurrency,
back-pressure
- [Configuration](configuration.md): TOML structure, precedence, environment
and CLI overrides
- [Sources](sources.md): every input plugin and its options
- [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
- [Password Authentication](scram-auth-plan.md): design and rationale for
Argon2id-SCRAM authentication; mTLS hardening
- [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)`
manual, [`lw.1`](lw.1)
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
- [To Do](todo.md): planned work in priority order: network access control
and the PROXY protocol, a hardening review of the config libraries,
packaging
A fully annotated configuration covering every option lives at
[`config/logwisp.toml`](../config/logwisp.toml).
@@ -64,7 +74,7 @@ endpoint), `tcp` (broadcast server), `null`, and the chain forwarders
- Authorization by certificate identity, per listener: named peers rather than
everything the CA issued, with the `http` sink's endpoints gated too
- Password authentication (Argon2id-SCRAM) bound to the listener's certificate,
with bearer tokens on HTTP listeners and a `logwisp auth` CLI, and browser
with bearer tokens on HTTP listeners and a `lw auth` CLI, and browser
logins behind a TLS-terminating proxy
- Node binding, so a chain source labels entries from the sender's certificate
identity or username rather than from what the sender claims
@@ -96,16 +106,17 @@ target = "stdout"
```
```bash
logwisp -c config.toml
lw -c config.toml
```
## System Requirements
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
- **Architecture**: amd64
- **Go**: 1.26+ to build from source
- **Go**: 1.27.1+ to build from source
Network sources and sinks bind and dial over IPv4 only.
Network sources and sinks bind and dial IPv4 or IPv6, each keeping strictly to
the family of its host ([Networking](networking.md#address-family)).
## License
+62 -50
View File
@@ -25,29 +25,33 @@ main
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 |
- `cmd/lw`: 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 the `lixenwraith/log` formatter and
sanitizer.
- `internal/source/*`, `internal/sink/*`: the source and 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/authz`: the single seam between `AuthOptions` and the network
plugins: `Admit`, `AuthorizeRequest`, `Greet`, `Prepare`.
- `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
`cmd/lw/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.
@@ -71,15 +75,14 @@ registry rejects a second instance of any such type.
`LogEntry` fields:
| Field | Purpose |
|-------|---------|
| `Time` | Entry timestamp |
| `Node` | Origin node label for chained topologies; stamped at the first hop, preserved by relays |
| `Source` | Origin identifier within the node (filename, plugin id, …) |
| `Level` | `DEBUG`/`INFO`/`WARN`/`ERROR`/`TRACE`, when detected |
| `Message` | Log content |
| `Fields` | Optional structured metadata as raw JSON |
| `RawSize` | Original byte size, used by the entry-size cap |
- `Time`: entry timestamp.
- `Node`: origin node label for chained topologies; stamped at the first hop,
preserved by relays.
- `Source`: origin identifier within the node (filename, plugin id, …).
- `Level`: `DEBUG`, `INFO`, `WARN`, `ERROR` or `TRACE`, when detected.
- `Message`: log content.
- `Fields`: optional structured metadata as raw JSON.
- `RawSize`: original byte size, used by the entry-size cap.
Carrying `Entry` alongside `Payload` is what makes chain sinks
format-independent: a `tcp_chain` or `http_chain` sink re-serializes the
@@ -88,13 +91,16 @@ structured entry rather than shipping whatever text the local formatter chose.
### Back-pressure and drops
There is exactly one drop policy and it is not configurable: **never block**.
Each stage drops and counts instead of waiting:
| 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` |
- Source → subscriber: a full channel drops the entry; source
`dropped_entries`.
- Flow: the rate limiter, a filter rejection or a format error drops it;
`flow.total_dropped`.
- Pipeline → sink: a full sink input drops it for that sink only; pipeline
`total_dropped_by_sink`.
- TCP/HTTP sink → client queue: a full queue drops it for that client only;
sink `dropped_writes`.
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
@@ -126,17 +132,20 @@ on a non-retryable response or on shutdown (`dropped_batches`).
## Network Architecture
All listeners bind `tcp4` and all dialers dial `tcp4`. IPv6 clients cannot
connect; this is deliberate, not an oversight.
Each listener and dialer keeps strictly to the family of its host literal:
`tcp4` for IPv4, IPv6-only `tcp6` for IPv6 (`::` too); a hostname resolves.
See [Networking](networking.md#address-family).
| Plugin | Role | Protocol |
|--------|------|----------|
| `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 |
The network plugins, by role:
- Listeners
- `tcp` sink: raw broadcast of formatted payloads.
- `http` sink: HTTP/1.1 SSE; HTTP/2 negotiated via ALPN when TLS is on.
- `tcp_chain` source: chain protocol, persistent NDJSON stream.
- `http_chain` source: chain protocol, NDJSON batches over POST.
- Dialers
- `tcp_chain` sink: chain protocol, persistent stream, auto-reconnect.
- `http_chain` sink: chain protocol, batched POST with retry.
TLS is built in exactly one place, `internal/tlsx`, which exposes
`Server(opts)` for listeners and `Client(opts, host)` for dialers. See
@@ -176,17 +185,20 @@ Reload (signal or file watch) rebuilds the entire service:
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.
Watch errors do not rebuild services. Queued changes are combined, and an unchanged
pipeline/status snapshot is skipped. Signals always rebuild for certificate rotation.
Failed candidates release their session workers. Once the old service has stopped,
a bind/start failure requires another corrected reload or process restart.
Watch errors do not rebuild services. Queued changes are combined, and an
unchanged pipeline/status snapshot is skipped. Signals always rebuild for
certificate rotation. Failed candidates release their session workers. Once the
old service has stopped, a bind/start failure requires another corrected reload
or process restart.
## Resource 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.
- The `tcp` and `http` sinks and the `tcp_chain` source 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`.
+29 -24
View File
@@ -36,24 +36,25 @@ Chained entries carry a `node` label identifying where they originated.
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 |
- `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.
With an `auth` block the source can instead bind the label to the sender's
authenticated identity — its certificate identity under `mtls`, its username
under `scram` — which overrides `trust_node` entirely:
under `scram`. `assert` and `force` take the connection label over from
`trust_node`; only `force` also overrides the per-entry labels.
`auth.node_binding`:
| `auth.node_binding` | Connection label | Per-entry `node` field |
|---------------------|------------------|------------------------|
| `none` | `trust_node` governs | `trust_node` governs |
| `assert` | Must equal the identity, or the peer is rejected | `trust_node` governs |
| `force` (default with an `auth` block) | The identity | Overwritten with the identity |
- `none`: `trust_node` governs the connection label and the per-entry `node`
field.
- `assert`: the connection label must equal the identity, or the peer is
rejected; `trust_node` governs the per-entry `node` field.
- `force` (default with an `auth` block): the identity is the connection label
and overwrites every per-entry `node` field.
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
@@ -116,13 +117,13 @@ binary ignores it and the dialer gives up after 10 s.
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` |
| `Authorization` | request | `Bearer <token>`, under `scram` |
| `X-Logwisp-Accepted` | response | Number of entries ingested |
- Request headers
- `X-Logwisp-Protocol`: the protocol version; must be `1`.
- `X-Logwisp-Node`: the origin node label.
- `Content-Type`: `application/x-ndjson`.
- `Authorization`: `Bearer <token>`, under `scram`.
- Response header
- `X-Logwisp-Accepted`: the 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, `401`
@@ -152,10 +153,14 @@ 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` and, under `scram`, failed logins and `401`; drops on any other non-2xx (`dropped_batches`) |
- `tcp_chain`: per line, each held across reconnects.
- On failure it retries with exponential backoff plus ±20 % jitter until
the line is written or the sink shuts down.
- Back-pressure appears upstream as `total_dropped_by_sink`.
- `http_chain`: at least once per batch.
- It retries transport errors, `408`, `429`, `5xx` and, under `scram`, failed
logins and `401`.
- It drops the batch on any other non-2xx, counted in `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
@@ -266,5 +271,5 @@ optional, and entries are labelled with the username. See
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`.
- **Dial the listener's own literal** when testing locally: a listener on
`127.0.0.1` takes IPv4 only, and `localhost` may resolve to `::1`.
+177 -86
View File
@@ -1,19 +1,21 @@
# Command Line Interface
```
logwisp [options]
logwisp auth <command> [flags]
logwisp help | -h | --help
logwisp --version
lw [options]
lw --check [options]
lw auth <command> [flags]
lw help | -h | --help
lw --version
```
`logwisp auth` manages SCRAM credentials and logs in to `scram` listeners; see
[below](#logwisp-auth). There is no certificate-generation command: use
`lw auth` manages SCRAM credentials and logs in to `scram` listeners; see
[below](#lw-auth). There is no certificate-generation command: use
`openssl` or your PKI tooling — see [Security](security.md#enabling-mtls).
## Options
Any scalar configuration key is settable as a flag using its TOML path:
Any scalar configuration key is settable as a flag using its TOML path;
pipelines have [their own flags](#pipelines):
```
--<path>=<value> e.g. --logging.level=debug
@@ -23,49 +25,92 @@ Any scalar configuration key is settable as a flag using its TOML path:
### Common
| Flag | Description | Default |
|------|-------------|---------|
| `-c <path>` | Configuration file | `./logwisp.toml` |
| `--config <path>` / `--config=<path>` | Configuration file | `./logwisp.toml` |
| `--quiet` | Suppress all application output | `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 | — |
- `-c <path>`, `--config <path>`, `--config=<path>`: the configuration file,
default `./logwisp.toml`
- `--check`: build every pipeline and plugin, bind nothing, report and exit;
see [Usage Patterns](#usage-patterns)
- `--quiet`: suppress all application output, default `false`
- `--status_reporter=<bool>`: periodic status logging, default `true`
- `--auto_reload=<bool>`: reload when the configuration file changes, default
`false`
- `--version`: print the version and exit
- `-h`, `--help`, `help`: print usage and exit
The last file-selection flag wins. `-c=<path>` also works. A missing or empty
path returns an error, and `--` ends option parsing.
### Logging
| 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 |
- `--logging.output`: `file`, `stdout`, `stderr`, `split`, `all` or `none`
- `--logging.level`: `debug`, `info`, `warn` or `error`
- `--logging.format`: `raw`, `txt` or `json`
- `--logging.sanitization`: `raw`, `json`, `txt` or `shell`
- `--logging.file.directory`: a path
- `--logging.file.name`: a string
- `--logging.file.max_size_mb`, `--logging.file.max_total_size_mb`: integers
- `--logging.file.retention_hours`: a float
`--logging.console.target` is accepted but has no effect; the console
destination is derived from `--logging.output`.
### Pipelines
Pipelines, sources, sinks, and filters **cannot** be configured from the command
line. Array-indexed paths such as `--pipelines.0.name=app` or
`--pipelines.0.plugin_sinks.0.type=null` are reported as unrecognized and
ignored:
Pipeline flags define whole pipelines. They replace the configuration file's
pipelines and the built-in default; every other key keeps its precedence.
- `--pipeline NAME` starts a pipeline; specs before the first one go to a
pipeline named `cli`
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- both `--flag SPEC` and `--flag=SPEC` work; `--` ends options
A SPEC is a comma-separated list:
- source, sink, filter and format specs start with a TYPE
- the plugin type: [Sources](sources.md), [Sinks](sinks.md)
- `include` or `exclude`: [Filters](filters.md)
- `json`, `txt` or `raw`: [Formatters](formatters.md)
- rate-limit and heartbeat specs have no TYPE
- the rest are `key=value` pairs with the TOML keys of the plugin's `config`
table or of the [flow stage](configuration.md#flow-stages)
- a dotted key reaches a nested table: `tls.cert_file=...`, `auth.type=scram`
- a repeated key makes a list: `patterns=ERROR,patterns=WARN`
- each value is one list entry, commas included: `auth.allow_patterns=^a{1\,3}$`
is one regex, `auth.allow=a,auth.allow=b` two entries
- values convert to the option's type: `port=8080`, `raw=true`
- `\` escapes `,`, `=` and `\` in a value; any other backslash stays, so regex
escapes such as `\d` pass unchanged
- `id=NAME` names a source or sink; the default is its TYPE, then `TYPE_2`,
`TYPE_3`, ...
- naming a stage turns it on: `--rate-limit` defaults `policy` to `drop`,
`--heartbeat` defaults `enabled` to `true`
Errors name the flag and the offending part, and nothing starts:
```
Warning: unrecognized flags ignored: [pipelines.0.name]
--sink http,port: missing "="
--rate-limit rate=100,polcy=drop: unknown key "polcy"
```
Use a configuration file. Older documentation described CLI pipeline overrides
that the current loader does not implement.
A misspelled plugin key fails when the plugin is built, before any listener
opens: `failed to create sink http: ... unknown key "tls.enabeld"`.
```bash
# tail a directory, serve it over SSE
lw --source 'file,directory=/var/log/app,pattern=*.log' \
--sink http,host=0.0.0.0,port=8080
# errors and warnings as JSON, over TLS with SCRAM logins
lw --source file,directory=/var/log/app \
--filter include,patterns=ERROR,patterns=WARN --format json \
--sink "http,port=8443,auth.type=scram,auth.credentials_file=/etc/logwisp/users.toml,\
tls.enabled=true,tls.cert_file=/etc/logwisp/server.crt,tls.key_file=/etc/logwisp/server.key"
# two pipelines
lw --pipeline app --source file,directory=/var/log/app --sink console \
--pipeline relay --source tcp_chain,port=9000 \
--sink file,directory=/var/log/relay,name=relay
```
## Environment Variables
@@ -83,28 +128,54 @@ forms when upgrading; see [Configuration](configuration.md#environment-variables
The path resolver reads these variables directly:
| 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` |
- `LOGWISP_CONFIG_FILE`: the configuration file path, joined onto
`LOGWISP_CONFIG_DIR` when both are set
- `LOGWISP_CONFIG_DIR`: the configuration directory; alone, it implies
`<dir>/logwisp.toml`
As with flags, array elements cannot be set this way.
### Pipeline Variables
One pipeline can come from the environment, with the [SPEC](#pipelines) syntax.
Any pipeline flag on the command line makes LogWisp ignore all of them.
- `LOGWISP_PIPELINE`: the pipeline's name, default `cli`
- `LOGWISP_SOURCE`, `LOGWISP_SINK`, `LOGWISP_FILTER`: one stage each
- `LOGWISP_SOURCE_1` .. `LOGWISP_SOURCE_N` add more, after the unnumbered
one in numeric order; likewise `LOGWISP_SINK_N` and `LOGWISP_FILTER_N`
- `LOGWISP_FORMAT`, `LOGWISP_RATE_LIMIT`, `LOGWISP_HEARTBEAT`
- an empty variable counts as unset
A container needs no configuration file:
```bash
docker run --rm -p 8080:8080 -v /var/log/app:/logs:ro \
-e LOGWISP_PIPELINE=app \
-e LOGWISP_SOURCE='file,directory=/logs,pattern=*.log' \
-e LOGWISP_FILTER='exclude,patterns=DEBUG' \
-e LOGWISP_SINK='http,host=0.0.0.0,port=8080' \
-e LOGWISP_SINK_1=console \
logwisp
```
## Precedence
1. Command-line flags
2. Environment variables
3. Configuration file
4. Built-in defaults
- Pipelines
- pipeline flags, if any are given
- else pipeline variables, if any are set
- else the configuration file's `[[pipelines]]`
- else the built-in default
- Every other key
1. command-line flags
2. environment variables
3. configuration file
4. built-in defaults
A reload rereads the file and keeps the command-line or environment pipelines.
## Signals
| Signal | Action |
|--------|--------|
| `SIGINT` | Graceful shutdown |
| `SIGTERM` | Graceful shutdown |
| `SIGHUP` | Reload configuration |
| `SIGUSR1` | Reload configuration |
- `SIGINT`, `SIGTERM`: graceful shutdown
- `SIGHUP`, `SIGUSR1`: reload the configuration
`SIGHUP` is ignored during startup, before the signal handler is installed, so
LogWisp survives a terminal hang-up like `nohup`. Once running, it triggers a
@@ -115,11 +186,10 @@ 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 |
- `0`: clean shutdown, `--version` / `--help`, or a valid `--check`
- `1`: general error: a configuration load or validation failure (`--check`
included), a logger init failure, a service bootstrap failure
- `2`: an explicitly requested configuration file is not found
Exit code 2 applies only when the file was named explicitly (`-c`,
`--config=`, or the `LOGWISP_CONFIG_*` variables). A missing discovered default
@@ -133,8 +203,9 @@ 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.
As soon as the file defines `[[pipelines]]`, or pipeline flags or variables are
given, that entire default pipeline — rate limit included — is replaced rather
than merged.
## Usage Patterns
@@ -142,26 +213,29 @@ pipeline — rate limit included — is replaced rather than merged.
```bash
# verbose, everything to stderr
logwisp -c dev.toml --logging.output=stderr --logging.level=debug
lw -c dev.toml --logging.output=stderr --logging.level=debug
# no config at all: synthetic generator to stdout
logwisp
lw
```
**Configuration check**
```bash
# starts the service; a config error exits non-zero before any pipeline runs
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug
```
`lw --check` validates without running: it builds every pipeline and plugin as
a start would (options, TLS files, credentials files, startup warnings), but
binds no port and opens, reads or creates no log file; it prints
`configuration ok` and exits 0, or the error and exits 1 (2 when a named file
is missing, see [Exit Codes](#exit-codes)):
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.
```bash
lw --check -c /etc/logwisp/logwisp.toml
lw --check --source file,directory=/var/log/app --sink http,port=8080
```
**Production**
```bash
logwisp -c /etc/logwisp/logwisp.toml --logging.output=file
lw -c /etc/logwisp/logwisp.toml --logging.output=file
```
Run under a supervisor (systemd, rc.d) rather than backgrounding it — there is
@@ -171,29 +245,38 @@ no `--background` flag; earlier releases had one and it was removed. See
**Reload**
```bash
kill -HUP $(pidof logwisp)
kill -USR1 $(pidof logwisp)
kill -HUP $(pidof lw)
kill -USR1 $(pidof lw)
```
## `logwisp auth`
## `lw auth`
Manages the credentials files of `auth.type = "scram"` listeners and logs in to
them as a viewer. See
[Password Authentication](security.md#password-authentication-scram).
| Command | Does |
|---------|------|
| `add-user -credentials FILE -user NAME [-password-file FILE] [-generate]` | Adds a user, or replaces its password |
| `remove-user -credentials FILE -user NAME` | Removes a user; refuses the last one |
| `token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE [-unbound] [TLS flags]` | Logs in to an `http` sink or `http_chain` source and prints a bearer token |
| `stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]` | Logs in to a `tcp` sink and copies its stream to stdout until interrupted |
```
lw auth add-user -credentials FILE -user NAME [-password-file FILE] [-generate]
lw auth remove-user -credentials FILE -user NAME
lw auth token -url https://HOST:PORT[/PATH] -user NAME -password-file FILE
[-unbound] [TLS flags]
lw auth stream -addr HOST:PORT -user NAME -password-file FILE [TLS flags]
```
`logwisp auth <command> -h` lists a command's flags. The exit status is `0` on
- `add-user`: adds a user, or replaces its password
- `remove-user`: removes a user; refuses the last one
- `token`: logs in to an `http` sink or `http_chain` source and prints a
bearer token
- `stream`: logs in to a `tcp` sink and copies its stream to stdout until
interrupted
`lw auth <command> -h` lists a command's flags. The exit status is `0` on
success, `1` on failure and `2` on a usage error.
**`add-user`** creates the credentials file, with a fresh `decoy_key`, when it
does not exist or is empty (create it empty first to choose its owner). The password comes from `-password-file` when that file exists
(at least 8 bytes; one trailing line break is trimmed). Otherwise a random
does not exist or is empty (create it empty first to choose its owner). The
password comes from `-password-file` when that file exists (at least 8 bytes;
one trailing line break is trimmed). Otherwise a random
26-character password (130 bits) is generated and written to `-password-file`,
or printed once to stdout when there is none. Replacing an existing user's
password needs an existing `-password-file` or `-generate`, so a mistyped path
@@ -215,29 +298,37 @@ There is no flag to skip verification: an unverified server could relay the
login. Redirects are not followed. `-unbound` logs in to an `http` sink behind
a TLS-terminating proxy (`auth.trusted_proxies`), still pinning the proxy's
certificate across the two requests; only then may `-url` carry the path the
proxy mounts LogWisp at. `stream` exits `0`
on `SIGINT` or `SIGTERM`, and `1` when the server ends the stream (a reload or
shutdown).
proxy mounts LogWisp at. `stream` exits `0` on `SIGINT` or `SIGTERM`, and `1`
when the server ends the stream (a reload or shutdown).
Addresses, as for the plugins ([Networking](networking.md#address-family)):
- An IPv6 address goes in brackets: `-addr [::1]:PORT`,
`-url https://[::1]:PORT`; in a URL a link-local zone is escaped,
`https://[fe80::1%25eth0]:PORT`.
- The dial keeps to the address's family; a hostname resolves.
- `-server-name` defaults to the host; an address, without brackets or zone,
must then be among the certificate's IP SANs.
```bash
# listener host: a file the service user can read, users, then apply
install -m 0640 -o root -g logwisp /dev/null /etc/logwisp/users.toml
logwisp auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass
logwisp auth add-user -credentials /etc/logwisp/users.toml -user viewer \
lw auth add-user -credentials /etc/logwisp/users.toml -user viewer \
-password-file viewer.pass
kill -HUP $(pidof logwisp)
kill -HUP $(pidof lw)
# rotate: new password into the file; SIGHUP, deploy the file, SIGHUP the edge
logwisp auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
lw auth add-user -credentials /etc/logwisp/users.toml -user edge-01 \
-password-file /etc/logwisp/edge-01.pass -generate
# http sink status, with the token kept out of curl's argv
curl --cacert ca.crt -H @<(printf 'Authorization: Bearer %s\n' \
"$(logwisp auth token -url https://HOST:PORT -user viewer \
"$(lw auth token -url https://HOST:PORT -user viewer \
-password-file viewer.pass -ca-file ca.crt)") https://HOST:PORT/status
# follow a tcp sink
logwisp auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
lw auth stream -addr HOST:PORT -user viewer -password-file viewer.pass \
-ca-file ca.crt
```
+94 -77
View File
@@ -5,16 +5,18 @@ and its default ships as [`config/logwisp.toml`](../config/logwisp.toml).
## Configuration Precedence
Sources are merged in this order, highest priority first:
1. Command-line flags
2. Environment variables
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.
- Pipelines come from the first of these that defines any, replacing the rest
wholesale (the default pipeline's rate limit and formatter included):
1. pipeline flags: `--source`, `--sink`, ... ([CLI](cli.md#pipelines))
2. pipeline variables: `LOGWISP_SOURCE`, ...
([CLI](cli.md#pipeline-variables))
3. the file's `[[pipelines]]`
4. the built-in default pipeline
- Every other key merges, highest priority first:
1. command-line flags
2. environment variables
3. configuration file
4. built-in defaults
## File Location
@@ -36,11 +38,14 @@ runtime metadata and cannot be redirected by a `config_file` key inside the file
## Global Settings
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `quiet` | bool | `false` | Disable all application logging and console diagnostics |
| `status_reporter` | bool | `true` | Emit a periodic status report every 30 s at DEBUG level |
| `auto_reload` | bool | `false` | Watch the config file and reload pipelines on change |
Top-level keys, each as type and default:
- `quiet` (bool, `false`): disable all application logging and console
diagnostics.
- `status_reporter` (bool, `true`): emit a status report every 30 s at DEBUG
level.
- `auto_reload` (bool, `false`): watch the config file and reload pipelines on
change.
`--version` prints version information and exits; it is not a persistent
setting.
@@ -69,14 +74,12 @@ retention_hours = 168.0
### Output modes
| Mode | Behaviour |
|------|-----------|
| `file` | Files only |
| `stdout` | Standard output only |
| `stderr` | Standard error only |
| `split` | DEBUG/INFO to stdout, WARN/ERROR to stderr |
| `all` | Files plus split console |
| `none` | No application logging |
- `file`: files only.
- `stdout`: standard output only.
- `stderr`: standard error only.
- `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.
@@ -131,32 +134,34 @@ 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) |
- `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 |
Every stage is optional:
- `flow.rate_limit`: see [Rate limiting](#rate-limiting).
- `flow.filters`: see [Filters](filters.md).
- `flow.format`: defaults to `raw`; see [Formatters](formatters.md).
- `flow.heartbeat`: see [Heartbeat](#heartbeat).
#### 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 |
Options, each as type and default:
- `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:
@@ -167,13 +172,16 @@ Two behaviours are easy to trip over:
#### 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` |
Options, each as type and default:
- `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` are accepted but have no
effect: heartbeats go through the flow's `format`, like every entry.
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
@@ -186,14 +194,14 @@ suppress them, and they reach every sink in the pipeline.
## Environment Variables
Environment overrides are derived from the TOML path: `.` becomes `_`, the
result is uppercased, and `LOGWISP_` is prepended.
result is uppercased, and `LOGWISP_` is prepended:
| TOML path | Environment variable |
|-----------|---------------------|
| `quiet` | `LOGWISP_QUIET` |
| `status_reporter` | `LOGWISP_STATUS_REPORTER` |
| `logging.level` | `LOGWISP_LOGGING_LEVEL` |
| `logging.file.directory` | `LOGWISP_LOGGING_FILE_DIRECTORY` |
```
quiet LOGWISP_QUIET
status_reporter LOGWISP_STATUS_REPORTER
logging.level LOGWISP_LOGGING_LEVEL
logging.file.directory LOGWISP_LOGGING_FILE_DIRECTORY
```
Migration: the old custom transform accidentally read bare names such as `QUIET`
and `LOGGING_LEVEL`. Rename those variables to their prefixed forms; bare names
@@ -201,16 +209,17 @@ are now ignored. `LOGWISP_CONFIG_FILE` and `LOGWISP_CONFIG_DIR` still select the
file directly.
Only scalar paths that exist in the configuration schema can be set this way.
Array elements cannot: `LOGWISP_PIPELINES_0_NAME` has no effect.
Array elements cannot: `LOGWISP_PIPELINES_0_NAME` has no effect. A whole
pipeline can: see [pipeline variables](cli.md#pipeline-variables).
## Command-Line Overrides
Any scalar configuration path is settable as a flag using its TOML path:
```bash
logwisp --logging.level=debug --status_reporter=false
logwisp --logging.level debug # space form also works
logwisp --quiet # bare flag means true
lw --logging.level=debug --status_reporter=false
lw --logging.level debug # space form also works
lw --quiet # bare flag means true
```
Unrecognized flags are reported on stderr before the logger exists and are then
@@ -220,11 +229,8 @@ ignored:
Warning: unrecognized flags ignored: [pipelines.0.name]
```
> Array-indexed paths are **not** settable from the command line.
> `--pipelines.0.name=x`, `--pipelines.0.plugin_sinks.0.type=null`, and similar
> flags are reported as unrecognized and ignored. Pipelines, sources, sinks, and
> filters can only be defined in the configuration file. Older documentation
> claimed otherwise.
Array-indexed paths such as `--pipelines.0.name=x` are unrecognized; whole
pipelines have [their own flags](cli.md#pipelines).
## Validation
@@ -236,6 +242,8 @@ Startup validation is intentionally split.
`config` table: `config file "…": unknown key
"pipelines[0].plugin_sinks[0].confg"`. A misspelled table path would otherwise
drop the whole table it heads. A top-level `config_file` is ignored
- pipeline [specs](cli.md#pipelines): syntax, and unknown flow-stage keys such
as `--rate-limit rate=1,polcy=drop`
- at least one pipeline
- unique, non-empty pipeline names
- at least one source and one sink per pipeline
@@ -265,7 +273,11 @@ auto_reload = true
or send `SIGHUP` / `SIGUSR1`. Signals reread the selected file even when watching
is disabled and always rebuild, allowing certificate and credentials rotation
without TOML edits; `auto_reload` watches only the configuration file.
CLI and environment overrides are captured at startup and keep their precedence.
CLI and environment overrides, pipelines included, are captured at startup and
keep their precedence. As at startup, a discovered default file that does not
exist is not an error, so an instance configured by flags or variables alone
still rebuilds. Once read, though, its removal fails the reload and the running
service stays as it is; restart to run without the file.
Reload rebuilds the whole service: a new service is constructed from the new
configuration first, and only if that succeeds is the old one shut down. Each
@@ -279,25 +291,30 @@ without rebuilding. Queued path changes are combined, and unchanged pipelines
and status settings do not trigger another rebuild. Removed file keys fall back
to the remaining sources on the next successful load.
| Reloaded | Not reloaded |
|----------|--------------|
| Pipelines, sources, sinks | `logging.*` (applied once at startup) |
| Filters, formatters, rate limits, heartbeats | `quiet` |
| `status_reporter` | `auto_reload` (the watcher is not restarted) |
What a reload applies:
- Reloaded:
- pipelines, sources, sinks
- filters, formatters, rate limits, heartbeats
- `status_reporter`
- Not reloaded:
- `logging.*`: applied once at startup
- `quiet`
- `auto_reload`: the watcher is not restarted
Because the rebuild is total, listeners close and reopen and every connected
client is disconnected. Chain sinks reconnect on their own backoff schedule.
## Type Reference
| TOML type | Go type | Command-line / environment form |
|-----------|---------|-------------------------------|
| String | `string` | Plain text |
| Integer | `int64` | Decimal string |
| Float | `float64` | Decimal string |
| Boolean | `bool` | `true` / `false`, or a bare flag for `true` |
| Array | `[]T` | Not settable outside the file |
| Table | struct | Nested path with `.` (flags) or `_` (environment) |
Each TOML type, its Go type, and its command-line / environment form:
- String (`string`): plain text.
- Integer (`int64`): decimal string.
- Float (`float64`): decimal string.
- Boolean (`bool`): `true` / `false`, or a bare flag for `true`.
- Array (`[]T`): only in [pipeline specs](cli.md#pipelines), as a repeated key.
- Table (struct): nested path with `.` (flags) or `_` (environment).
Integer fields reject fractions, overflow and negative unsigned values. Non-finite
floats and integer-to-float precision loss are rejected too. This applies to plugin
+198
View File
@@ -0,0 +1,198 @@
# Deployment
`deploy/lw-deploy.sh` configures and deploys one LogWisp node per run. It asks
for what its flags leave open, prints a plan with the equivalent non-interactive
command, then writes the files, runs `lw --check` on them (the node's binary or
image, with its mounts) and starts the node. `--dry-run` stops at printing;
`--yes` never asks and fails on a missing required value. It creates no
certificates: see [Security](security.md#enabling-mtls).
## Roles
- `edge`
- tails each `--log-dir` (not recursive) for `--log-pattern` (default `*.log`)
- forwards to `--aggregator` over `tcp_chain` (port 9440) or, with
`--chain http`, `http_chain` (port 9441)
- labels its entries `--node`: the short host name, or the jail name
- `aggregator`
- one chain listener on `--listen` (default `0.0.0.0`) and `--chain-port`
- outputs, at least one: `--file-dir` (rotating `--file-name`.log),
`--http-port` (SSE `/stream`, JSON `/status`), `--tcp-port`
- `--format` `txt` (default), `json` or `raw`
- `standalone`
- the edge's sources and the aggregator's outputs, no chain
## Runtimes
- `docker`, `podman` (Linux)
- the container runs `--read-only --cap-drop ALL --security-opt
no-new-privileges -u 65532:65532 --restart unless-stopped`
- `--config-dir` (default `/etc/logwisp/NAME`) holds `logwisp.toml`, `tls/`,
and the credentials files, owned by 65532 and mounted read-only at
`/etc/logwisp`
- log directories mount read-only and the output directory writable, each at
its host path; `--log-group` adds that group's id to the container
- listener ports publish on `--listen`, an IP address; the process inside
binds `0.0.0.0`
- `--network NET` joins a user network, created when missing, where edges
reach an aggregator by container name; `host` publishes nothing
- `--image` defaults to `logwisp:dev`, the `make image` tag; `--build` builds
it from the repository. Behind an HTTPS-intercepting proxy add
`--build-ca FILE` and, for a proxy on loopback,
`--build-flags "--network host"`; the environment's proxy variables pass
through as build arguments
- rootful engines need root to give the files to 65532; rootless podman maps
the caller to 65532 with `--userns keep-id`
- SELinux hosts must label the mounted directories for containers; the
script does not relabel, since `:z` on a shared directory such as
`/var/log` would relabel it for every other service too
- `native`, Linux
- needs systemd; installs `lw` to `/usr/local/bin` unless one is installed
(`--bin FILE`, or `--build` for `make build`), and
`deploy/package/logwisp.service`, `logwisp.sysusers` and
`logwisp.tmpfiles` under `/etc`
- a package's unit (`/usr/lib/systemd/system/logwisp.service`) is used as it
is, with the `lw` its `ExecStart` runs; `--bin` and `--build` then stop the
run, since the service would never run that binary
- writes `/etc/logwisp/logwisp.toml`, `root:logwisp`, mode 0640
- refuses log and output directories under `/tmp` and `/var/tmp`: the unit's
`PrivateTmp=yes` gives the service its own, empty ones
- adds `logwisp.service.d/deploy.conf` when the node needs it:
`ReadWritePaths` for an output outside `/var/log/logwisp`,
`ProtectHome=read-only` for logs or output under `/home`,
`SupplementaryGroups` for `--log-group`, `CAP_NET_BIND_SERVICE` for a port
below 1024
- `native`, FreeBSD
- installs `deploy/package/logwisp.rc` as `/usr/local/etc/rc.d/logwisp`,
creates the `logwisp` user with `pw`, enables it with `sysrc`; LogWisp logs
to syslog under the tag `logwisp`
- writes `/usr/local/etc/logwisp/logwisp.toml`
- `--jail NAME` writes the files and runs the commands through `jexec`, so
the jail's own symlinks cannot redirect a write onto the host; the jail
must be running, and the script prints a `jail.conf` example when it is not
- a non-root user binds a port below 1024 only with
`sysctl net.inet.ip.portrange.reservedhigh` lowered or `mac_portacl`
- `manual`
- writes into `--config-dir`, an absolute path (default `~/logwisp-ROLE`),
adds users with the local `lw`, and prints the `lw -c` command
FreeBSD runs no Docker, and podman there runs FreeBSD images only while the
LogWisp image is Linux: on FreeBSD the script offers `native` instead, on the
host or in a jail. A `native` install needs a host of its `--os`; for another
one, `--dry-run` shows the install and `--runtime manual` writes the
configuration.
Re-running the same command updates the node: an existing `logwisp.toml` is
kept as `logwisp.toml.bak`, the container is replaced, generated passwords are
reused. A user already in a credentials file whose password file is missing
stops the run before any change, instead of getting a new password that would
lock its edge out. Under `--yes`, replacing a configuration or a container
needs `--force`.
## Security options
- `--tls` secures every network link of the node; the files are copied into
the configuration directory as `tls/node.crt`, `tls/node.key`, `tls/ca.crt`
- listeners present `--cert-file` and `--key-file`
- an edge verifies the aggregator with `--ca-file`, or the system roots; the
certificate must name the address dialed (an IP SAN for an IP), or the
name given as `--server-name`
- `--auth` on the chain link
- `mtls`: edges present `--cert-file`; the aggregator admits the `--allow`
CNs (none: any certificate the CA issued); an edge's `--allow` pins the
aggregator's CN
- `scram`: the aggregator creates each `--add-user`, its password generated
into `--secrets-dir`, an absolute path (default `~/logwisp-secrets`), and
handed to `lw auth add-user`, natively or through the image; copy each
password file to its edge as `--password-file`, whose name without
`.pass` is the default `--username`
- either binds the node label to the authenticated identity
- `--sink-auth` on the `http` and `tcp` outputs
- `mtls` with `--sink-allow`, or `scram` with `--add-viewer` (a separate
`viewers.toml`); viewers log in with `lw auth token` and `lw auth stream`
- without it the outputs admit anyone who reaches them: bind `--listen` to a
trusted address
- without `--auth` or `--sink-auth`, the auth flags imply their mode:
`--allow` and `--sink-allow` mtls; `--add-user`, `--username`,
`--password-file` and `--add-viewer` scram. Under `--yes` one that does not
apply, such as `--add-user` with `--auth none` or without `--tls`, stops the
run rather than deploy the node open
## FreeBSD host, jails and one aggregator
Application jails run an edge each; a `logs` jail aggregates. Every jail runs
already, and the `logs` jail has an address the others reach (`AGG_ADDR`).
1. Certificates: a CA, and a server certificate for `logs` naming `AGG_ADDR`
([Security](security.md#enabling-mtls)).
2. The aggregator, from the host:
```sh
deploy/lw-deploy.sh --yes --runtime native --jail logs --role aggregator \
--file-dir /var/log/logwisp --http-port 8080 \
--tls --cert-file logs.crt --key-file logs.key \
--auth scram --add-user web1 --add-user db1 \
--sink-auth scram --add-viewer ops
```
The passwords land in `~/logwisp-secrets/`: `web1.pass`, `db1.pass` and
`ops.pass`.
3. An edge per application jail:
```sh
deploy/lw-deploy.sh --yes --runtime native --jail web1 --role edge \
--log-dir /var/log/nginx --aggregator AGG_ADDR \
--tls --ca-file ca.crt --auth scram --password-file ~/logwisp-secrets/web1.pass
```
4. Check: `jexec logs service logwisp status`, the files in the `logs` jail's
`/var/log/logwisp`, and `https://AGG_ADDR:8080/status` with a token from
`lw auth token -url https://AGG_ADDR:8080 -user ops -password-file
~/logwisp-secrets/ops.pass -ca-file ca.crt` ([CLI](cli.md#lw-auth)).
Variants:
- the aggregator on the host itself: drop `--jail logs`
- no daemon per jail: one `standalone` or `edge` on the host tails each jail's
logs through the host path (`--log-dir JAIL_PATH/var/log`); the entries then
carry the host's label and the bare file name, so files of the same name in
two jails cannot be told apart
- a Linux aggregator: run step 2 with `--runtime docker` on that host and point
the edges at it; the chain protocol is the same
## Linux host with containers
An aggregator container and an edge container per application, on one user
network:
```sh
deploy/lw-deploy.sh --yes --runtime docker --role aggregator --network logwisp \
--file-dir /srv/logwisp --http-port 8080 --listen 127.0.0.1
deploy/lw-deploy.sh --yes --runtime docker --role edge --name logwisp-edge-app \
--network logwisp --aggregator logwisp-aggregator --log-dir /var/log/app
```
`--listen 127.0.0.1` keeps the published ports on loopback; edges on other
hosts need it on an address they reach. `docker kill -s HUP NAME` reloads an
edited configuration or credentials file.
## Not covered
Edit the generated `logwisp.toml` for these; `config/logwisp.toml` documents
every key.
- filters, rate limits, heartbeat, formatter flags and timestamp layouts
- more than one pipeline; both chain transports, or a further hop, on one node
- proxy mode: browser logins through a TLS-terminating proxy
- network ACLs: not implemented in LogWisp yet
- mtls identities other than the certificate CN; `node_binding` other than
`force`
- creating certificates
- several native instances on one host: use one per host or jail
## Testing the script
`test/deploy-test.sh --auto` drives the script with `--yes` and `--dry-run` for
the manual, native and docker runtimes, runs the generated edge, aggregator and
standalone nodes end to end, and checks that `--yes` fails closed and that
`--dry-run` changes nothing.
+15 -19
View File
@@ -12,11 +12,11 @@ patterns = ["ERROR", "WARN"]
## Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `include` | `include` (only matches pass) or `exclude` (matches are dropped) |
| `logic` | string | `or` | `or` (any pattern matches) or `and` (every pattern matches) |
| `patterns` | []string | `[]` | Go RE2 regular expressions |
- `type`, string, default `include`: `include` (only matches pass) or
`exclude` (matches are dropped).
- `logic`, string, default `or`: `or` (any pattern matches) or `and` (every
pattern matches).
- `patterns`, []string, default `[]`: 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.
@@ -33,10 +33,8 @@ 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 |
- `"^app\\.log "` matches entries whose source is `app.log`.
- `"ERROR"` matches level `ERROR`, or the word `ERROR` anywhere in the message.
The structured `fields` payload is **not** part of the match text.
@@ -121,15 +119,13 @@ so later ones evaluate fewer entries.
Go's RE2 syntax. No backreferences and no lookaround — RE2 guarantees linear
time, which is exactly what you want in a log hot path.
| Need | Pattern |
|------|---------|
| Literal substring | `ERROR` |
| Case-insensitive | `(?i)error` |
| Whole word | `\\berror\\b` |
| Alternation | `ERROR\|WARN\|FATAL` |
| Character class | `[0-9]{3}` |
| Anchors | `^ERROR`, `ERROR$` |
| Any characters | `.*exception.*` |
- Literal substring: `ERROR`
- Case-insensitive: `(?i)error`
- Whole word: `\berror\b`
- Alternation: `ERROR|WARN|FATAL`
- Character class: `[0-9]{3}`
- Anchors: `^ERROR`, `ERROR$`
- Any characters: `.*exception.*`
Remember that TOML basic strings process escapes, so a regex backslash needs
doubling: `"\\berror\\b"`. TOML literal strings avoid the issue:
@@ -137,7 +133,7 @@ doubling: `"\\berror\\b"`. TOML literal strings avoid the issue:
Anchors apply to the assembled match text, which begins with the source name —
so `^ERROR` will not match an entry whose source is non-empty. Use
`\\bERROR\\b` instead unless you mean to anchor on the source.
`\bERROR\b` instead unless you mean to anchor on the source.
## Common Recipes
+40 -30
View File
@@ -19,12 +19,14 @@ 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"` |
Each as type and default:
- `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, `time.RFC3339Nano`): Go reference layout, e.g.
`"2006-01-02T15:04:05Z07:00"`.
## Types
@@ -86,33 +88,37 @@ LogWisp's own configuration surface.
`flags` is a bitmask passed to the underlying formatter. Leave it at `0` unless
you need to override the defaults.
| Value | Name | Effect |
|-------|------|--------|
| `1` | Raw | Bypass formatting and sanitization entirely |
| `2` | ShowTimestamp | Emit the timestamp |
| `4` | ShowLevel | Emit the level |
| `8` | StructuredJSON | Render attached fields as a JSON object |
| `16` | NoTimestamp | Suppress the timestamp |
| `32` | NoLevel | Suppress the level |
- `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.
`txt` and `json` show timestamp and level unless a suppress bit says otherwise
(suppress wins over show), so `2` and `4` alone change nothing. Examples:
`flags = 16` for level only, no timestamp; `flags = 32` 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 |
Each policy, then what to use it 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"`.
- `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
@@ -129,7 +135,8 @@ 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:
renders it as a syslog-style prefix on the source field (`trace` in `json`
output):
```
edge-01/app.log
@@ -149,13 +156,16 @@ from the heartbeat generator when `include_stats = true`.
## Choosing a Configuration
| Goal | Configuration |
|------|---------------|
| Maximum throughput, data already formatted | `type = "raw"` |
| Human reading in a terminal or file | `type = "txt"`, `sanitizer_policy = "txt"` |
| Downstream ingestion (Loki, Elasticsearch, jq) | `type = "json"`, `sanitizer_policy = "json"` |
| Compact console output | `type = "txt"`, `flags = 4` |
| Untrusted log content | never `raw`; pick `txt` or `json` and set the matching policy |
By goal:
- 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 = 16`.
- Untrusted log content: never `raw`; pick `txt` or `json` and set the
matching policy.
## Formatting and Chain Links
+253 -180
View File
@@ -2,231 +2,304 @@
## Requirements
- **Operating systems**: Linux (kernel 6.10+), FreeBSD (14.0+)
- **Architecture**: amd64
- **Go**: 1.27.1 or newer, to build from source
- Operating systems: Linux (kernel 6.10+), FreeBSD (14.0+)
- Architecture: amd64; arm64 builds but is untested
- To build: Go 1.27.1 or newer (FreeBSD: the `go127` package), and GNU make
or BSD make
- For `make e2e`: bash 5+, coreutils, curl, openssl and go (the proxy test
builds its reverse proxy). Optional, else those checks SKIP: node with
playwright and the Chromium builds `playwright install chromium` fetches
(browser checks), an IPv6 loopback (`ipv6-test.sh`), docker as root with the
`logwisp:dev` image (`deploy-test.sh`'s container run)
## Building from Source
```bash
git clone https://github.com/lixenwraith/logwisp.git
cd logwisp
make
sudo make install # installs to $PREFIX/bin, default /usr/local/bin
make build # bin/lw; plain `make` only lists the targets
```
The Makefile works with both GNU make and BSD make. Targets:
The Makefile works with GNU make and BSD make alike. Targets:
| 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 |
- Build
- `make build`: `bin/lw`, with version, commit and build time from git
- `make release`: the same, static (`CGO_ENABLED=0`), `-trimpath`, stripped
- `make dev`: built with the race detector
- `make version`: the metadata a build would embed
- `make clean`: remove `bin/`
- Check
- `make test`: the Go tests
- `make verify`: tests, `go vet`, `gofmt -l` on the Go files changed since
`main`, linux and freebsd cross builds for amd64 and arm64, and the web
client's `node --test` when node is installed
- `make e2e`: builds, then runs every `test/*-test.sh --auto` in turn and
reports passes, failures and skips; `E2E='test/scram-*-test.sh'` runs a
subset; see [Test Scripts](#test-scripts)
- Container
- `make image`, `make image-check`: see [Container Image](#container-image)
- Install
- `make install`, `make uninstall`: see [Installing](#installing)
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:
Variables:
- `GO`: the toolchain. On FreeBSD the default is the versioned package that
`go.mod` asks for (`go127`); `make GO=/path/to/go` overrides it, and
`check-go` names the package to install when it is missing.
- `GO_BUILDFLAGS`, `GO_LDFLAGS`: extra `go build` and linker flags for
packagers. They are distinct from `GOFLAGS` and `LDFLAGS`, which the go
command and the C linker read themselves.
- `SOURCE_DATE_EPOCH`: when set, the embedded build time comes from it, so
package builds are reproducible.
A plain `go build -o bin/lw ./cmd/lw` works too, and reports `dev` for the
version metadata. `go install github.com/lixenwraith/logwisp/cmd/lw@latest`
does not work until the module is renamed to its canonical path.
## Installing
`make install` copies what a package ships and compiles nothing, so build
first. It honours `DESTDIR`, `PREFIX`, `SYSCONFDIR` and `BINDIR` (default
`$PREFIX/bin`, the path the systemd unit and the rc.d script run `lw` from):
- Linux defaults: `PREFIX=/usr`, `SYSCONFDIR=/etc`. Outside a package
manager, prefer `PREFIX=/usr/local`; systemd reads units, sysusers and
tmpfiles from there too.
- FreeBSD defaults: `PREFIX=/usr/local`, `SYSCONFDIR=/usr/local/etc`.
- `INSTALL_OS` (default: `uname -s`) picks the layout, so
`INSTALL_OS=FreeBSD DESTDIR=...` stages the FreeBSD one on another system.
```bash
go build -o bin/logwisp ./cmd/logwisp
make release
sudo make install PREFIX=/usr/local
```
`go install github.com/lixenwraith/logwisp/cmd/logwisp@latest` also works, with
the same loss of version metadata.
Every system gets:
## 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>` (also `=<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.
- `$BINDIR/lw` and the manual `$PREFIX/share/man/man1/lw.1`
- `$PREFIX/share/doc/logwisp/` (this documentation) and
`$PREFIX/share/licenses/logwisp/LICENSE`
### Linux (systemd)
`/etc/systemd/system/logwisp.service`:
```ini
[Unit]
Description=LogWisp Log Transport Service
After=network.target
[Service]
Type=simple
User=logwisp
Group=logwisp
ExecStart=/usr/local/bin/logwisp -c /etc/logwisp/logwisp.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=10
WorkingDirectory=/var/lib/logwisp
StandardOutput=journal
StandardError=journal
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log/logwisp /var/lib/logwisp
[Install]
WantedBy=multi-user.target
```
`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:
- `$SYSCONFDIR/logwisp/logwisp.toml`: the annotated sample configuration,
kept when one is already there. Edit it before starting the service.
- `$PREFIX/lib/systemd/system/logwisp.service`: runs
`lw -c $SYSCONFDIR/logwisp/logwisp.toml` as the `logwisp` user, from
`/var/lib/logwisp`; `systemctl reload` sends `SIGHUP`.
- `$PREFIX/lib/sysusers.d/logwisp.conf`: the `logwisp` account.
- `$PREFIX/lib/tmpfiles.d/logwisp.conf`: `/var/lib/logwisp` and
`/var/log/logwisp`, owned by it.
```bash
sudo useradd -r -s /usr/sbin/nologin logwisp
sudo mkdir -p /etc/logwisp /var/lib/logwisp /var/log/logwisp
sudo chown logwisp:logwisp /var/lib/logwisp /var/log/logwisp
sudo systemd-sysusers logwisp.conf
sudo systemd-tmpfiles --create logwisp.conf
sudo systemctl daemon-reload
sudo systemctl enable --now 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.
The unit is hardened: no capabilities, a read-only view of the system with
only its two directories writable, `/home` hidden, and IPv4, IPv6 and Unix
sockets only. Widen it with a drop-in (`systemctl edit logwisp`) rather than
editing the unit:
- A `file` sink or `logging.file` elsewhere: `ReadWritePaths=/srv/logs`
- A `file` source under `/home`: `ProtectHome=read-only`
- A port below 1024: `CapabilityBoundingSet=CAP_NET_BIND_SERVICE` and
`AmbientCapabilities=CAP_NET_BIND_SERVICE`
The service must read every key, credentials and password file it names.
Create a credentials file with its owner and mode first; `lw auth` keeps
both:
```bash
sudo install -m 0600 -o logwisp -g logwisp /dev/null /etc/logwisp/users.toml
sudo lw auth add-user -credentials /etc/logwisp/users.toml -user <name> \
-password-file <file>
sudo systemctl reload logwisp
```
### FreeBSD (rc.d)
`/usr/local/etc/rc.d/logwisp`:
```sh
#!/bin/sh
# PROVIDE: logwisp
# REQUIRE: DAEMON NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="logwisp"
rcvar="${name}_enable"
pidfile="/var/run/${name}.pid"
procname="/usr/local/bin/logwisp"
command="/usr/sbin/daemon"
command_args="-p ${pidfile} -f ${procname} -c /usr/local/etc/logwisp/logwisp.toml"
load_rc_config $name
: ${logwisp_enable:="NO"}
run_rc_command "$1"
```
Setup:
- `$SYSCONFDIR/logwisp/logwisp.toml.sample`, copied to `logwisp.toml` when
none exists and `DESTDIR` is empty
- `$PREFIX/etc/rc.d/logwisp`: runs `lw` through `daemon(8)` as
`logwisp_user`, with its output in syslog under the tag `logwisp`;
`service logwisp reload` sends `SIGHUP`
```bash
sudo chmod +x /usr/local/etc/rc.d/logwisp
sudo pw useradd logwisp -d /nonexistent -s /usr/sbin/nologin
sudo mkdir -p /usr/local/etc/logwisp /var/log/logwisp
sudo chown logwisp:logwisp /var/log/logwisp
sudo sysrc logwisp_enable="YES"
sudo pw useradd logwisp -d /nonexistent -s /usr/sbin/nologin -c "LogWisp log transport"
sudo sysrc logwisp_enable=YES
sudo service logwisp start
```
## Directory Layout
rc.conf variables:
| Purpose | Linux | FreeBSD |
|---------|-------|---------|
| Binary | `/usr/local/bin/logwisp` | `/usr/local/bin/logwisp` |
| Configuration | `/etc/logwisp/` | `/usr/local/etc/logwisp/` |
| TLS material | `/etc/logwisp/tls/` | `/usr/local/etc/logwisp/tls/` |
| Working directory | `/var/lib/logwisp/` | `/var/db/logwisp/` |
| Application logs | `/var/log/logwisp/` | `/var/log/logwisp/` |
- `logwisp_enable`: `YES` to start at boot (default `NO`)
- `logwisp_config`: the configuration (default
`/usr/local/etc/logwisp/logwisp.toml`)
- `logwisp_user`: the account (default `logwisp`)
- `logwisp_chdir`: the working directory (default `/var/db/logwisp`, created
for `logwisp_user` when missing)
- `logwisp_args`: extra `lw` options, e.g. `--logging.level=debug` (default
empty); `logwisp_flags` goes to `daemon(8)`, not to `lw`
Key files should be mode `0600` and owned by the service account.
### Uninstall
`sudo make uninstall` (with the `PREFIX` used to install) removes everything
`make install` wrote except `$SYSCONFDIR/logwisp`, which holds configuration
and credentials. Remove that, the working directories and the account by
hand.
## Container Image
The `Dockerfile` builds `lw` in a Go builder and copies it alone, static and
stripped, into `scratch`: `/lw` running as UID 65532, plus the CA bundle that
TLS dialers without a `ca_file` verify against. There is no shell and no
configuration in the image.
```bash
make image # logwisp:dev; IMAGE=, IMAGE_TAG= rename it
make image-check
```
`image-check` runs the image the way it should run in production: read-only,
`--network none`, `--cap-drop ALL`, `no-new-privileges`, as 65532. It prints
`--version`, then runs `--check` on `config/logwisp.toml` (or
`IMAGE_CHECK_CONFIG`) mounted read-only.
Building behind a proxy:
- A TLS-intercepting proxy: `make image BUILD_CA=/path/to/ca-bundle.pem`.
The bundle reaches only the module download, as a BuildKit secret.
- An explicit proxy: pass Docker's predefined proxy build arguments, which
never reach an image layer, e.g.
`IMAGE_BUILD_FLAGS='--network host --build-arg HTTPS_PROXY=http://127.0.0.1:<port>'`.
- `CONTAINER_ENGINE=podman` works too.
### Running it securely
```bash
docker run -d --name logwisp \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-v /etc/logwisp:/etc/logwisp:ro \
-v /srv/logwisp/secrets:/run/secrets:ro \
-p 8443:8443 \
-e LOGWISP_LOGGING_LEVEL=info \
logwisp:<tag> -c /etc/logwisp/logwisp.toml
```
- Configuration: mount it read-only at `/etc/logwisp` and name it with `-c`.
The files must be readable by UID 65532.
- Scalar settings: environment variables, `LOGWISP_<PATH>`.
- Pipelines without a file: the pipeline variables, in the `--source` and
`--sink` syntax (see [CLI](cli.md)); they replace the file's pipelines:
```bash
-e LOGWISP_PIPELINE=relay \
-e LOGWISP_SOURCE=tcp_chain,port=9000,tls.enabled=true,tls.cert_file=/run/secrets/tls.crt,tls.key_file=/run/secrets/tls.key,auth.type=scram,auth.credentials_file=/run/secrets/users.toml \
-e LOGWISP_SINK=console
```
- Secrets: only as mounted files, at `/run/secrets` (Docker or Compose
secrets, a Kubernetes secret volume), named by `tls.key_file`,
`auth.credentials_file` and `auth.password_file`. Never put a password in
the environment: `docker inspect` and `/proc` show it. Make each file
readable by 65532 only (`chown 65532` and mode `0400`).
- Privileges: keep the image's non-root user, a read-only root filesystem,
`--cap-drop ALL` and `no-new-privileges`. Without capabilities a listener
needs a port of 1024 or above; publish it on any host port.
- Listeners bind `0.0.0.0` inside the container; `127.0.0.1` would be
unreachable through `-p`.
- Writable data: a `file` sink or `logging.file` needs a volume writable by
65532; nothing else is written.
- Reload: `docker kill -s HUP logwisp`.
- `lw auth` runs from the image too, with directories writable by 65532.
Keep the generated passwords out of the directory the server mounts: it
needs only `users.toml` (verifiers) and the TLS files, while a `.pass` file
is the client's plaintext password.
```bash
docker run --rm --read-only --cap-drop ALL --network none \
-v /srv/logwisp/secrets:/work -v /srv/logwisp/passwords:/pw logwisp:<tag> \
auth add-user -credentials /work/users.toml -user <name> -password-file /pw/<name>.pass
```
## Packaging Status
The foundation for distribution packages is in place:
- the `lw` binary name, and `make install` with `DESTDIR`, `PREFIX` and
`SYSCONFDIR`
- the `doc/lw.1` manual
- service files in `deploy/package/`: `logwisp.service`, `logwisp.sysusers`,
`logwisp.tmpfiles` and the FreeBSD `logwisp.rc`
- skeletons: `deploy/package/arch/PKGBUILD` and a `sysutils/logwisp` port in
`deploy/package/freebsd/`
Still missing: tagged, signed release tarballs; the module rename to
`github.com/lixenwraith/logwisp`, which `go install` and FreeBSD's
`go:modules` need; finishing and submitting the AUR package and the port;
Debian packaging. [To Do](todo.md) has the steps.
## Verification
```bash
logwisp --version
# start in the foreground with debug logging and watch pipelines come up
logwisp -c /etc/logwisp/logwisp.toml --logging.level=debug --logging.output=stderr
lw --version
lw -c /etc/logwisp/logwisp.toml --logging.level=debug --logging.output=stderr
sudo systemctl status logwisp # Linux
sudo service logwisp status # FreeBSD
```
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).
Expect `Created source instance`, `Created sink instance` and
`Starting pipeline` for each pipeline. To validate a configuration without
starting it, run `lw --check -c FILE`; see
[Operations](operations.md#checking-a-configuration).
## Test Scripts
End-to-end scripts under `test/` run against a local build:
The end-to-end scripts in `test/` run against `bin/lw` and share
`test/lib.sh`. `make e2e` runs each with `--auto` and reports it as PASS, FAIL
or SKIP (exit 77, e.g. no IPv6 loopback); only a failure fails the target.
```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/scram-chain-test.sh --auto # SCRAM logins on every network plugin
./test/passthrough-test.sh # file source relays a wide envelope intact
- Modes:
- without flags: start the daemons, print a guide of the ports and one-line
commands (run `. test/run-X/env` first for `$LW`, `$CA` and the token
helper), and wait for Ctrl-C
- `--auto`: run the checks and tear down; `--keep` leaves the daemons up
after a pass
- `--follow`: show only WARN, ERROR and key events from the daemons
- Daemons log only to `test/run*/log/NAME.out`, never to the terminal.
- Colour only on a terminal; `NO_COLOR` turns it off, `FORCE_COLOR=1` on.
- Exit status: 0 all passed, 1 a failure, 77 skipped.
- Each script owns a port range and a gitignored run directory:
```
15801-15804 chain-test.sh, chain-aggregate-test.sh test/run/
(no ports) passthrough-test.sh test/run/passthrough/
15811-15814 mtls-chain-test.sh test/run-mtls/
15821-15825 scram-chain-test.sh test/run-scram/
15831-15832 scram-proxy-test.sh test/run-proxy/
15851-15855 ipv6-test.sh test/run-ipv6/
15871-15879 deploy-test.sh test/run-deploy/
```
Without `--auto` the chain scripts run the relay in the foreground for
interactive inspection. They need bash 5+, coreutils, and curl (the mTLS and
SCRAM scripts also openssl). The plain chain scripts bind ports 15801–15804 and
write to `test/run/`; the mTLS script uses 15811–15814 and `test/run-mtls/`, the
SCRAM script 15821–15825 and `test/run-scram/`. The pass-through test binds
nothing.
## Uninstall
### Linux
```bash
sudo systemctl disable --now logwisp
sudo rm /usr/local/bin/logwisp /etc/systemd/system/logwisp.service
sudo systemctl daemon-reload
sudo rm -rf /etc/logwisp /var/lib/logwisp /var/log/logwisp
sudo userdel logwisp
```
### FreeBSD
```bash
sudo service logwisp stop
sudo sysrc -x logwisp_enable
sudo rm /usr/local/bin/logwisp /usr/local/etc/rc.d/logwisp
sudo rm -rf /usr/local/etc/logwisp /var/db/logwisp /var/log/logwisp
sudo pw userdel logwisp
```
- `chain-test.sh`: two edges into a relay, one pipeline per chain transport;
ports 15801-15804
- `chain-aggregate-test.sh`: the same edges, with the relay fanning both into
both sinks; ports 15801-15804
- `passthrough-test.sh`: `file` source to `file` sink, a wide envelope byte
for byte (`raw = true`) and, parsed, with no key dropped; no ports
- `mtls-chain-test.sh`: mTLS chain edges and viewers, allow lists, node
binding; ports 15811-15814
- `scram-chain-test.sh`: SCRAM chain edges and viewers with `lw auth`
credentials; ports 15821-15825
- `scram-proxy-test.sh`: browser and CLI logins to an `http` sink behind a
TLS-terminating proxy; ports 15831-15832; the browser checks skip without
node and playwright's Chromium: the headless shell for the scenarios with
cookies, full Chromium for the profile that blocks them
- `ipv6-test.sh`: a relay on `::1` over both chain transports, IPv6 SANs,
per-family listeners; ports 15851-15855; skips (exit 77) without an IPv6
loopback
- `deploy-test.sh`: the configurations `deploy/lw-deploy.sh` writes, deployed
and checked; ports 15871-15879
+328
View File
@@ -0,0 +1,328 @@
.Dd October 2, 2026
.Dt LW 1
.Os
.Sh NAME
.Nm lw
.Nd LogWisp log transport
.Sh SYNOPSIS
.Nm
.Op Fl \-check
.Op Fl c Ar file
.Op Fl \-quiet
.Op Fl \- Ns Ar key Ns = Ns Ar value ...
.Op Fl \-pipeline Ar name
.Op Fl \-source Ar spec ...
.Op Fl \-sink Ar spec ...
.Op Fl \-filter Ar spec ...
.Op Fl \-format Ar spec
.Op Fl \-rate\-limit Ar spec
.Op Fl \-heartbeat Ar spec
.Nm
.Cm auth
.Ar command
.Op Ar flags
.Nm
.Fl \-version
.Nm
.Cm help | Fl h | Fl \-help
.Sh DESCRIPTION
.Nm
runs LogWisp: independent pipelines, each reading entries from sources,
passing them through a flow of rate limiting, filters and formatting, and
writing them to sinks.
Sources tail files, read standard input or accept other LogWisp nodes;
sinks write to the console or files, serve live streams over HTTP or TCP, or
forward to other nodes.
Every network source and sink can use TLS, mutual TLS and
Argon2id-SCRAM password authentication.
.Pp
.Nm
stays in the foreground and logs to standard output; run it under a
supervisor such as
.Xr systemd 1
or
.Xr daemon 8 .
With no configuration file it runs a demonstration pipeline: a synthetic
generator writing JSON to standard output.
.Pp
The options are as follows:
.Bl -tag -width Ds
.It Fl c Ar file , Fl \-config Ar file , Fl \-config= Ns Ar file
Read the configuration from
.Ar file ,
which must exist.
The last one given wins.
.It Fl \- Ns Ar key Ns = Ns Ar value , Fl \- Ns Ar key Ar value
Set any scalar configuration key by its TOML path, such as
.Fl \-logging.level=debug .
A bare
.Fl \- Ns Ar key
sets a boolean to true.
.It Fl \-quiet
Suppress console output.
.It Fl \-status_reporter= Ns Ar bool
Log pipeline statistics every 30 seconds at the debug level
.Pq default true .
.It Fl \-auto_reload= Ns Ar bool
Reload when the configuration file changes
.Pq default false .
.It Fl \-logging.output= Ns Ar mode
One of
.Cm file , stdout , stderr , split , all
or
.Cm none .
.It Fl \-logging.level= Ns Ar level
One of
.Cm debug , info , warn
or
.Cm error .
.It Fl \-check
Build every pipeline and plugin as a start would, loading options, TLS files
and credentials files and printing startup warnings, without binding a port
or opening, reading or creating a log file; print
.Dq configuration ok
and exit 0, or the error and exit 1, or 2 when a named configuration file is
missing
.Pq see Sx EXIT STATUS .
.It Fl \-version
Print the version, commit and build time, and exit.
.It Fl h , Fl \-help , Cm help
Print usage and exit.
.It Fl \-
End option processing.
.El
.Ss Pipeline specifications
Pipelines can be given on the command line or in the environment instead of
the configuration file.
They then replace the file's pipelines and the built-in default; every other
key keeps its usual precedence.
A reload rereads the file but keeps these pipelines.
.Bl -tag -width Ds
.It Fl \-pipeline Ar name
Start a new pipeline.
Specifications before the first
.Fl \-pipeline
belong to a pipeline named
.Dq cli .
.It Fl \-source Ar spec , Fl \-sink Ar spec , Fl \-filter Ar spec
Add a source, sink or filter; each is repeatable.
.It Fl \-format Ar spec , Fl \-rate\-limit Ar spec , Fl \-heartbeat Ar spec
Set the pipeline's formatter, rate limit and heartbeat.
.El
.Pp
A source, sink, filter or format
.Ar spec
is a type followed by comma-separated
.Ar key Ns = Ns Ar value
pairs; a rate limit or heartbeat
.Ar spec
has the pairs only:
.Bd -literal -offset indent
\-\-source file,directory=/var/log/app,pattern=*.log
\-\-sink http,host=127.0.0.1,port=8080,id=web
\-\-filter include,patterns=ERROR,patterns=WARN
\-\-format json
\-\-rate\-limit rate=100,burst=200,policy=drop
.Ed
.Pp
Keys are dotted paths into the plugin's options, such as
.Cm tls.cert_file .
A repeated key makes a list.
A backslash escapes
.Ql \&, ,
.Ql =
and
.Ql \e
inside a value; quote such a
.Ar spec
so the shell keeps the backslash.
.Cm id= Ns Ar name
names a source or sink; the default is its type, then
.Ar type Ns _2 ,
.Ar type Ns _3
and so on.
Any pipeline flag on the command line makes
.Nm
ignore the pipeline variables in
.Sx ENVIRONMENT .
.Ss Authentication commands
.Nm
.Cm auth
manages SCRAM credentials files and logs in to SCRAM listeners.
Changes to a credentials file apply when the server receives
.Dv SIGHUP .
.Bl -tag -width Ds
.It Cm add-user Fl credentials Ar file Fl user Ar name Oo Fl password-file Ar file Oc Op Fl generate
Add a user, or replace its password.
The password is read from the
.Fl password-file
when it exists; otherwise one is generated and written there, or printed
once to standard output.
.Fl generate
replaces an existing password without reading one.
A missing or empty credentials file is created with mode 0600; an existing
one keeps its owner and mode.
.It Cm remove-user Fl credentials Ar file Fl user Ar name
Remove a user.
The last user cannot be removed.
.It Cm token Fl url Ar https://host:port Fl user Ar name Fl password-file Ar file Oo Ar tls-flags Oc Op Fl unbound
Log in to an
.Cm http
sink or
.Cm http_chain
source and print a bearer token.
.Fl unbound
logs in without channel binding, to an
.Cm http
sink behind a TLS-terminating proxy; the URL may then carry the proxy's path.
.It Cm stream Fl addr Ar host:port Fl user Ar name Fl password-file Ar file Op Ar tls-flags
Log in to a
.Cm tcp
sink and copy its stream to standard output until interrupted.
.El
.Pp
The
.Ar tls-flags
are
.Fl ca-file Ar file
.Pq default: the system roots ,
.Fl server-name Ar name ,
and
.Fl cert-file Ar file
with
.Fl key-file Ar file
for a listener that requires client certificates.
There is no flag to skip server verification.
An IPv6
.Ar host
is written in brackets, as in
.Li [2001:db8::1]:8443 .
.Sh ENVIRONMENT
.Bl -tag -width Ds
.It Ev LOGWISP_ Ns Ar KEY
Any scalar configuration key: its TOML path in upper case with
.Ql \&.
replaced by
.Ql _ ,
as in
.Ev LOGWISP_LOGGING_LEVEL=debug .
Command-line options take precedence, and both take precedence over the
configuration file.
.It Ev LOGWISP_CONFIG_FILE
The configuration file, relative to
.Ev LOGWISP_CONFIG_DIR
when both are set.
.It Ev LOGWISP_CONFIG_DIR
The directory holding
.Pa logwisp.toml .
.It Ev LOGWISP_PIPELINE
The name of the one pipeline the variables below define.
All of these are ignored when a pipeline option is on the command line.
.It Ev LOGWISP_SOURCE , LOGWISP_SINK , LOGWISP_FILTER
Source, sink and filter specifications, in the syntax of the matching
options; a suffix
.Ar _N ,
counting from 1, adds more, as in
.Ev LOGWISP_SINK_1 .
.It Ev LOGWISP_FORMAT , LOGWISP_RATE_LIMIT , LOGWISP_HEARTBEAT
The pipeline's format, rate limit and heartbeat specifications.
.El
.Pp
Secrets never go in the environment: TLS keys, credentials files and
password files are named by path
.Pq Cm tls.key_file , auth.credentials_file , auth.password_file .
.Sh FILES
Without
.Fl c
or the
.Ev LOGWISP_CONFIG_*
variables,
.Nm
reads the first of these that exists:
.Bl -enum -compact
.It
.Pa ~/.config/logwisp/logwisp.toml
.It
.Pa ./logwisp.toml
.El
.Pp
Packages install a sample configuration and service files that pass it with
.Fl c :
.Bl -tag -width Ds
.It Pa /etc/logwisp/logwisp.toml
The configuration on Linux, used by
.Pa logwisp.service .
.It Pa /usr/local/etc/logwisp/logwisp.toml
The configuration on
.Fx ,
installed as
.Pa logwisp.toml.sample
and used by
.Pa /usr/local/etc/rc.d/logwisp .
.It Pa /var/lib/logwisp , /var/db/logwisp
The service's working directory on Linux and on
.Fx .
.El
.Sh EXIT STATUS
.Nm
exits 0 after a clean shutdown,
.Fl \-version
or help; 1 on a configuration, logger or startup error; and 2 when a
configuration file named by
.Fl c
or
.Ev LOGWISP_CONFIG_*
does not exist.
.Pp
.Nm
.Cm auth
exits 0 on success or help, 1 on failure, and 2 on a usage error.
.Sh SIGNALS
.Bl -tag -width "SIGINT, SIGTERM"
.It Dv SIGINT , SIGTERM
Shut down gracefully.
.It Dv SIGHUP , SIGUSR1
Reread the configuration file, certificates and credentials, and rebuild
every pipeline.
An invalid configuration leaves the running service untouched.
.El
.Sh EXAMPLES
Run a configuration in the foreground with debug logging:
.Bd -literal -offset indent
$ lw \-c /etc/logwisp/logwisp.toml \-\-logging.level=debug
.Ed
.Pp
Tail a directory and serve it as a live stream, without a file:
.Bd -literal -offset indent
$ lw \-\-source 'file,directory=/var/log/app,pattern=*.log' \e
\-\-sink http,host=127.0.0.1,port=8080
.Ed
.Pp
Create a credentials file the service account can read, add a user,
letting
.Nm
generate the password into a file, then reload the server:
.Bd -literal -offset indent
# install \-m 0600 \-o logwisp \-g logwisp /dev/null /etc/logwisp/users.toml
# lw auth add-user \-credentials /etc/logwisp/users.toml \e
\-user alice \-password-file /root/alice.pass
# systemctl reload logwisp
.Ed
.Sh SEE ALSO
.Xr rc.conf 5 ,
.Xr systemd.exec 5 ,
.Xr sysusers.d 5 ,
.Xr tmpfiles.d 5 ,
.Xr daemon 8
.Pp
The documentation installed in
.Pa share/doc/logwisp
under the installation prefix, and
.Lk https://github.com/lixenwraith/logwisp
.Sh SECURITY CONSIDERATIONS
A listener without an
.Cm auth
block serves or accepts logs from anyone who can reach it.
Keep key, credentials and password files at mode 0600, owned by the account
.Nm
runs as; startup warns about world-readable ones.
+37 -32
View File
@@ -65,14 +65,13 @@ back to authenticated peers.
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`.
pure field selection — `tlsx.PeerIdentity`. The `identity` modes:
| `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 |
- `cn` (default): `Subject.CommonName`, matching the existing `tls_peer_cn`
metadata.
- `san_dns`: the first `DNSNames` entry; preferred for host identities.
- `san_uri`: the first `URIs` entry; SPIFFE-style IDs.
- `san_email`: the 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.
@@ -96,13 +95,14 @@ 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 |
- `type`, string, default `none`: `none` preserves pre-auth behaviour
exactly; `mtls` enables the policy.
- `identity`, string, default `cn`: which certificate field is the identity.
- `allow`, []string, default `[]`: exact identity matches.
- `allow_patterns`, []string, default `[]`: RE2 patterns matched against the
identity.
- `node_binding`, string, default `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
@@ -111,13 +111,15 @@ 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.
declared. Setting it to anything but `none` on any other plugin is a
configuration error. Its values:
| 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 |
- `none`: `trust_node` governs the connection label, as before, and the
per-entry `node` field.
- `assert`: the connection label must equal the identity, and a mismatch or an
omission is rejected; `trust_node` governs the per-entry `node` field.
- `force`: the declared label is ignored and the identity is used; every
per-entry `node` field is overwritten with the identity.
The split between `assert` and `force` is what makes both worth having:
@@ -131,8 +133,9 @@ The split between `assert` and `force` is what makes both worth having:
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.
`node_binding` overrides `trust_node` for the connection label. Under `force`
the constructor logs that `trust_node` is ignored; under `assert`, that it still
governs the per-entry labels.
Dialer-side plugins (`tcp_chain` and `http_chain` sinks) accept the same block
to pin the *server's* identity beyond hostname verification. There
@@ -161,7 +164,8 @@ One small package, mirroring `internal/tlsx`, is the single seam between the
against the sibling TLS config and returns `(nil, nil)` when auth is disabled;
every method tolerates a nil receiver, so call sites never branch on config.
Listeners admit only through `Admit` (TCP) and `AuthorizeRequest` (HTTP); the
current API, including SCRAM, is in [scram-auth-plan.md](scram-auth-plan.md#the-authz-seam).
current API, including SCRAM, is in
[scram-auth-plan.md](scram-auth-plan.md#the-authz-seam).
### Enforcement Points
@@ -169,18 +173,19 @@ current API, including SCRAM, is in [scram-auth-plan.md](scram-auth-plan.md#the-
Handshake → `Admit` (authorize, then read hello) → `ResolveNode` → create
session. Under `mtls` the certificate is checked before the hello is 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.
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, through `AuthorizeRequest`, 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.
Per request, through `AuthorizeRequest`, 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`)
+96 -39
View File
@@ -6,23 +6,54 @@ see [Chaining](chaining.md).
## Address Family
**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.
Every listener and dialer keeps strictly to the family of its `host`:
When testing locally use `127.0.0.1`, not `localhost` — the latter may resolve
to `::1` and appear as an unexplained connection refusal.
- IPv4 literal (`127.0.0.1`, the default `0.0.0.0`): IPv4 only (`tcp4`). An
empty `host` is the IPv4 wildcard, as `0.0.0.0`.
- IPv6 literal (`::1`, `::`, `fe80::1%eth0`): IPv6 only (`tcp6`).
- `::` takes no IPv4 connections, on Linux and FreeBSD alike, whatever the
system's `bindv6only` default.
- To serve both families, run two listeners on the same port, one on
`0.0.0.0` and one on `::`: neither holds the other's port.
- Hostname: resolved. A listener binds one address (IPv4 when there is one)
in that address's family alone, a name for `0.0.0.0` or `::` too, so name
the address to be sure; a dialer tries each address in turn.
### IPv6
- Notation
- `host` takes the bare address: `host = "::1"`. A bracketed host, one
with a port, or an IPv4-mapped one (`::ffff:10.0.0.1`) fails at load.
- Addresses and URLs bracket it: logs print `[::1]:8443`, and `lw auth`
takes `-addr [::1]:8443` and `-url https://[::1]:8443`. Older `curl`
releases read the brackets as a glob; `-g` stops that and is harmless.
- A link-local address carries its zone, `fe80::1%eth0`, escaped in URLs as
`https://[fe80::1%25eth0]:8443`.
- TLS: a dialer verifies an IPv6 target against the certificate's IP SANs
(`subjectAltName = IP:::1`), as it does an IPv4 one. The zone is not part of
the name; `server_name` overrides as usual.
- Peers
- Logs and sessions name an IPv6 peer by its address, a link-local one with
its zone.
- SCRAM throttling counts an IPv6 client by its /64, so the hosts on a link
share one link-local budget per interface (zone): each can pick any
`fe80::/64` address.
- A peer with a zone never matches `auth.trusted_proxies`: put the proxy on
loopback or a routed address.
## Network Plugins
| Plugin | Role | Protocol | Purpose |
|--------|------|----------|---------|
| `tcp` sink | Listener | Raw stream | Broadcast formatted payloads to clients |
| `http` sink | Listener | HTTP SSE | Browser-friendly live stream plus status JSON |
| `tcp_chain` source | Listener | Chain v1 | Ingest a persistent NDJSON stream |
| `http_chain` source | Listener | Chain v1 | Ingest NDJSON batches over POST |
| `tcp_chain` sink | Dialer | Chain v1 | Forward entries over a persistent connection |
| `http_chain` sink | Dialer | Chain v1 | Forward entries as batched POSTs |
Listeners:
- `tcp` sink: raw stream; broadcasts formatted payloads to clients.
- `http` sink: HTTP SSE; a browser-friendly live stream plus status JSON.
- `tcp_chain` source: chain v1; ingests a persistent NDJSON stream.
- `http_chain` source: chain v1; ingests NDJSON batches over POST.
Dialers:
- `tcp_chain` sink: chain v1; forwards entries over a persistent connection.
- `http_chain` sink: chain v1; forwards 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
@@ -34,30 +65,36 @@ ERROR msg="Failed to start sink" error="tcp sink bind 0.0.0.0:9090: listen tcp4
## Timeouts
Every network plugin exposes the deadlines relevant to its role. Zero means "no
deadline" wherever the table says so.
Every network plugin exposes the deadlines relevant to its role. `0` means no
deadline where the default is `0` (none); elsewhere it selects the default.
| 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, and under `scram` the whole login |
| `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, and a SCRAM login when one is due |
- `tcp` sink
- `write_timeout_ms`, default `5000`: one write to one client; a miss
disconnects that client.
- `http` sink
- `write_timeout_ms`, default `0` (none): one SSE event write.
- `tcp_chain` source
- `hello_timeout_ms`, default `10000`: reading the protocol preamble, and
under `scram` the whole login.
- `read_timeout_ms`, default `0` (none): idle time between entries.
- `http_chain` source
- `read_timeout_ms`, default `30000`: reading a whole request body.
- `tcp_chain` sink
- `dial_timeout_ms`, default `5000`: TCP connect.
- `write_timeout_ms`, default `5000`: one line write.
- `http_chain` sink
- `request_timeout_ms`, default `10000`: dial plus write plus response, and
a SCRAM login when one is due.
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 |
| SCRAM login | 10 s | `tcp` sink, chain sinks, `logwisp auth`, each `/auth` request |
| SCRAM line or `/auth` body | 4 KiB | `scram` listeners and dialers |
- TLS handshake: 10 s, on all TLS listeners and dialers.
- HTTP read-header timeout: 10 s, on the `http` sink and `http_chain` source.
- HTTP server shutdown grace: 2 s, on the `http` sink and `http_chain` source.
- Single entry line: at most 1 MiB, on chain listeners.
- SCRAM login: 10 s, on the `tcp` sink, chain sinks, `lw auth`, and each
`/auth` request.
- SCRAM line or `/auth` body: at most 4 KiB, on `scram` listeners and dialers.
The `http` sink deliberately leaves the server's `WriteTimeout` unset, since it
would terminate long-lived SSE streams; per-event deadlines come from
@@ -163,7 +200,9 @@ headers, and entry encoding.
**Connection refused**
- Confirm the pipeline started; a bind failure is logged at ERROR.
- Confirm you are dialing IPv4. `localhost` may resolve to `::1`.
- Confirm the dialer uses the listener's family: `[::1]` does not reach a
`127.0.0.1` or `0.0.0.0` listener, nor `127.0.0.1` a `::` one. `localhost`
may resolve to either.
- Check the port is not already bound by another pipeline in the same process.
**TLS handshake failure**
@@ -176,7 +215,8 @@ headers, and entry encoding.
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
- Listeners log handshake failures at WARN with the remote address (the `tcp`
sink at DEBUG); the `tcp` sink and `tcp_chain` source count them in
`tls_handshake_errors`.
**Rejected after a successful handshake**
@@ -206,7 +246,7 @@ headers, and entry encoding.
between the peers terminates TLS. Pass TLS through to LogWisp; the login
cannot work otherwise, except on an `http` sink in proxy mode
(`auth.trusted_proxies`), where `the client bound its proof to the proxy's
certificate` means a client that needs `logwisp auth token -unbound`, and
certificate` means a client that needs `lw auth token -unbound`, and
`the client sent an unbound proof` the reverse. On an `http_chain` sink,
`server certificate differs from the one the SCRAM login was bound to` means
the certificate changed after the login: a rotation (the retry binds anew),
@@ -217,15 +257,16 @@ headers, and entry encoding.
`X-Forwarded-For`.
- `too many attempts` — the address failed or abandoned logins faster than one
per second beyond a burst of 10, or has 4 unfinished; it clears within seconds
once the failing peer stops. Peers behind one NAT share the budget. `busy` —
4,096 logins in flight, or the listener is stopping.
once the failing peer stops. Peers behind one NAT or one passthrough proxy
share the budget. `busy` — 4,096 logins in flight, or the listener is
stopping.
- `authentication not enabled` — the dialer has `scram`; the listener's
`auth.type` is `none` or `mtls`.
- `no challenge within 10s (older logwisp, or not a scram listener)` — the
server read the hello and said nothing. Over HTTP the same case reads
`no auth endpoint at …/auth`.
- `Argon2 parameters below client minimum` — the credentials file holds a
cheaper Argon2 profile than the defaults; recreate it with `logwisp auth`.
cheaper Argon2 profile than the defaults; recreate it with `lw auth`.
- `peer offered no credentials` (`authentication required` on the wire) in the
listener's log — a peer without `scram` reached a `scram` listener; a client
that sends nothing logs `read hello: … i/o timeout` after 10 s instead. A
@@ -245,6 +286,22 @@ headers, and entry encoding.
- `403` is not the token: it is the `identity` binding, node binding or, in
proxy mode, the proxy gate.
**Behind an L4 proxy**
- Every connection through the proxy fails: it sends a PROXY header (nginx
`stream` with `proxy_protocol on`, HAProxy `send-proxy`) to a LogWisp TLS
listener, which cannot read one yet. The header lands in front of the TLS
handshake, which fails; the listener logs the proxy's address and
`first record does not look like a TLS handshake` (the `tcp` sink at DEBUG).
Route LogWisp without it: see
[Behind nginx or another proxy](security.md#behind-nginx-or-another-proxy).
- All peers share one throttling budget behind a passthrough proxy: without
PROXY every `scram` peer arrives from the proxy's address. One client failing
logins exhausts everyone's, and a fifth login while 4 are unfinished (edges
reconnecting together after a restart) is refused; both answer
`too many attempts` ([Throttling](security.md#throttling)).
Expose the LogWisp ports directly, or keep the proxy hop on a trusted
network so only trusted clients share the budget.
**Entries not arriving over a chain link**
- Check the sink's `connected` statistic and its `reconnects` count.
- Check the source's `auth_rejected` — an allow-list miss or a failed login
+40 -29
View File
@@ -6,10 +6,10 @@ Running, monitoring, and maintaining LogWisp.
```bash
# foreground, explicit config
logwisp -c /etc/logwisp/logwisp.toml
lw -c /etc/logwisp/logwisp.toml
# no config: built-in demo pipeline (random source -> stdout)
logwisp
lw
```
There is no built-in daemon mode. Run LogWisp in the foreground under a
@@ -42,7 +42,7 @@ auto_reload = true
or send a signal:
```bash
kill -HUP $(pidof logwisp)
kill -HUP $(pidof lw)
```
Signals reread the selected TOML file even when `auto_reload` is disabled.
@@ -60,8 +60,9 @@ What reload does *not* do:
- 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`.
- Notice rotated certificates on its own: certificate files are read at plugin
construction and `auto_reload` watches only the configuration file, so
rotation requires `SIGHUP`.
Plan reloads on a busy relay the way you would plan a restart.
Listener bind/start failures happen after the old service stops; these can leave
@@ -70,16 +71,17 @@ File-watch errors do not restart services, and queued changes are combined.
### Checking a configuration
There is no validate-only mode. To check a file, start it with debug logging and
watch for pipeline startup:
`lw --check` builds every pipeline and plugin as a start would, without binding
a port or opening, reading or creating a log file, prints the startup warnings,
and exits 0, 1 on an invalid configuration, or 2 when a named file is missing:
```bash
logwisp -c candidate.toml --logging.level=debug --logging.output=stderr
lw --check -c candidate.toml
```
Success looks like `Created source instance`, `Created sink instance`, and
`Starting pipeline` for each pipeline. Failures name the pipeline and the
offending key:
Before a reload, check the edited file this way: a failed reload keeps the
running service, but `--check` says why without touching it. Failures name the
pipeline and the offending key:
```
ERROR msg="Failed to create pipeline" pipeline=app error="failed to create sink out: port: must be 1-65535, got 0"
@@ -145,22 +147,28 @@ curl -s http://127.0.0.1:8080/status | jq .
This endpoint is scoped to one sink, not to the whole process, and without an
`auth` block it is **unauthenticated**. Bind it to a trusted interface, or
query it with a client certificate or a token from `logwisp auth token`.
query it with a client certificate or a token from `lw auth token`.
### Metrics worth watching
| Metric | Where | Meaning if rising |
|--------|-------|-------------------|
| `dropped_entries` | source | Downstream cannot keep up with the source |
| `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 |
Each counter, where it is reported, and what a rise means:
- `dropped_entries` (source): downstream cannot keep up with the source
- `total_dropped` (flow): the rate limit or filters are discarding entries,
often by intent
- `total_dropped_by_sink` (pipeline): a sink's input queue is full
- `dropped_writes` (`tcp` and `http` sinks): a client's queue overflowed,
because it is too slow or one burst exceeded `client_buffer_size`
- `rejected_conns` (`tcp` sink, `tcp_chain` source), `rejected_clients`
(`http` sink): `max_connections` is being hit; `rejected_conns` also counts
auth refusals
- `tls_handshake_errors` (`tcp` sink, `tcp_chain` source): a certificate or
version mismatch, or scanning
- `parse_errors` (chain sources): protocol or version skew upstream
- `reconnects` (`tcp_chain` sink): an unstable link or a flapping downstream
- `dropped_batches` (`http_chain` sink): downstream is rejecting batches
permanently
- `synthesized` (chain sinks): events reach the sink without structure
## Log Management
@@ -264,15 +272,18 @@ sink blocked on an unreachable downstream also holds its full input queue.
**Chain link not delivering**
Check `connected` and `reconnects` on the sink, `parse_errors` on the source,
and remember `http_chain` waits up to `flush_interval_ms`. For TLS problems see
[Networking](networking.md#troubleshooting).
Check `connected` and `reconnects` on a `tcp_chain` sink, `parse_errors` on the
source, and remember `http_chain` waits up to `flush_interval_ms`. For TLS
problems see [Networking](networking.md#troubleshooting).
**Environment variable override has no effect**
LogWisp reads `LOGWISP_QUIET`, `LOGWISP_LOGGING_LEVEL`, and other prefixed
names. Bare names used by older versions must be renamed. Array-indexed paths cannot be set from the
environment or the command line at all.
names. Bare names used by older versions must be renamed. Array-indexed paths,
such as a key inside `[[pipelines]]`, cannot be set from the environment or the
command line; define whole pipelines there with the
[pipeline flags](cli.md#pipelines) or
[pipeline variables](cli.md#pipeline-variables).
## Security Operations
+79 -64
View File
@@ -6,7 +6,7 @@ channel binding and the mTLS layer was hardened (see
on the `auth` release that carries channel binding; then browser logins behind
a TLS-terminating proxy. Operator documentation lives in
[Security](security.md#password-authentication-scram) and
[`logwisp auth`](cli.md#logwisp-auth).
[`lw auth`](cli.md#lw-auth).
**Scope:** optional username/password authentication on every network plugin,
next to the existing certificate method, without weakening what mTLS gives.
@@ -18,7 +18,7 @@ next to the existing certificate method, without weakening what mTLS gives.
- No password or password-equivalent on the wire, no KDF work on listeners, and
no credential relay through a TLS-terminating MITM.
- Default stays `auth.type = "none"`; existing configurations are unaffected.
- Config in TOML, credential management and viewer clients in a `logwisp auth`
- Config in TOML, credential management and viewer clients in a `lw auth`
CLI.
## Non-Goals
@@ -94,8 +94,9 @@ Validation at construction:
CA-issued certificate plus any valid password (`clientcert=verify-ca`).
- `token_lifetime_ms` only on HTTP listeners; default 15 minutes.
- `/auth` and every path under it are reserved.
- `mtls` rejects the four SCRAM keys. A block that names peers or credentials
with `type = "none"` is refused: the type was forgotten, not the block.
- `mtls` rejects every SCRAM key, `trusted_proxies` included. A block that
names peers or credentials with `type = "none"` is refused: the type was
forgotten, not the block.
**Credentials file** (TOML, written by the CLI, mode 0600):
@@ -113,12 +114,12 @@ username = "edge-01"
`decoy_key` keeps unknown-user challenges stable across restarts and edits, so
a single probe cannot tell a real user from an unknown one (a real user's salt
changes when it is added, rotated or removed). It is created by the first `add-user` and
preserved by every rewrite. An empty user set, a duplicate user, a missing or
short `decoy_key`, an unknown key, or users differing in KDF profile or salt
length are configuration errors. The file is read at construction; the SCRAM
server starts in the plugin's `Start` and stops in `Stop`, so a rejected reload
leaks nothing.
changes when it is added, rotated or removed). It is created by the first
`add-user` and preserved by every rewrite. An empty user set, a duplicate
user, a missing or short `decoy_key`, an unknown key, or users differing in KDF
profile or salt length are configuration errors. The file is read at
construction; the SCRAM server starts in the plugin's `Start` and stops in
`Stop`, so a rejected reload leaks nothing.
## Wire Protocol
@@ -140,12 +141,15 @@ silent, and the dialer gives up after its deadline with "no challenge within
**HTTP** (`http_chain` source, `http` sink): `POST /auth`, outside the auth
middleware, with its own 10 s read/write deadline. It answers `404` unless the
policy is `scram`.
policy is `scram`. Each step, by its request body:
| Step | Request body | Success | Failure |
|------|--------------|---------|---------|
| 1 | hello | `200` challenge | `429` throttled, `503` busy, `400` malformed, `413` oversized |
| 2 | proof | `200` final, token, expires_in | `401 {"error":"authentication failed"}`, `503` if the server stopped |
- Step 1, the hello
- Success: `200` with the challenge.
- Failure: `429` throttled, `503` busy, `400` malformed, `413` oversized.
- Step 2, the proof
- Success: `200` with the final, `token` and `expires_in`.
- Failure: `401 {"error":"authentication failed"}`, or `503` if the server
stopped.
Protected endpoints take `Authorization: Bearer <token>`: missing or invalid is
`401` with `WWW-Authenticate: Bearer realm="logwisp"`, a certificate-binding miss
@@ -157,23 +161,22 @@ checked at connect and outlives its token; a reload ends it.
batch is held under backoff); `404`/`405` from `/auth` reads "no auth endpoint
at <url> (older logwisp, or not a scram listener)"; an ingest `401` clears the
token and is retried (tokens are renewed ahead of expiry, so this follows a
listener reload); `403` drops
the batch; a pin mismatch clears token and pin. Refusals are logged at WARN on
every attempt, paced by the backoff (`Chain connect refused` on `tcp_chain`,
`Chain batch delivery failed` on `http_chain`), and failed logins are counted
in `auth_failures` / `last_auth_error`.
listener reload); `403` drops the batch; a pin mismatch clears token and pin.
Refusals are logged at WARN on every attempt, paced by the backoff (`Chain
connect refused` on `tcp_chain`, `Chain batch delivery failed` on
`http_chain`), and failed logins are counted in `auth_failures` /
`last_auth_error`.
## Throttling
Per remote IP (the socket address; in proxy mode the forwarded client, an IPv6
one per /64), on handshake
starts: a token bucket (burst 10, 1/s) refunded on success, and at most 4
unfinished exchanges. An unanswered HTTP challenge holds its slot for the
`auth` handshake timeout (30 s); abandoned TCP exchanges release their slot,
and their entry in the `auth` handshake table, immediately. The address table
holds 65,536 entries, drops one idle for a minute once its challenges have
expired, and fails closed when full; the SCRAM server itself caps in-flight
handshakes at 4,096 (`busy`).
one per /64), on handshake starts: a token bucket (burst 10, 1/s) refunded on
success, and at most 4 unfinished exchanges. An unanswered HTTP challenge holds
its slot for the `auth` handshake timeout (30 s); abandoned TCP exchanges
release their slot, and their entry in the `auth` handshake table,
immediately. The address table holds 65,536 entries, drops one idle for a
minute once its challenges have expired, and fails closed when full; the SCRAM
server itself caps in-flight handshakes at 4,096 (`busy`).
Counters: `auth_allowed` counts logins; `auth_rejected` every refusal — failed
proofs, malformed requests, missing credentials and, on HTTP, refused tokens;
@@ -201,10 +204,10 @@ closed.
## CLI
```
logwisp auth add-user -credentials F -user U [-password-file P] [-generate]
logwisp auth remove-user -credentials F -user U
logwisp auth token -url https://host:port[/path] -user U -password-file P [-unbound] [TLS flags]
logwisp auth stream -addr host:port -user U -password-file P [TLS flags]
lw auth add-user -credentials F -user U [-password-file P] [-generate]
lw auth remove-user -credentials F -user U
lw auth token -url https://host:port[/path] -user U -password-file P [-unbound] [TLS flags]
lw auth stream -addr host:port -user U -password-file P [TLS flags]
```
- `add-user` takes the password from `-password-file` when it exists (at least
@@ -266,44 +269,54 @@ SameSite=Strict; Max-Age=<lifetime>`) and carries no token in the body. It has
no `Path`: RFC 6265 defaults it to the directory of `POST <mount>/auth`, which
is the mount behind any proxy prefix. Stream and status accept the cookie or
`Authorization: Bearer`; another `Authorization` scheme, such as the site's own
Basic auth, leaves the cookie in charge. Logout is `{"logout": true}` on `POST /auth` itself,
because a clearing cookie set from `/auth/logout` would default to another path
and miss the login cookie. It clears the cookie and revokes a valid token until
it expires (a set of at most 65,536 hashes, gone on reload like every token). An
open stream outlives its token; a reconnect after expiry gets `401`. `/auth`
takes only `application/json`, which a cross-origin page cannot send without a
preflight nobody answers.
Basic auth, leaves the cookie in charge. Logout is `{"logout": true}` on
`POST /auth` itself, because a clearing cookie set from `/auth/logout` would
default to another path and miss the login cookie. It clears the cookie and
revokes a valid token until it expires (a set of at most 65,536 hashes, gone on
reload like every token). An open stream outlives its token; a reconnect after
expiry gets `401`. `/auth` takes only `application/json`, which a cross-origin
page cannot send without a preflight nobody answers.
**Token mode.** A proof step without `"session"` gets the token in the answer
body, in proxy mode as everywhere else, so a browser that keeps no cookies needs
no server change. `scram.js` asks for it with `{session: "token"}`, reads the
stream through `fetch` with the bearer (`EventSource` cannot send one), and
revokes it with `logout(base, {token})`. The viewer falls back to it when a
cookie would not stick or a fresh cookie login still gets `401`, and keeps the
token in a page variable only: it lasts no longer than the page or its lifetime.
**Endpoints.** `/auth` and every path under it are reserved; every URL in the
pages is relative, so a proxy prefix works unchanged.
| Path | Purpose |
|------|---------|
| `POST /auth` | SCRAM hello, proof, or logout; unbound in proxy mode |
| `GET /auth/scram.js` | the client library, always in proxy mode |
| `GET /auth/login`, `login.js`, `style.css` | login page (`login_page`) |
| `GET /auth/view`, `view.js` | minimal live viewer (`viewer_page`, needs `login_page`) |
- `POST /auth`: SCRAM hello, proof or logout; unbound in proxy mode.
- `GET /auth/scram.js`: the client library, always served in proxy mode.
- `GET /auth/login`, `login.js`, `style.css`: the login page (`login_page`).
- `GET /auth/view`, `view.js`: a minimal live viewer (`viewer_page`, needs
`login_page`).
Files carry `default-src 'none'; script-src 'self'; connect-src 'self';
style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
`nosniff` and `no-referrer`; nothing is inline. The viewer learns a custom
`status_path` from a meta tag the sink fills in, renders entries as text, and
sends a signed-out visitor to `login?next=view`; the login page follows `next`
only within its origin. The `proxy_tls` capability satisfies the pipeline's
"auth needs TLS" check.
sends a signed-out visitor to `login?next=view%23signed-in` (the fragment marks
a fresh cookie login; without cookies it shows its own sign-in form); the login
page follows `next` only within its origin. The `proxy_tls` capability
satisfies the pipeline's "auth needs TLS" check.
**Client library.** `internal/sink/http/web/scram.js`, embedded with `go:embed`,
is one dependency-free ES module, so a site with its own CSP can copy it into
its bundle. It exports `login(base, username, password, {onProgress})` and
`logout(base)`. BLAKE2b and Argon2id are plain JS (no WebAssembly, which would
need `'wasm-unsafe-eval'`), yielding to the page every 50 ms; SHA-256 and HMAC
come from WebCrypto, so it needs a secure context. It checks the challenge as
the Go client does (nonce, salt, cost limits and the dialer's Argon2 floor),
its bundle. It exports `login(base, username, password, {onProgress, session})`,
`logout(base, {token})`, `stream(url, {token, signal, onEvent})` (an event
stream parsed as the HTML standard does), `cookiesUsable()` and
`loginUnavailable()`. BLAKE2b and Argon2id are plain JS (no WebAssembly, which
would need `'wasm-unsafe-eval'`), yielding to the page every 50 ms; SHA-256 and
HMAC come from WebCrypto, so it needs a secure context. It checks the challenge
as the Go client does (nonce, salt, cost limits and the dialer's Argon2 floor),
refuses redirects, and reports success only after verifying the server's
signature. A 64 MiB login takes about 2 s on a desktop. The wire protocol above
is the contract: a site may implement its own client.
**CLI.** `logwisp auth token -unbound` logs in through the proxy, still pinning
**CLI.** `lw auth token -unbound` logs in through the proxy, still pinning
its certificate across the two requests; only then may `-url` carry the mount
path.
@@ -336,29 +349,31 @@ their types and CSP, direct peers refused, pages only in proxy mode, and the
CLI: private files with a matching verifier, no password replaced without a
source, mode, decoy key and symlink kept across rewrites, an empty file filled,
last-user refusal. Under node, `scram.js` against the RFC 9106 vector, Go's
`argon2.IDKey`, and `auth`'s known-answer proof.
`argon2.IDKey`, and `auth`'s known-answer proof; the event-stream line breaks
and fields, the bearer, refusals and abort; a token-mode login and logout.
`test/scram-chain-test.sh --auto` (ports 15821-15825): a relay with SCRAM on all
four listener types plus one `client_auth` + SCRAM port; authorized edges over
both chain transports; node labels forced to usernames; wrong password, unknown
user and no-auth edges deliver nothing; a `tcp` sink viewer through `logwisp auth
user and no-auth edges deliver nothing; a `tcp` sink viewer through `lw auth
stream` held past the exchange deadline; a raw TLS client without a hello gets
nothing; the `http` sink through `logwisp auth token` and curl; missing and bad
nothing; the `http` sink through `lw auth token` and curl; missing and bad
tokens get `401`; `remove-user` + `SIGHUP` revokes token and login; throttling
last. `test/scram-proxy-test.sh --auto` (ports 15831-15832): a Go reverse proxy
ending TLS in front of a plaintext `http` sink mounted at `/logs/`; headless
Chromium sent to the login page, refused a wrong password, streaming events
after login, with the cookie scoped to `/logs` and hidden from scripts, cleared
by sign-out, and no CSP violations; `token -unbound` and curl through the
proxy; direct peers and plaintext-forwarded requests `403`. The existing
scripts keep passing.
by sign-out, and no CSP violations; a cookie the browser drops leading to the
viewer's own form, not a loop, its token kept out of storage and the URL; a
profile blocking every cookie signing in on the viewer, its proofs asking for
no cookie, streaming with a bearer, sign-out revoking the token, and a reload's
`401` asking again; `token -unbound` and curl through the proxy; direct peers
and plaintext-forwarded requests `403`. The existing scripts keep passing.
## Not Implemented
1. **PROXY protocol behind TLS passthrough.** Every client then shares the
proxy's address, and so one throttling budget that a single client can
exhaust. Accepting PROXY v2 from listed proxies would restore per-client
throttling on the TCP listeners and on HTTP listeners outside proxy mode.
1. **PROXY protocol behind TLS passthrough**: planned in
[To Do, 1.2](todo.md#12-proxy-protocol-deferred-gap-of-scram-see-scram-auth-planmd).
## mTLS Hardening
+297 -102
View File
@@ -5,21 +5,37 @@ 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 certificate identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Password (Argon2id-SCRAM) authentication, bound to the TLS channel | Implemented — see [Password Authentication](#password-authentication-scram) |
| Authentication on the `http` sink's stream and status endpoints | Implemented: client certificate or bearer token |
| Browser logins behind a site's TLS-terminating proxy | Implemented — see [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy) |
| Server pinning by dialers | Implemented: certificate identity (`mtls`), bound certificate (`scram`) |
| Startup warnings for expiring certificates and risky settings | Implemented — see [Startup Warnings](#startup-warnings) |
| Unknown configuration keys rejected | Implemented — a typo in `tls`, `auth` or a table path fails startup |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list or credentials file |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** — only SCRAM logins are throttled per address |
**Implemented:**
- TLS 1.2 / 1.3 on all network sources and sinks.
- Server certificate verification by dialers.
- Mutual TLS (client certificate required and verified), at the transport
layer.
- Peer identity recorded per session.
- Authorization from certificate identity (allow-lists, node binding): see
[The Auth Block](#the-auth-block).
- Password (Argon2id-SCRAM) authentication, bound to the TLS channel: see
[Password Authentication](#password-authentication-scram).
- Authentication on the `http` sink's stream and status endpoints: client
certificate or bearer token.
- Browser logins behind a site's TLS-terminating proxy: see
[Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy).
- Server pinning by dialers: certificate identity (`mtls`), bound certificate
(`scram`).
- Startup warnings for expiring certificates and risky settings: see
[Startup Warnings](#startup-warnings).
- Unknown configuration keys rejected: a typo in `tls`, `auth` or a table path
fails startup.
**Not implemented:**
- Certificate revocation lists (CRL) or OCSP: revoke by editing the allow-list
or credentials file.
- IP allow/deny lists, per-IP connection or request limits: only SCRAM logins
are throttled per address.
- PROXY protocol: behind a proxy that passes TLS through, every peer shares the
proxy's address; see
[Behind nginx or another proxy](#behind-nginx-or-another-proxy).
Two credentials are supported. Certificates (`mtls`) are the one the transport
already carries: the `tls` block establishes that a peer chains to your CA, and
@@ -46,17 +62,23 @@ 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"` |
Options by role, each with its default:
- Both roles
- `enabled` (`false`): master switch; when false the whole block is ignored.
- `cert_file`: local certificate; **required** for listeners, an optional
client identity for dialers.
- `key_file`: private key for `cert_file`; set the two together.
- `min_version` (`"1.3"`): `"1.2"` or `"1.3"`.
- Listeners
- `client_auth` (`false`): require and verify a client certificate (mTLS).
- `client_ca_file`: CA bundle that verifies client certificates; **required**
when `client_auth` is true.
- Dialers
- `ca_file` (system store): CA bundle that verifies the server certificate.
- `server_name` (the configured `host`): SNI and certificate name to verify
against.
- `insecure_skip_verify` (`false`): disable server verification.
Listeners are the `tcp` and `http` sinks and the `tcp_chain` and `http_chain`
sources; dialers are the `tcp_chain` and `http_chain` sinks.
@@ -99,26 +121,44 @@ password_file = "" # scram dialers
trusted_proxies = [] # scram http sink behind a TLS-terminating proxy
```
| Option | Applies to | Default | Description |
|--------|------------|---------|-------------|
| `type` | all | `none` | `none` ignores the block; `mtls` authorizes by certificate identity; `scram` by password |
| `identity` | `mtls`; `scram` listeners | `cn` (`mtls`), unset (`scram`) | Certificate field carrying the identity. Under `scram` it binds the client certificate to the user |
| `allow` | `mtls` | `[]` | Exact identities to admit |
| `allow_patterns` | `mtls` | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | chain sources | `force` | `none`, `assert`, or `force` |
| `credentials_file` | `scram` listeners | — | Verifiers written by [`logwisp auth add-user`](cli.md#logwisp-auth) |
| `token_lifetime_ms` | `scram` on the `http` sink and `http_chain` source | 15 minutes | Bearer token lifetime, 10 s to 24 h |
| `username` | `scram` dialers | — | User to log in as |
| `password_file` | `scram` dialers | — | File holding the password; one trailing line break is trimmed |
| `trusted_proxies` | `scram` on the `http` sink | `[]` | Addresses or CIDRs of the reverse proxies that end the browsers' TLS; see [Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy) |
Options by where they apply, each with its default:
**Roles by plugin:**
- Every plugin
- `type` (`none`): `none` ignores the block; `mtls` authorizes by
certificate identity, `scram` by password.
- Chain sources
- `node_binding` (`force`): `none`, `assert` or `force`.
- `mtls`, and `scram` listeners
- `identity`: the certificate field carrying the identity; `cn` under
`mtls`, unset under `scram`, where it binds the client certificate to the
user.
- `mtls`
- `allow` (`[]`): exact identities to admit.
- `allow_patterns` (`[]`): RE2 patterns matched against the identity; anchor
them yourself.
- `scram` listeners
- `credentials_file`: verifiers written by
[`lw auth add-user`](cli.md#lw-auth).
- `token_lifetime_ms` (15 minutes): bearer token lifetime, 10 s to 24 h; the
`http` sink and `http_chain` source only.
- `trusted_proxies` (`[]`): addresses or CIDRs of the reverse proxies that
end the browsers' TLS; the `http` sink only, see
[Browsers behind a TLS-terminating proxy](#browsers-behind-a-tls-terminating-proxy).
- `scram` dialers
- `username`: user to log in as.
- `password_file`: file holding the password; one trailing line break is
trimmed.
| 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 | `mtls`: which server identity to accept, beyond hostname verification. `scram`: which user to log in as |
**Roles by plugin**, and what the block decides:
- Listeners
- `tcp_chain` and `http_chain` sources: which senders may ingest, and what
node label their entries carry.
- `tcp` and `http` sinks: which clients may read the stream (and, on
`http`, the status endpoint).
- Dialers: the `tcp_chain` and `http_chain` sinks.
- `mtls`: which server identity to accept, beyond hostname verification.
- `scram`: which user to log in as.
### Identity
@@ -127,12 +167,13 @@ certificate; under `scram` it is the username. 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 |
Modes, each with the field it reads and its typical use:
- `cn` (default): `Subject.CommonName`; matches the existing `tls_peer_cn`
metadata.
- `san_dns`: the first DNS SAN; host identities.
- `san_uri`: the first URI SAN; SPIFFE-style IDs.
- `san_email`: the 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.
@@ -159,13 +200,19 @@ reported at startup.
### Node binding
`node_binding` applies only to the chain sources, and it overrides `trust_node`.
`node_binding` applies only to the chain sources. `assert` and `force` take the
connection label over from `trust_node`; only `force` also overrides the
per-entry labels.
| 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 |
- `none`: `trust_node` governs both the connection label and the per-entry
`node` field.
- `assert`
- Connection label: must equal the identity; a mismatch or an omission is
rejected.
- Per-entry `node` field: `trust_node` governs.
- `force`
- Connection label: ignored; the identity is used.
- Per-entry `node` field: 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
@@ -206,14 +253,14 @@ was shown instead; see [Channel binding](#channel-binding).
Misconfiguration fails at plugin construction, before the pipeline starts:
- `type` `mtls` or `scram` without `tls.enabled` (proxy mode aside), or on a dialer with
`tls.insecure_skip_verify`: an identity read from an unverified chain is a
claim, and an unverified server could relay a login
- `type` `mtls` or `scram` without `tls.enabled` (proxy mode aside), or on a
dialer with `tls.insecure_skip_verify`: an identity read from an unverified
chain is a claim, and an unverified server could relay a login
- `type = "mtls"` on a listener without `tls.client_auth`
- a block naming peers or credentials (`allow`, `allow_patterns`,
`credentials_file`, `token_lifetime_ms`, `username`, `password_file`,
`trusted_proxies`) whose
`type` is `none` or unset: auth was intended and the type forgotten
`trusted_proxies`) whose `type` is `none` or unset: auth was intended and the
type forgotten
- a key of the other method: `allow` or `allow_patterns` under `scram`, a
`scram` key under `mtls`
- a `scram` listener without a loadable `credentials_file` (see
@@ -350,7 +397,7 @@ end to end — run it with `--auto` to see each guarantee asserted.
`type = "scram"` authenticates peers by username and password with
Argon2id-SCRAM from `lixenwraith/auth`. It needs TLS but no client
certificates, works on all six network plugins, and is LogWisp's own protocol,
not standard SASL: viewers use the [`logwisp auth`](cli.md#logwisp-auth) CLI or,
not standard SASL: viewers use the [`lw auth`](cli.md#lw-auth) CLI or,
behind a TLS-terminating proxy, the shipped browser client.
```toml
@@ -405,7 +452,7 @@ connection. After the challenge the dialer pins the certificate it was bound to:
the proof and every ingest request must meet the same certificate, or the TLS
handshake fails before anything is sent. The `http_chain` sink then drops token
and pin and logs in again, which also covers a rotated server certificate. A
token printed by `logwisp auth token` is not pinned.
token printed by `lw auth token` is not pinned.
### Certificates and users
@@ -433,21 +480,22 @@ exchange.
Logins are throttled per remote socket address or, on an `http` sink in proxy
mode, per forwarded client (an IPv6 client per /64); forwarded headers are read
only from `trusted_proxies`. Each exchange takes a token from a bucket of 10 that refills at one per
second, and a successful login gives it back, so only failed or abandoned
attempts drain it. At most 4 exchanges per address may be unfinished: an HTTP
challenge never answered holds its slot for up to 30 s, a TCP connection that
ends mid-exchange frees it at once. A refused start answers `too many attempts`
(HTTP `429`) and counts `auth_throttled`; the table holds 65,536 addresses and
refuses new ones when full. Separately, at most 4,096 exchanges may be in flight
per listener; beyond that, or while the plugin stops, a login gets `busy` (HTTP
`503`) and counts `auth_busy`. Peers behind one NAT or one passthrough proxy
share a bucket: there, one client can exhaust every other client's logins.
only from `trusted_proxies`. Each exchange takes a token from a bucket of 10
that refills at one per second, and a successful login gives it back, so only
failed or abandoned attempts drain it. At most 4 exchanges per address may be
unfinished: an HTTP challenge never answered holds its slot for up to 30 s, a
TCP connection that ends mid-exchange frees it at once. A refused start answers
`too many attempts` (HTTP `429`) and counts `auth_throttled`; the table holds
65,536 addresses and refuses new ones when full. Separately, at most 4,096
exchanges may be in flight per listener; beyond that, or while the plugin
stops, a login gets `busy` (HTTP `503`) and counts `auth_busy`. Peers behind
one NAT or one passthrough proxy share a bucket: there, one client can exhaust
every other client's logins.
### Credentials file
```toml
# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.
# logwisp SCRAM verifiers, written by `lw auth add-user`. Keep it private.
decoy_key = "<base64, 32 random bytes>"
[[users]]
@@ -460,7 +508,7 @@ stored_key = "<base64>"
username = "edge-01"
```
Write it with [`logwisp auth add-user`](cli.md#logwisp-auth) rather than by
Write it with [`lw auth add-user`](cli.md#lw-auth) rather than by
hand. The whole file is validated when the plugin is built: a `decoy_key` of at
least 32 bytes, at least one user, unique names, no unknown keys, and one Argon2
profile and salt length for every user, since mixed profiles would tell a prober
@@ -477,14 +525,14 @@ different users.
- **Rollout.** Turning `scram` on cuts off every dialer of that listener that
has no credentials. Upgrade every binary first, then add a second listener
with `scram` on another port, move the dialers to it, and remove the old one.
- **Password rotation.** `logwisp auth add-user -generate` with the user's
- **Password rotation.** `lw auth add-user -generate` with the user's
`-password-file` replaces verifier and password together. `SIGHUP` the
listener, deploy the password file, `SIGHUP` the dialer. Logins fail in
between and the dialer retries under backoff, holding its current entry or
batch while its input queue fills. To avoid the gap, add a second user, move
the dialer to it, then remove the first; under `node_binding = "force"` the
node label follows the username.
- **Revocation.** `logwisp auth remove-user`, then `SIGHUP`. The reload drops
- **Revocation.** `lw auth remove-user`, then `SIGHUP`. The reload drops
every connection and revokes every token; other dialers log in again on
their own.
@@ -493,14 +541,15 @@ different users.
- TLS must terminate at LogWisp, except on an `http` sink in proxy mode. Behind
any other terminating proxy or load balancer every login fails by design;
pass TLS through instead (TCP or SNI routing). All clients then share the
proxy's address, and so one throttling budget.
proxy's address, and so one throttling budget: see
[Behind nginx or another proxy](#behind-nginx-or-another-proxy).
- One process per HTTP address. Handshake state and the token key live in one
instance, so behind a balancer the proof or the token can reach an instance
that never saw the login.
- Browsers log in only to an `http` sink in proxy mode, below; elsewhere use
`mtls` for browsers.
- `nc` and `openssl s_client` cannot read a `scram` `tcp` sink; use
`logwisp auth stream`.
`lw auth stream`.
`test/scram-chain-test.sh --auto` exercises these guarantees end to end.
@@ -541,28 +590,169 @@ location /logs/ {
could relay a login. Keep that hop on loopback or a trusted network, or
enable `tls` on the sink; a plaintext hop to a proxy off this host is
warned about at startup. `identity` cannot be combined with proxy mode.
- **Sessions.** The page asks for a cookie: `logwisp_session`, `HttpOnly`,
`Secure`, `SameSite=Strict`, `Max-Age` the token lifetime, and no `Path`, so
it scopes itself to the mount (`/logs` above). Stream and status accept it or
a bearer token, so `new EventSource("/logs/stream")` works on any page of the
site, beside the site's own Basic auth too, and `logwisp auth token -unbound`
keeps working. `POST /auth` with
`{"logout": true}` clears the cookie and revokes the token until it expires.
`/auth` takes only `application/json`, which a cross-origin page cannot send
without a preflight.
- **Sessions.** The login page asks for a cookie: `logwisp_session`,
`HttpOnly`, `Secure`, `SameSite=Strict`, `Max-Age` the token lifetime, and no
`Path`, so it scopes itself to the mount (`/logs` above). Stream and status
accept it or a bearer token, so `new EventSource("/logs/stream")` works on any
page of the site, beside the site's own Basic auth too, and `lw auth token
-unbound` keeps working. `POST /auth` with `{"logout": true}` clears the
cookie and revokes the token until it expires. `/auth` takes only
`application/json`, which a cross-origin page cannot send without a preflight.
- **Private windows.** Private or incognito windows keep cookies in memory,
apart from normal windows: they sign in on their own, and the session ends at
the token lifetime or when the last private window closes.
- **Cookies disabled.** The viewer runs in token mode when the browser keeps no
cookie for the site.
- It notices from `navigator.cookieEnabled`, from a throwaway cookie with the
session's attributes that does not come back (Chromium blocking every cookie
still reports `cookieEnabled`), or from a `401` right after a cookie login,
and shows its own sign-in form.
- The token lives in a page variable only, never in storage or the URL. It
lasts as long as the page and at most the token lifetime: a reload, another
tab, or a reconnect after expiry asks again; sign out revokes it.
- The login page says that cookies are unavailable and links to the viewer.
A page not served over HTTPS keeps no `Secure` cookie either: both pages
name HTTPS instead and offer no sign-in.
- **Integrating `scram.js`.** A site with its own CSP can copy `scram.js` from
`internal/sink/http/web/` into its bundle: one dependency-free ES module,
where `base` is the mount URL ending in `/`. It needs a secure context for
WebCrypto and takes about 2 s of Argon2 per login on a desktop.
- Cookie mode, the default: `login(base, username, password, {onProgress})`,
then `EventSource` and `fetch` as for any same-origin resource;
`logout(base)`.
- Token mode: `login(..., {session: "token"})` resolves to `{username,
expiresIn, token}`. Keep the token in memory; `stream(url, {token, signal,
onEvent})` reads the event stream through `fetch` with the bearer, status
takes `Authorization: Bearer`, and `logout(base, {token})` revokes it. The
bearer replaces the Basic credentials a browser would send, so token mode
cannot pass a proxy that asks for its own Basic auth.
- `cookiesUsable()` tells which applies: false when the session cookie would
not stick. `loginUnavailable()`, asked first, names why neither can run
(no secure context), or is empty.
- **Pages.** Under `/auth/` the sink serves `scram.js` always, and with
`login_page` / `viewer_page` the login page and a minimal live viewer with
their script and style, under `default-src 'none'; script-src 'self';
connect-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors
'none'; base-uri 'none'`. A site with its own CSP can copy `scram.js` from
`internal/sink/http/web/` into its bundle: one dependency-free ES module
exporting `login(base, username, password, {onProgress})` and
`logout(base)`, where `base` is the mount URL ending in `/`. It needs a
secure context for WebCrypto and takes about 2 s of Argon2 per login on a
desktop.
'none'; base-uri 'none'`.
`test/scram-proxy-test.sh --auto` logs in from headless Chromium through such a
proxy.
proxy, with cookies and with every cookie blocked.
### Behind nginx or another proxy
A proxy in front of LogWisp works at one of two layers:
- **L7, ending TLS** (nginx `http` block, HAProxy `mode http`): the `http` sink
in [proxy mode](#browsers-behind-a-tls-terminating-proxy).
- `trusted_proxies` lists the proxy's address.
- The proxy sends `X-Forwarded-Proto: https` and `X-Forwarded-For`. It may
overwrite `X-Forwarded-For` with the real client: the rightmost hop that is
not a proxy is the client either way.
- **L4, passing TLS through to LogWisp's own TLS** (nginx `stream` with
`ssl_preread`, HAProxy `mode tcp`): whatever needs TLS to end at LogWisp.
- Chain links (`tcp_chain`, `http_chain`).
- `lw auth stream` viewers of a `tcp` sink.
- An `http` sink that keeps its own TLS, and with it SCRAM channel binding
or client certificates.
A common shape serves both from port 443:
```
:443 nginx stream: ssl_preread routes by SNI, proxy_protocol on
logs.example.org -> 127.0.0.1:8443 nginx http
relay.example.org -> a LogWisp TLS listener, without PROXY (below)
127.0.0.1:8443 nginx http: listens with proxy_protocol, ends TLS
/logs/ -> 127.0.0.1:8081 LogWisp http sink, proxy mode
```
**The `http` sink behind the `http` block** works in proxy mode as is:
`trusted_proxies` is the `http` block's address, usually `127.0.0.1`, and the
block names the client from the PROXY header.
```nginx
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
server_name logs.example.org;
# ssl_certificate, ssl_certificate_key: the site's own
location /logs/ {
proxy_pass http://127.0.0.1:8081/;
proxy_set_header X-Real-IP $proxy_protocol_addr;
proxy_set_header X-Forwarded-For $proxy_protocol_addr;
proxy_set_header X-Forwarded-Proto https;
}
}
```
`$proxy_add_x_forwarded_for` does not fit this shape: it appends the stream
server's address, not the client's. LogWisp would name that address as every
client or, when it is trusted (both on `127.0.0.1`), the hop to its left: one
the client wrote, so the client picks its own throttling address. LogWisp
ignores `X-Real-IP`.
**LogWisp's own TLS listeners take no PROXY header.** LogWisp cannot read the
PROXY protocol yet; it is planned first in the ACL work
([To Do, 1.2](todo.md#12-proxy-protocol-deferred-gap-of-scram-see-scram-auth-planmd)).
A stream route that sends `proxy_protocol` straight to a LogWisp TLS listener
breaks every connection: the header lands in front of the TLS handshake. nginx
sets `proxy_protocol` per stream `server`, so route LogWisp SNIs without it:
- Route 1: LogWisp gets its own stream `server` and port, without
`proxy_protocol`.
- Route 2: port 443 sends LogWisp SNIs to an internal stream `server` that
`listen`s with `proxy_protocol` and proxy_passes to LogWisp without it; that
hop consumes the header.
- Either way dialers must send the name: a `host` that is one, or
`tls.server_name` (`-server-name` for `lw auth`); an IP literal sends no SNI.
- With HAProxy, leave `send-proxy` off the LogWisp backends.
```nginx
stream {
map $ssl_preread_server_name $route {
relay.example.org 127.0.0.1:10443; # route 2: the internal hop
tail.example.org 127.0.0.1:10443;
default 127.0.0.1:8443; # the http block, logs.example.org
}
map $ssl_preread_server_name $logwisp {
relay.example.org 127.0.0.1:9001; # tcp_chain source
tail.example.org 127.0.0.1:9002; # tcp sink, for lw auth stream
}
server {
listen 443;
ssl_preread on;
proxy_protocol on; # on every route of this server
proxy_pass $route;
}
server { # route 1: its own port, no header
listen 9443;
ssl_preread on;
proxy_pass $logwisp;
}
server { # route 2: consumes the header
listen 127.0.0.1:10443 proxy_protocol;
ssl_preread on;
proxy_pass $logwisp;
}
}
```
**Passthrough without PROXY** brings every peer from the proxy's address:
- All peers share one SCRAM [throttling](#throttling) budget: 10 failed or
abandoned logins, then one per second, and 4 unfinished at once. One client
that keeps failing logins makes every other client's logins answer
`too many attempts` until it stops.
- Established `tcp_chain` links and open streams keep flowing; a reconnect
waits for the budget. An `http_chain` sink whose early renewal is put off
(`too many attempts` or `busy`) keeps sending on its token to the server it
pinned, retrying every few seconds, and holds its batches only once that
token has expired; any other refusal ends the token at once.
- Logs and sessions name the proxy, not the peer.
- Proxy mode is unaffected: it throttles on the forwarded client.
- Until LogWisp reads PROXY, mitigate:
- expose the chain ports directly, without the proxy, where you can;
- keep the proxy hop on a trusted network, so only trusted clients share the
budget;
- or accept the shared budget.
## What Each Layer Enforces
@@ -598,12 +788,17 @@ would add.
An `auth` block (`mtls` or `scram`) 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 (the header is omitted once an auth policy is set) |
| `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 that can connect (with `client_auth`, any the CA vouches for), under any node label it claims |
What each exposes when `auth.type = "none"`:
- `http` sink `stream_path`: the full log stream, with
`Access-Control-Allow-Origin: *`, so any browser origin can read it (the
header is omitted once an auth policy is set).
- `http` sink `status_path`: host, port, TLS flag, uptime, client counts,
throughput counters.
- `tcp` sink: the full log stream to any client that connects.
- `tcp_chain` and `http_chain` sources: ingest from any peer that can connect
(with `client_auth`, any the CA vouches for), under any node label it
claims.
`max_connections` bounds concurrency on all of them but does not distinguish
callers.
@@ -618,7 +813,7 @@ where TLS ends at the trusted proxy; `mtls` also requires `client_auth`.
- Use a dedicated CA for LogWisp so its trust decisions stay independent.
- Keep leaf lifetimes short (90–825 days) and automate renewal.
- Key, credentials and password files should be `0600` and owned by the service
account; a world-readable one is reported at startup. `logwisp auth` creates
account; a world-readable one is reported at startup. `lw auth` creates
them `0600` and keeps the mode of an existing file.
- Rotation requires a reload (`SIGHUP`), because certificates and credentials
are loaded once at plugin construction; there is no on-disk watch for them.
+86 -75
View File
@@ -27,10 +27,10 @@ target = "stdout"
buffer_size = 1000
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `target` | string | `stdout` | `stdout` or `stderr` |
| `buffer_size` | int | `1000` | Sink input queue depth |
Options, each as type and default:
- `target` (string, `stdout`): `stdout` or `stderr`.
- `buffer_size` (int, `1000`): sink input queue depth.
> `split` is **not** a valid target for this sink and is rejected at startup.
> Level-based splitting exists only for LogWisp's own application log
@@ -60,16 +60,16 @@ buffer_size = 1000
flush_interval_ms = 100
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `directory` | string | **required** | Output directory |
| `name` | string | **required** | Base filename |
| `max_size_mb` | int | `100` | Rotate when the active file reaches this size |
| `max_total_size_mb` | int | `1000` | Cap across all rotated files |
| `min_disk_free_mb` | int | `0` | Free-space floor before writing; `0` = no floor |
| `retention_hours` | float | `168.0` | Delete rotated files older than this |
| `buffer_size` | int | `1000` | Sink input queue depth |
| `flush_interval_ms` | int | `100` | Forced flush interval |
Options, each as type and default:
- `directory` (string, **required**): output directory.
- `name` (string, **required**): base filename.
- `max_size_mb` (int, `100`): rotate when the active file reaches this size.
- `max_total_size_mb` (int, `1000`): cap across all rotated files.
- `min_disk_free_mb` (int, `0`): free-space floor before writing; `0` = none.
- `retention_hours` (float, `168.0`): delete rotated files older than this.
- `buffer_size` (int, `1000`): sink input queue depth.
- `flush_interval_ms` (int, `100`): forced flush interval.
> `min_disk_free_mb` has an unusual default. The constructor replaces only
> *negative* values with `100`; leaving the key unset yields `0`, which means no
@@ -125,20 +125,25 @@ 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 |
| `login_page` | bool | `false` | Serve the browser login page at `/auth/login`; needs `auth.trusted_proxies` |
| `viewer_page` | bool | `false` | Serve a minimal live viewer at `/auth/view`; needs `login_page` |
| `tls` | table | — | Listener TLS; see [Security](security.md) |
| `auth` | table | — | Client authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the listener
keeps to its family ([Networking](networking.md#address-family)).
- `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.
- `login_page` (bool, `false`): serve the browser login page at `/auth/login`;
needs `auth.trusted_proxies`.
- `viewer_page` (bool, `false`): serve a minimal live viewer at `/auth/view`;
needs `login_page`.
- `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): client authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block).
**Behaviour**
@@ -148,7 +153,7 @@ allow = ["viewer-01"]
- With an `auth` block, one middleware gates **both** endpoints, with no body
detail in a refusal. Under `mtls` a refused certificate gets `403`. Under
`scram` a client logs in at `POST /auth`, which sits outside the gate (see
[`logwisp auth token`](cli.md#logwisp-auth)), and sends
[`lw auth token`](cli.md#lw-auth)), and sends
`Authorization: Bearer <token>`; a missing, invalid or expired token gets
`401` with `WWW-Authenticate`, a certificate that does not match the token's
user `403`. A stream is checked when it connects and outlives its token; a
@@ -233,18 +238,20 @@ 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 authentication (`mtls` or `scram`); see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the listener
keeps to its family ([Networking](networking.md#address-family)).
- `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`): 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 authentication (`mtls` or `scram`); see
[Security](security.md#the-auth-block).
**Behaviour**
@@ -254,7 +261,7 @@ allow = ["viewer-01"]
first: a hello carrying the login, then challenge, proof and final, all
within 10 s, after which the stream follows on the same connection. `nc` and
`openssl s_client` cannot do this; use
[`logwisp auth stream`](cli.md#logwisp-auth).
[`lw auth stream`](cli.md#lw-auth).
- 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`)
@@ -300,20 +307,22 @@ 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 (`mtls`) or a login (`scram`); see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, **required**): downstream host or address; an IPv6 one goes
bare (`::1`).
- `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`): 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 (`mtls`) or a login (`scram`); see
[Security](security.md#the-auth-block).
**Behaviour**
@@ -380,21 +389,23 @@ 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 (`mtls`) or a login (`scram`); see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, **required**): downstream host or address; an IPv6 one goes
bare (`::1`).
- `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 (`mtls`) or a login (`scram`); see
[Security](security.md#the-auth-block).
**Behaviour**
@@ -407,10 +418,10 @@ key_file = "/etc/logwisp/tls/client.key"
sends `Authorization: Bearer <token>`. A failed login is retried like a
transport error, holding the batch. The token is renewed ahead of its expiry;
a `401` on ingest (the source reloaded) drops it and the retry logs in again.
A `403` is permanent. A
connection presenting a certificate other than the one the login was bound
to drops token and pin, and the retry logs in anew. A sink without `scram`
facing a `scram` source gets `401` and drops every batch.
A `403` is permanent. A connection presenting a certificate other than the
one the login was bound to drops token and pin, and the retry logs in anew.
A sink without `scram` facing a `scram` source gets `401` and drops every
batch.
- Redirects are never followed; a `3xx` is permanent too. Following one would
resend the batch wherever the response points, plaintext `http` included.
- HTTP/2 is off by design; batched NDJSON POSTs gain nothing from it.
+53 -39
View File
@@ -36,13 +36,14 @@ 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 |
Options, each as type and default:
- `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**
@@ -93,9 +94,9 @@ type = "console"
buffer_size = 1000
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `buffer_size` | int | `1000` | Subscriber channel depth |
Option, as type and default:
- `buffer_size` (int, `1000`): subscriber channel depth.
At most **one** instance per pipeline: the type is registered with
`MaxInstances: 1`, and a second instance is rejected at pipeline construction.
@@ -119,13 +120,15 @@ length = 20
special = false
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `interval_ms` | int | `500` | Emission period |
| `jitter_ms` | int | `0` | Symmetric jitter; clamped to `interval_ms`, must be non-negative |
| `format` | string | `txt` | `raw` (message only), `txt` (bracketed line), `json` (JSON object as the message) |
| `length` | int | `20` | Message length in characters |
| `special` | bool | `false` | Inject control and non-ASCII characters |
Options, each as type and default:
- `interval_ms` (int, `500`): emission period.
- `jitter_ms` (int, `0`): symmetric jitter; must be non-negative, clamped to
`interval_ms`.
- `format` (string, `txt`): `raw` (message only), `txt` (bracketed line) or
`json` (JSON object as the message).
- `length` (int, `20`): message length in characters.
- `special` (bool, `false`): inject control and non-ASCII characters.
`special = true` is the intended way to exercise sanitizer policies: it inserts
control bytes and multi-byte Unicode into otherwise ordinary messages. Levels
@@ -180,17 +183,22 @@ 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 authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the
listener keeps to its family ([Networking](networking.md#address-family)).
- `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. `auth.node_binding` `assert` or `force` takes over the
connection label; only `force` also overrides the per-entry labels; `none`
leaves both to `trust_node`.
- `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block).
For passwords instead of certificates, the `auth` block takes `type = "scram"`
and a `credentials_file`, and `client_auth` becomes optional; see
@@ -258,17 +266,23 @@ 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 authentication (`mtls` or `scram`) and node binding; see [Security](security.md#the-auth-block) |
Options, each as type and default:
- `host` (string, `0.0.0.0`): bind address, IPv4 or IPv6 (`::`); the
listener keeps to its family ([Networking](networking.md#address-family)).
- `port` (int, required): listen port, 1–65535.
- `ingest_path` (string, `/ingest`): endpoint path; must start with `/`, and
`/auth` is reserved for the login.
- `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. `auth.node_binding` `assert` or `force` takes over the
connection label; only `force` also overrides the per-entry labels; `none`
leaves both to `trust_node`.
- `tls` (table): listener TLS; see [Security](security.md).
- `auth` (table): peer authentication (`mtls` or `scram`) and node binding;
see [Security](security.md#the-auth-block).
**Behaviour**
+202
View File
@@ -0,0 +1,202 @@
# To Do
Planned work, in order of priority. Each item names what to build, how it fits
the existing seams, and how to verify it. Finished items move to the design doc
they belong to.
## 1. Network access control (ACL)
The next feature push. Today a listener decides *who* a peer is (`tls`, `auth`);
nothing decides *where* a peer may connect from, and every limit except SCRAM
throttling is global. The ACL adds address rules and per-client limits at the
connection level, below TLS, so a refused peer costs no handshake.
### 1.1 One seam: a filtering listener
- New package `internal/netacl`, the third network seam beside `tlsx` and
`authz` (AGENTS.md lists the seams; add it there).
- `netacl.New(opts *config.ACLOptions, role) (*Policy, error)` compiles the
block; a nil policy is valid and transparent, like `authz`.
- `(*Policy).Listener(ln net.Listener) net.Listener` wraps the raw listener
before TLS:
- TCP plugins wrap it before `tls.NewListener`.
- HTTP plugins wrap it before `Serve`/`ServeTLS`.
- `Accept` reads an optional PROXY header (1.2), decides allow or deny (1.3),
applies per-address connection caps (1.4), and returns a `net.Conn` whose
`RemoteAddr` is the real client.
- Everything downstream then sees the real client with no further change:
`authz.remoteIP`, SCRAM throttling, sessions, logs and `http.Request.RemoteAddr`.
- Keep the proxy's own address as `peer_addr` in session metadata for audit.
- A denied connection is closed before any byte is read or written, with a
counter (`acl_denied`) and a rate-limited WARN.
### 1.2 PROXY protocol (deferred gap of SCRAM, see scram-auth-plan.md)
- Why: behind an L4 proxy that passes TLS through (nginx `stream` with
`ssl_preread`, HAProxy `mode tcp`), every peer arrives from the proxy's
address.
- All peers share one SCRAM throttling budget, so one client can lock out
every login.
- Logs, sessions and the ACL itself see only the proxy.
- What: accept PROXY v1 (text) and v2 (binary) headers, but only from
`proxy_from` addresses or CIDRs. Two config keys in the ACL block:
- `proxy_protocol = "off" | "optional" | "required"`, default off.
- `proxy_from = [...]`.
- Rules:
- A listed peer must send the header when `required`.
- An unlisted peer that sends a header is refused, since the header is
spoofable.
- The header must arrive within a short deadline (reuse
`tlsx.HandshakeTimeout`), with a size cap: v1 at most 107 bytes, v2 with a
bounded TLV length.
- `LOCAL`/`UNKNOWN` commands (proxy health checks) keep the socket address.
- TLVs are ignored.
- The header precedes the TLS ClientHello, so SCRAM channel binding is
unaffected: LogWisp still terminates TLS.
- nginx side:
- In the `stream` server, `proxy_protocol on` on the route to LogWisp (it
sends v1).
- HAProxy: `send-proxy` or `send-proxy-v2`.
- Relation to `auth.trusted_proxies` (HTTP proxy mode):
- That list trusts `X-Forwarded-For` from a TLS-terminating L7 proxy;
`proxy_from` trusts a PROXY header from an L4 proxy.
- Keep both, with distinct names and docs. They answer different layers, and
one deployment can use both: nginx `stream` with `proxy_protocol` into an
nginx `http` block that sets `X-Forwarded-For $proxy_protocol_addr`.
### 1.3 Address rules
- `allow = [CIDR...]`, `deny = [CIDR...]`, IPv4 and IPv6 (strict per family,
as the listeners are).
- Order: deny wins, then allow; an empty allow list admits everyone not denied.
- Rules match the real client (after PROXY), never the proxy.
- In HTTP proxy mode, `X-Forwarded-For` is applied by `authz.ClientAddr` after
the connection-level decision.
- Address rules for forwarded HTTP clients therefore belong in a second,
request-level check inside `authz.ClientAddr`: reuse the compiled `netacl`
rules there rather than duplicating them.
- Startup warnings (in `LogStartup` style): an allow list containing
`0.0.0.0/0` or `::/0`, `proxy_from` covering public ranges, a listener on a
wildcard host with no `allow`.
### 1.4 Per-client limits
- Generalize the SCRAM limiter in `internal/authz/scram.go` (token bucket per
key, bounded table that fails closed, idle sweep, IPv6 per /64 via
`throttleKey`) into the shared per-key limiter. One mechanism (AGENTS.md):
SCRAM throttling then uses it too.
- Keys:
- `max_connections_per_client` on every listener (concurrent connections per
address).
- `requests_per_second_per_client` on HTTP listeners (ingest, stream and
status requests).
- Later: per-identity limits once a peer is authenticated (the mtls plan's
deferred item 2), keyed by identity instead of address.
### 1.5 Config, validation, docs
- Config shape: `[...config.acl]` beside `tls` and `auth` on the four listener
plugins.
- Decode through `config.Scan`, so unknown keys fail.
- Dialers have no ACL block.
- Intent rule as in `authz`: an ACL key on a dialer, or `proxy_from` without
`proxy_protocol`, is an error.
- Stats: `acl_denied`, `acl_proxy_headers`, `acl_limited`, merged into the
plugin stats like `authz.Stats`.
- Docs: a security.md section, plus networking.md troubleshooting lines for
each refusal.
### 1.6 Verification
- Go tests, one per rule:
- PROXY v1 and v2 parse, including malformed, oversized and slow headers.
- A header from an unlisted peer refused; `required` without a header
refused; `LOCAL` kept.
- Deny over allow; IPv6 rules.
- Per-client connection cap; the limiter table failing closed.
- The real client reaching SCRAM throttling and session metadata.
- `test/acl-test.sh`:
- A Go stub proxy that sends v1 and v2 headers in front of each listener
type.
- nginx `stream` with `proxy_protocol on` when nginx is installed.
- Denied and allowed clients; per-client SCRAM budgets behind one proxy
address.
## 2. Hardening review of `lixenwraith/config` and `lixenwraith/toml`
Both parse untrusted-shaped input (config files, environment, command line)
and were not reviewed with the auth work. Review them as `auth` was reviewed:
adversarial lenses, reproduction before belief, a fix per confirmed finding in
the library repository, then a dependency bump here.
- toml parser:
- Limits on nesting depth, key count, string and array length, and number
sizes.
- Behaviour on invalid UTF-8, duplicate keys and tables, dotted-key and
inline-table redefinition.
- Fuzz targets (`go test -fuzz`) seeded with the TOML test suite.
- config:
- The weakly typed decoder: string-to-number overflow, duration and size
parsing, the comma split into slices.
- `maxValueDepth` and `MaxValueSize`, environment and CLI precedence, and
unknown-key reporting.
- Files:
- `PreventPathTraversal`, symlink handling and the file-size cap.
- The watcher: polling, debounce, a file replaced by rename or symlink swap,
TOCTOU between stat and read.
- Errors: values must never echo secrets (a misplaced password in a config
value reported back in an error).
- Deliverables:
- Findings per library.
- Fuzz targets committed in the library repositories.
- A note in this repository's security.md once done.
## 3. Packaging: AUR, FreeBSD ports, Debian
The foundation exists:
- The `lw` binary name.
- `make install` with `DESTDIR`, `PREFIX` and `SYSCONFDIR`.
- The `doc/lw.1` manual.
- Service files in `deploy/package/`: systemd unit, sysusers, tmpfiles and the
FreeBSD rc.d script.
- Skeletons in `deploy/package/arch/` and `deploy/package/freebsd/`.
What remains:
- Releases:
- The repository has no tags, so versions come from `git describe` and fall
back to a commit hash. Tag releases `vX.Y.Z`.
- Publish source tarballs with checksums, and sign them.
- Module path:
- The module is `logwisp`, so `go install github.com/lixenwraith/logwisp/cmd/lw@latest`
does not work.
- FreeBSD's `USES=go:modules` and Debian's dh-golang expect the canonical
path.
- Rename the module to `github.com/lixenwraith/logwisp`: a mechanical
import rewrite in one commit.
- Name check before submission:
- Arch: `pkgfile -s lw` against the official repositories, and the AUR
package list for `lw` and `logwisp`.
- Already checked: Ubuntu 26.04 and 24.04 contents and the FreeBSD 15.1
ports tree ship no `bin/lw`.
- Arch (AUR): `deploy/package/arch/PKGBUILD` already has the source URL, the
Arch Go flags (`-trimpath -buildmode=pie -mod=readonly -modcacherw`),
`backup` for the config and `check()`. Remaining:
- the checksum of a tagged tarball in place of `SKIP`, and a maintainer;
- namcap, `.SRCINFO`, and publishing `logwisp` (optionally `logwisp-git`).
- FreeBSD: `deploy/package/freebsd/` already has `USES=go:1.27,modules`,
`USE_RC_SUBR=logwisp`, `USERS`/`GROUPS`, and a pkg-plist with the `.sample`
config and the manual. Remaining:
- `distinfo` (or `GH_TUPLE`), which needs the module rename and a tag;
- a real `MAINTAINER` and a `UIDs`/`GIDs` entry for `logwisp`;
- poudriere on 14.x and 15.x, then submission through Bugzilla
(`sysutils/logwisp`).
- Debian:
- Debian policy wants every Go dependency packaged; the four `lixenwraith`
libraries are not.
- Either package them too (dh-golang), or start with an `.deb` built by the
Makefile `install` target (nfpm or `dpkg-deb`) and a PPA, and move to the
archive later.
- Shell completion for bash, zsh and fish, installed by `make install`.
- A packaging CI job: build the AUR package in an Arch container and the port
in a FreeBSD VM, run `lw --version` and `make image-check`.