10 KiB
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 besidetlsxandauthz(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, likeauthz.(*Policy).Listener(ln net.Listener) net.Listenerwraps the raw listener before TLS:- TCP plugins wrap it before
tls.NewListener. - HTTP plugins wrap it before
Serve/ServeTLS.
- TCP plugins wrap it before
Acceptreads an optional PROXY header (1.2), decides allow or deny (1.3), applies per-address connection caps (1.4), and returns anet.ConnwhoseRemoteAddris the real client.- Everything downstream then sees the real client with no further change:
authz.remoteIP, SCRAM throttling, sessions, logs andhttp.Request.RemoteAddr. - Keep the proxy's own address as
peer_addrin 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
streamwithssl_preread, HAProxymode 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_fromaddresses 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/UNKNOWNcommands (proxy health checks) keep the socket address.- TLVs are ignored.
- A listed peer must send the header when
- The header precedes the TLS ClientHello, so SCRAM channel binding is unaffected: LogWisp still terminates TLS.
- nginx side:
- In the
streamserver,proxy_protocol onon the route to LogWisp (it sends v1). - HAProxy:
send-proxyorsend-proxy-v2.
- In the
- Relation to
auth.trusted_proxies(HTTP proxy mode):- That list trusts
X-Forwarded-Forfrom a TLS-terminating L7 proxy;proxy_fromtrusts 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
streamwithproxy_protocolinto an nginxhttpblock that setsX-Forwarded-For $proxy_protocol_addr.
- That list trusts
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-Foris applied byauthz.ClientAddrafter the connection-level decision. - Address rules for forwarded HTTP clients therefore belong in a second,
request-level check inside
authz.ClientAddr: reuse the compilednetaclrules there rather than duplicating them.
- In HTTP proxy mode,
- Startup warnings (in
LogStartupstyle): an allow list containing0.0.0.0/0or::/0,proxy_fromcovering public ranges, a listener on a wildcard host with noallow.
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 viathrottleKey) into the shared per-key limiter. One mechanism (AGENTS.md): SCRAM throttling then uses it too. - Keys:
max_connections_per_clienton every listener (concurrent connections per address).requests_per_second_per_clienton 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]besidetlsandauthon the four listener plugins.- Decode through
config.Scan, so unknown keys fail. - Dialers have no ACL block.
- Decode through
- Intent rule as in
authz: an ACL key on a dialer, orproxy_fromwithoutproxy_protocol, is an error. - Stats:
acl_denied,acl_proxy_headers,acl_limited, merged into the plugin stats likeauthz.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;
requiredwithout a header refused;LOCALkept. - 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
streamwithproxy_protocol onwhen nginx is installed. - Denied and allowed clients; per-client SCRAM budgets behind one proxy address.
2. Flush network sinks at shutdown
At the end of a finite input lw shuts down, and the http and tcp sinks
disconnect their clients without writing the entries still queued for them, so
a client can miss the last ones. On Stop, each sink's broker should first move
its input queue into the client queues, then let every client writer drain its
queue within a bound (write_timeout_ms) before the disconnect frame. Verify
with a finite stdin into each sink and a connected client counting lines.
3. 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.
maxValueDepthandMaxValueSize, 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.
4. Packaging: AUR, FreeBSD ports, Debian
The foundation exists:
- The
lwbinary name, free in Arch (official repositories and AUR), Ubuntu 24.04 and 26.04, and the FreeBSD 15.1 ports tree. - The canonical module path
github.com/lixenwraith/logwisp, whichgo install, FreeBSD'sUSES=go:modulesand Debian's dh-golang expect. make installwithDESTDIR,PREFIXandSYSCONFDIR.- The
doc/lw.1manual. - Service files in
deploy/package/: systemd unit, sysusers, tmpfiles and the FreeBSD rc.d script. - Skeletons in
deploy/package/arch/anddeploy/package/freebsd/.
What remains. Steps 1 to 3 are the maintainer's: they need push rights and an identity, and both skeletons download the tagged source.
- Tag the release on the merged main commit,
vX.Y.Z:git tag -a vX.Y.Z -m vX.Y.Z # git tag -s signs it with your key git push origin vX.Y.Zmakethen stampsvX.Y.Z(fromgit describe), andgo install github.com/lixenwraith/logwisp/cmd/lw@vX.Y.Zworks.- A GitHub release for the tag is optional; the tag alone serves
archive/vX.Y.Z.tar.gz, which the PKGBUILD downloads.
- Arch (AUR),
deploy/package/arch/PKGBUILD:pkgver=X.Y.Zand the# Maintainer: Name <email>line.updpkgsums(pacman-contrib) replacessha256sums=('SKIP')with the tarball's checksum.namcap PKGBUILD, thenmakepkg -si(builds, runscheck(), installs), thenmakepkg --printsrcinfo > .SRCINFO.- Push
PKGBUILDand.SRCINFOtossh://aur@aur.archlinux.org/logwisp.git; optionally alogwisp-gitpackage built from the main branch.
- FreeBSD,
deploy/package/freebsd/(sysutils/logwisp):DISTVERSION=X.Y.ZandMAINTAINER=your address.make makesumwritesdistinfo: the module zip that the Go proxy serves forGO_MODULEat the tag.- The
logwispuser needs an ID registered in the ports tree. Take a number below 1000 that is free in both/usr/ports/UIDsand/usr/ports/GIDs, and add to the same patch:UIDs: logwisp:*:NNN:NNN::0:0:LogWisp daemon:/nonexistent:/usr/sbin/nologin GIDs: logwisp:*:NNN: portlint -AC,poudriere testporton 14.x and 15.x jails, then a Bugzilla report with the port directory and the UIDs/GIDs diff.
- Debian:
- Debian policy wants every Go dependency packaged; the four
lixenwraithlibraries are not. - Either package them too (dh-golang), or start with an
.debbuilt by the Makefileinstalltarget (nfpm ordpkg-deb) and a PPA, and move to the archive later.
- Debian policy wants every Go dependency packaged; the four
- 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 --versionandmake image-check.