v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f192c26fa2
commit
9ab8c356d2
60 files changed
+6791
-747
No files matched your search
+334
-86
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user