v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
This commit is contained in:
1 parent
9ab8c356d2
commit
71d1b82f83
102 files changed
+7102
-2178
No files matched your search
+31
-20
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`.
|
||||
Reference in new issue
Block a user