v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-02 15:25:51 -04:00
1 parent f192c26fa2
commit 9ab8c356d2
60 files changed
+6791 -747

No files matched your search

+334 -86
View File
@@ -11,19 +11,21 @@ configure it, and — equally important — what it does not yet do.
| Server certificate verification by dialers | Implemented |
| Mutual TLS (client certificate required and verified) | Implemented at the transport layer |
| Peer identity recorded per session | Implemented |
| Authorization from peer identity (allow-lists, node binding) | Implemented — see [The Auth Block](#the-auth-block) |
| Authentication on the `http` sink's stream and status endpoints | Implemented, via the auth block |
| Server identity pinning by dialers | Implemented, via the auth block |
| 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` or `auth` fails startup |
| Certificate revocation lists (CRL) or OCSP | **Not implemented** — revoke by editing the allow-list |
| Password (Argon2id-SCRAM) authentication | **Planned** — see [scram-auth-plan.md](scram-auth-plan.md) |
| IP allow/deny lists, per-IP connection or request limits | **Not implemented** |
| 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 |
Certificates are the credential the transport already carries, so they are what
authentication is built on today: the `tls` block establishes that a peer chains
to your CA, and the `auth` block decides which peers that CA vouches for may
actually do what.
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
the `auth` block decides which of those peers may do what. Passwords (`scram`)
need no client PKI: the listener holds verifiers, never passwords, and every
login is bound to the listener's certificate.
## The TLS Block
@@ -56,13 +58,8 @@ min_version = "1.3"
| `insecure_skip_verify` | dialer | `false` | Disable server verification |
| `min_version` | both | `"1.3"` | `"1.2"` or `"1.3"` |
**Roles by plugin:**
| Plugin | Role | Keys that apply |
|--------|------|-----------------|
| `tcp` sink, `http` sink | Listener | `cert_file`, `key_file`, `client_auth`, `client_ca_file`, `min_version` |
| `tcp_chain` source, `http_chain` source | Listener | same as above |
| `tcp_chain` sink, `http_chain` sink | Dialer | `ca_file`, `server_name`, `insecure_skip_verify`, `cert_file`, `key_file`, `min_version` |
Listeners are the `tcp` and `http` sinks and the `tcp_chain` and `http_chain`
sources; dialers are the `tcp_chain` and `http_chain` sinks.
> `min_version` takes `"1.2"` or `"1.3"`. The older `"TLS1.2"` spelling from
> pre-restructure releases is rejected. There is no `max_version` and no
@@ -86,25 +83,34 @@ Misconfiguration fails at plugin construction, before the pipeline starts:
TLS answers "is this channel private, and does the peer chain to a CA". Auth
answers "may *this* peer do *this*". They are separate blocks because they are
separate questions, and because a later non-certificate method should be able to
reuse the second one.
separate questions.
```toml
[pipelines.plugin_sources.config.auth] # or plugin_sinks.config.auth
type = "none" # none | mtls
identity = "cn" # cn | san_dns | san_uri | san_email
allow = []
allow_patterns = []
node_binding = "force" # chain sources only
type = "none" # none | mtls | scram
identity = "cn" # cn | san_dns | san_uri | san_email
allow = [] # mtls
allow_patterns = [] # mtls
node_binding = "force" # chain sources only
credentials_file = "" # scram listeners
token_lifetime_ms = 0 # scram, HTTP listeners; 10 s to 24 h, 0 = 15 minutes
username = "" # scram dialers
password_file = "" # scram dialers
trusted_proxies = [] # scram http sink behind a TLS-terminating proxy
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `type` | string | `none` | `none` ignores the whole block; `mtls` authorizes by certificate identity |
| `identity` | string | `cn` | Which certificate field carries the identity |
| `allow` | []string | `[]` | Exact identities to admit |
| `allow_patterns` | []string | `[]` | RE2 patterns matched against the identity; anchor them yourself |
| `node_binding` | string | `force` under `mtls` | Chain sources only: `none`, `assert`, or `force` |
| 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) |
**Roles by plugin:**
@@ -112,13 +118,14 @@ node_binding = "force" # chain sources only
|--------|------|---------|
| `tcp_chain` source, `http_chain` source | Listener | Which senders may ingest, and what node label their entries carry |
| `tcp` sink, `http` sink | Listener | Which clients may read the stream (and, on `http`, the status endpoint) |
| `tcp_chain` sink, `http_chain` sink | Dialer | Which server identity to accept, beyond hostname verification |
| `tcp_chain` sink, `http_chain` sink | Dialer | `mtls`: which server identity to accept, beyond hostname verification. `scram`: which user to log in as |
### Identity
The identity is one string pulled from the peer's verified leaf certificate.
The handshake has already checked the chain, signature, and validity window, so
this is pure field selection.
Under `mtls` the identity is one string pulled from the peer's verified leaf
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 |
|------|--------|-------------|
@@ -133,7 +140,8 @@ identity is a refusal, not an empty match.
### The allow list
`allow` is an exact-match set; `allow_patterns` holds RE2 patterns. An identity
passes if it appears in either.
passes if it appears in either. Under `scram` the credentials file is the allow
list, and both keys are refused.
Leaving **both** empty under `type = "mtls"` admits any identity the CA vouches
for. That is deliberate — it is how you enable node binding without enumerating
@@ -161,7 +169,8 @@ reported at startup.
Use **`force`** on an ingest boundary you do not trust. Every entry is
relabelled, so a compromised edge cannot smuggle a foreign origin through the
per-entry `node` field either. It is the default under `type = "mtls"`.
per-entry `node` field either. It is the default whenever `type` is `mtls` or
`scram`.
Use **`assert`** on a relay-to-relay hop. The relay must prove its own identity —
a mismatch fails loudly instead of being silently corrected — but the entries it
@@ -177,7 +186,7 @@ INFO msg="Node labels bound to peer identity; trust_node is ignored"
### Dialer-side pinning
On a chain sink, the same block pins the *server's* identity. Hostname
Under `mtls`, the block on a chain sink pins the *server's* identity. Hostname
verification already proves the server holds a certificate valid for the address
you dialed; pinning additionally requires that certificate to name an identity
you listed.
@@ -190,20 +199,40 @@ allow = ["relay.internal"]
The check runs as part of the handshake, so a server the policy rejects never
receives an entry — the sink's normal backoff loop handles it like any other
connect failure. `insecure_skip_verify` is refused alongside `type = "mtls"`:
an identity read from an unverified chain is a claim, not a fact.
connect failure. Under `scram` the dialer binds its login to the certificate it
was shown instead; see [Channel binding](#channel-binding).
### Validation
Misconfiguration fails at plugin construction, before the pipeline starts:
- `type = "mtls"` on a listener without `tls.enabled` **and** `tls.client_auth`
- `type = "mtls"` on a dialer without `tls.enabled`, or with
`tls.insecure_skip_verify`
- `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
- 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
[Credentials file](#credentials-file)), with `username` or `password_file`,
with a `token_lifetime_ms` outside 10 s to 24 h or on a TCP listener, or with
`identity` but no `tls.client_auth`
- a `scram` dialer without `username` and `password_file`, with
`credentials_file`, `token_lifetime_ms`, `trusted_proxies` or `identity`
(server pinning is `mtls` only), or with a password file that is empty or
over 1024 bytes
- `trusted_proxies` on anything but the `http` sink, with `identity`, or with
an entry that is neither an address nor a CIDR; `login_page` or
`viewer_page` without `trusted_proxies`, `viewer_page` without `login_page`
- an `identity` that is not one of the four modes
- an `allow_patterns` entry that does not compile
- a `node_binding` that is not one of the three values, or one set on a plugin
that has no node concept
- an HTTP path (`stream_path`, `status_path`, `ingest_path`) equal to `/auth`,
which is reserved for logins
Errors read like `auth: type "mtls" requires tls.client_auth`.
@@ -217,15 +246,15 @@ when it is constructed, so startup and every reload repeat them:
- `insecure_skip_verify` on a dialer
- an `allow_patterns` entry not anchored at both ends of every alternative
(`^a|b$` admits `a…` and `…b`)
- a `key_file` every local user can read (once per path per process)
- a `key_file`, `credentials_file` or `password_file` every local user can read
(once per path per process)
## Enabling mTLS
### 1. Generate a CA and certificates
LogWisp no longer ships a certificate-generation subcommand; the `logwisp tls`
command was removed with the rest of the CLI restructure. Use `openssl`,
`cfssl`, `step-cli`, or your existing PKI.
LogWisp does not generate certificates; use `openssl`, `cfssl`, `step-cli`, or
your existing PKI.
```bash
# CA
@@ -299,48 +328,265 @@ INFO msg="TCP chain sink initialized" ... tls=true mtls=true
auth="mtls identity=cn allow=[1 exact, 0 pattern(s)] node_binding=none"
```
A client that presents no certificate is refused during the handshake:
```
WARN msg="TLS handshake failed" component=tcp_chain_source
remote_addr=127.0.0.1:53840 error="tls: client didn't provide a certificate"
```
A client that presents no certificate is refused during the handshake
(`tls: client didn't provide a certificate`, counted in `tls_handshake_errors`).
A client whose certificate is valid but whose identity is not authorized gets
past the handshake and is refused by the policy:
```
WARN msg="Connection rejected by auth policy" component=tcp_chain_source
remote_addr=127.0.0.1:33946 error="auth: identity \"edge-99\" is not allowed"
remote_addr=<addr> error="auth: refused: identity \"edge-99\" is not allowed"
```
Handshake failures are counted in `tls_handshake_errors`; policy rejections in
`auth_rejected`. Both appear in the status reporter and in the `http` sink's
status endpoint. Accepted peers are recorded in session metadata as
`auth_method` and `auth_identity`.
Policy rejections are counted in the plugin's `auth_rejected` statistic; the
`http` sink's status endpoint reports its own. Accepted peers are recorded in
session metadata as `auth_method` and `auth_identity`.
`test/mtls-chain-test.sh` builds a throwaway PKI and exercises the whole surface
end to end — run it with `--auto` to see each guarantee asserted.
## Password Authentication (SCRAM)
`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,
behind a TLS-terminating proxy, the shipped browser client.
```toml
# Listener: tcp_chain/http_chain source, tcp/http sink
[pipelines.plugin_sources.config.tls]
enabled = true
cert_file = "/etc/logwisp/tls/relay.crt"
key_file = "/etc/logwisp/tls/relay.key"
[pipelines.plugin_sources.config.auth]
type = "scram"
credentials_file = "/etc/logwisp/users.toml"
# Dialer: tcp_chain/http_chain sink
[pipelines.plugin_sinks.config.tls]
enabled = true
ca_file = "/etc/logwisp/tls/ca.crt"
[pipelines.plugin_sinks.config.auth]
type = "scram"
username = "edge-01"
password_file = "/etc/logwisp/edge-01.pass"
```
Startup reports `auth="scram users=2 certificate=independent node_binding=force"`
on such a chain source, and `auth="scram user=edge-01"` on the dialer.
### Mechanism
For each user the listener keeps a salt, the Argon2id cost, and two keys derived
from `K = Argon2id(password, salt)`: `StoredKey = SHA-256(HMAC(K, "Client
Key"))` and `ServerKey = HMAC(K, "Server Key")`. Neither logs anyone in, but
each password guess against them costs one Argon2id, so keep the file private.
Checking a login is HMAC work only; Argon2id (3 passes, 64 MiB, 4 threads) runs
on the dialer or the CLI. The proof is mutual: the dialer accepts the server's
final message only when it is signed with this user's `ServerKey`, and it
refuses a challenge asking for less Argon2 work than those defaults, so a rogue
server cannot request a cheaply crackable proof. An unknown user gets a stable
decoy challenge and fails exactly like a wrong password: `authentication
failed`.
### Channel binding
Both sides bind the proof to the SHA-256 of the listener's leaf certificate:
the listener hashes the certificate it serves, the dialer the one it was shown.
A TLS-terminating proxy or interceptor shows another certificate — CA-valid or
not — so the proof fails and the relay never obtains a link or a token. The
dialer is told `authentication failed` either way; the listener compares the
hash the dialer reports, logs `the client saw another server certificate: TLS
interception or a terminating proxy`, and counts `auth_binding_mismatch`.
An HTTP login takes two requests, and every later request may open a new pooled
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.
### Certificates and users
`tls.client_auth` is optional under `scram`, and on its own independent of the
password: a peer needs *some* certificate the CA issued and *some* valid
password, like PostgreSQL's `clientcert=verify-ca`. Setting `identity` (which
requires `tls.client_auth`) binds the two: the certificate's identity field must
equal the username, so every peer needs its own certificate *and* its own
password, like `clientcert=verify-full`. A mismatch fails the login; an HTTP
request whose certificate does not match its token's user gets `403`. Startup
shows the mode as `certificate=independent` or `certificate=cn=username`.
### Tokens
The `http` sink and `http_chain` source answer a login at `POST /auth` with a
bearer token: an HS256 JWT valid for `token_lifetime_ms` (default 15 minutes)
and signed with a key drawn when the plugin is built, so **every reload revokes
every token** (a rejected reload keeps the running service and its tokens). A
missing, invalid or expired token gets `401` with `WWW-Authenticate: Bearer
realm="logwisp"`. An SSE stream is checked when it connects and then outlives
its token; a reload ends it. TCP has no tokens: each connection runs its own
exchange.
### Throttling
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.
### Credentials file
```toml
# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.
decoy_key = "<base64, 32 random bytes>"
[[users]]
argon_memory = 65536
argon_threads = 4
argon_time = 3
salt = "<base64>"
server_key = "<base64>"
stored_key = "<base64>"
username = "edge-01"
```
Write it with [`logwisp auth add-user`](cli.md#logwisp-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
which users exist. `decoy_key` keeps unknown-user challenges stable across
restarts and edits; the CLI creates it once and preserves it. A single probe
cannot tell a real user from an unknown one, but a prober polling a name sees
its salt change when the user is added, rotated or removed. The file is read
only when the plugin is built and `auto_reload` does not watch it: send
`SIGHUP` after every change. Use one file per listener when listeners admit
different users.
### Rollout, rotation and revocation
- **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-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
every connection and revokes every token; other dialers log in again on
their own.
### Limits
- 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.
- 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`.
`test/scram-chain-test.sh --auto` exercises these guarantees end to end.
### Browsers behind a TLS-terminating proxy
A site that serves the log stream inside its own pages ends TLS at its reverse
proxy and need not give LogWisp a private key. `trusted_proxies` puts the `http`
sink in proxy mode, where browsers log in with the client LogWisp ships; the
password reaches neither the proxy nor LogWisp.
```toml
[pipelines.plugin_sinks.config]
host = "127.0.0.1"
port = 8081
login_page = true # GET /auth/login
viewer_page = true # GET /auth/view, needs login_page
[pipelines.plugin_sinks.config.auth]
type = "scram"
credentials_file = "/etc/logwisp/users.toml"
trusted_proxies = ["127.0.0.1"] # addresses or CIDRs
```
```nginx
location /logs/ {
proxy_pass http://127.0.0.1:8081/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
- **Trust.** Every other peer gets `403` on every path. `X-Forwarded-Proto`
must be `https` on every hop, so a site accidentally served in plaintext fails
closed. The client is the rightmost `X-Forwarded-For` hop that is not a
proxy; throttling, sessions and logs use it. List only the proxies: a client
inside a listed range is trusted to name its own hops.
- **Unbound logins.** The browser cannot see a certificate LogWisp could bind
to, so proofs are unbound and a party on the hop between proxy and LogWisp
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.
- **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.
`test/scram-proxy-test.sh --auto` logs in from headless Chromium through such a
proxy.
## What Each Layer Enforces
**`tls` with `client_auth = true`** — a membership check. The peer holds a
certificate chaining to `client_ca_file`, within its validity window, and holds
the matching private key. An attacker without a CA-issued certificate cannot
connect at all. What it does *not* decide is which CA-issued certificate: every
one is equivalent at this layer.
the matching private key. Every CA-issued certificate is equivalent at this
layer.
**`auth` with `type = "mtls"`** — an identity check, per listener:
**`auth` with `type = "mtls"`** — an identity check, per listener: only the
identities you list may connect, so one CA can serve several trust domains and
a single peer can be withdrawn without touching the others.
- only the identities you list may connect, so one CA can serve several trust
domains and a single peer can be withdrawn without touching the others
- the chain `node` label is bound to the certificate, so a compromised edge
cannot attribute its entries to another host
- the `http` sink's stream and status endpoints stop being open to anyone who
can reach the port
**`auth` with `type = "scram"`** — a password check, per listener: only the
users in its credentials file may connect, the login cannot be relayed through
another certificate (proxy mode, where TLS ends at the site's proxy, aside),
and with `identity` each user is tied to its own certificate.
**Revocation** is the allow-list, not a CRL. Remove the identity from `allow` /
`allow_patterns` and send `SIGHUP`: the reload rebuilds every pipeline, so the
Under either method the chain `node` label can be bound to the identity, so a
compromised edge cannot attribute its entries to another host, and the `http`
sink's stream and status endpoints stop being open to anyone who can reach the
port.
**Revocation** is the allow-list or the credentials file, not a CRL. Remove the
identity or user and send `SIGHUP`: the reload rebuilds every pipeline, so the
change takes effect on the next connection and existing ones are dropped by the
rebuild. No network call on the handshake path, and no window between revocation
and the next CRL publication. See
@@ -349,32 +595,33 @@ would add.
## Surfaces Without Access Control
An `auth` block closes each of these. Without one, bind them to a trusted
interface or front them with an authenticating proxy.
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 the CA vouches for, under any node label it claims |
| `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 |
`max_connections` bounds concurrency on all of them but does not distinguish
callers.
Note that `auth` requires `client_auth = true`, which requires TLS. There is no
way to authenticate a plaintext listener.
Both methods require TLS on the listener, except an `http` sink in proxy mode,
where TLS ends at the trusted proxy; `mtls` also requires `client_auth`.
## Operational Guidance
**Certificates**
**Certificates and secrets**
- Use a dedicated CA for LogWisp so its trust decisions stay independent.
- Keep leaf lifetimes short (90–825 days) and automate renewal.
- Key files should be `0600` and owned by the service account; a world-readable
one is reported at startup.
- Rotation requires a reload (`SIGHUP`), because certificates are loaded once at
plugin construction; there is no on-disk watch for certificate files.
- 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
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.
- Startup and every reload warn 30 days before a certificate lapses. Still
automate renewal; the warning only reaches someone reading the log.
- Keep the identity field you authorize on stable across rotations. Reissuing a
@@ -388,10 +635,11 @@ way to authenticate a plaintext listener.
verification entirely and makes the connection trivially interceptable. It is
reported at startup.
- Bind listeners to specific interfaces rather than `0.0.0.0` where you can.
- On any ingest port reachable from a network you do not fully control, set
`auth.type = "mtls"` with an explicit `allow` list. `trust_node = false` is the
fallback when certificates are not an option; it is unforgeable but labels
entries by remote address, which is useless behind NAT or a load balancer.
- On any ingest port reachable from a network you do not fully control, set an
`auth` block: `mtls` with an explicit `allow` list, or `scram`.
`trust_node = false` is the fallback when neither is an option; it is
unforgeable but labels entries by remote address, which is useless behind NAT
or a load balancer.
- Run LogWisp as an unprivileged user with write access only to its own log and
configuration directories.