picatz / picatz/flowstate

A listener plugin surface does not earn its keep: the requests behind it are a closed set of schemes

Open
#583 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

engine kind/decision
Dominant language
Go
Stars
9
Forks
0
Avg merge
3h 3m
Merged PRs (30d)
509

Description

Raised on #549's track as a candidate: an extensible plugin surface for listeners, so a third party can supply the socket flow server accepts on. This issue argues the answer is no, and that the real requests behind it are a finite list satisfied by parsing a scheme on --listen. Filing it as a decision to be made rather than a feature to be built, because "we considered a listener plugin and here is why not" is worth more written down than discovered again in six months.

Verified against origin/main at write time: flow server binds through http.Server.ListenAndServe with no explicit net.Listen; the only net.Listen("tcp", …) in non-test code is cmd/flow/serverdev.go:741, and the only other listen in the tree is the plugin SDK's own Unix socket (pkg/flowstate/v1/plugin/sdk/sdk.go:647). #569 adds net.Listen("tcp", httpServer.Addr) for the public listener; the network is still the string "tcp".

What the plugin model is for, and why a listener is outside it

docs/ARCHITECTURE.md's plugin section makes one argument for out-of-process plugins and it is worth quoting exactly, because it does not apply here: "A plugin is someone else's code running inside a worker that holds credentials and can reach internal networks. In-process, a panic takes the worker down… Out of process, those are the operating system's problem."

That trade works because a task plugin's work is a request/response over a socket. A listener's work is the socket. A net.Listener is not serializable; to have a plugin produce one you pass file descriptors over the existing Unix socket with SCM_RIGHTS, and then the accepted connection is served by the engine anyway. The isolation the plugin model buys — a crash is the OS's problem — does not exist, because the failure modes that matter (a listener that never accepts, that accepts and stalls, that leaks descriptors) are all inside our process by the time they matter.

The second half of the same section is the part that decides it: "a plugin is trusted code the moment it is launched, running with the worker's own process authority — the isolation a separate process buys is against a crash or a resource leak, not against code that is doing exactly what its author wrote on purpose." That is stated about the worker. A listener plugin puts third-party code in the accept path of the control plane, which is the process holding the signing key that mints workload assertions (pkg/flowstate/v1/auth/issuer.go) and the process every bearer token is presented to. The blast radius is categorically larger than the model was designed for, and the model's own justification does not extend to cover it.

And there is a version of this that cannot be made safe at all. A listener plugin that terminates TLS is a plugin that reads every credential the deployment receives, in plaintext, before authentication. Not as a bug — as its job. The only listener plugin whose posture is defensible is one that hands over a raw file descriptor it never reads from, and that is socket activation, which the operating system already implements and does not need a protocol from us.

What people actually want

The requests that hide behind "listener plugins" are finite and knowable, and every one of them is a scheme, not an extension point:

the ask why what it needs
Unix domain socket a sidecar or an nginx on the same host, no port net.Listen("unix", path)
systemd / launchd socket activation privileged port without a privileged process; zero-downtime restart net.FileListener on $LISTEN_FDS
Cloud Run, fly.io, Heroku the platform injects $PORT one os.Getenv("PORT") — already a documented gap at docs/DEPLOYMENT.md:429-435
PROXY protocol v2 behind an L4 load balancer, recover the real client IP a wrapping net.Listener
h2c plaintext HTTP/2 inside a mesh that terminates TLS an http2.Server handler wrap
QUIC / HTTP/3 later, if ever a different server, not a different listener
tsnet / Tailscale reachable on a tailnet with no public exposure a library import, in the operator's own main

Six of the seven are a two-line switch on a scheme, in-process, with no third-party code anywhere:

--listen tcp://0.0.0.0:9233        # the default spelling, and a bare host:port keeps working
--listen unix:///run/flowstate.sock
--listen fd://3                    # or systemd://flowstate.socket

That is a closed set the project can reason about, test, and hold a security position on. Each addition is a reviewed PR against a known threat model rather than an interface promising that anything at all may appear there.

// Illustrative, not the landed shape. The whole of the "extension point",
// and it is deliberately not exported.
//
// One place to answer "what does this address mean", so the loopback check
// the plaintext refusal depends on is asked once. A unix socket is loopback
// by construction; an fd:// listener is whatever systemd bound it to, which
// this process cannot see and must therefore refuse to assume is private.
func listenerFor(addr ListenAddress) (net.Listener, error)

That last comment is not a detail. #569's refusePlaintextListener decides whether to allow plaintext by asking whether the address is loopback. A unix:// socket answers yes correctly and for a better reason than a TCP loopback bind does — filesystem permissions, no network stack at all. An fd:// listener genuinely cannot answer, because the socket was bound by something else, which makes it the case where the refusal must fail closed and demand either TLS or an explicit acceptance. A plugin returning an opaque net.Listener cannot answer that question at all, for any deployment. That is the strongest argument against the plugin surface in the whole issue: the security check that already exists has no way to interrogate a listener it did not create.

The narrow in-process seam that does earn its keep

There is one real gap the scheme list does not cover: an operator who wants tsnet, or a cloud provider's SDK listener, or something nobody has thought of. The answer for them is a Go API, not a plugin protocol.

// Illustrative. In the server package, for people who import flowstate
// rather than run the binary.
//
// The trust decision is the operator's own compiler: code reaches the accept
// path only by being linked into a binary they built. That is a stronger
// consent than a directory of executables, and it costs one exported type
// and no wire contract.
type ListenerFunc func(ctx context.Context) (net.Listener, error)

func WithListener(fn ListenerFunc) ServerOption

This is the same shape secrets.Provider has and for the same stated reason — docs/ARCHITECTURE.md calls a secrets backend "one interface with one method, plus the scheme it answers for". The difference is which side of the process boundary it sits on, and the reason for the difference is that a secret crosses a boundary as a value while a listener does not cross one at all.

If this seam is built, cmd/flow should not use it. The binary's listener set stays the closed scheme list, so the shipped artifact's attack surface is not a function of what somebody could have linked in.

If the answer is yes anyway

Recording the posture so a future decision is informed rather than re-derived. An out-of-process listener plugin needs: the plugin launched and healthy before the server binds anything, so a failing plugin is a startup failure rather than a half-served deployment; file descriptors passed over the existing Unix socket, with the plugin never reading from the connection it hands over — unenforceable, which is the problem; TLS terminated on our side of the boundary without exception, since a plugin that terminates it sees every token; and no way for a plugin-supplied listener to satisfy the loopback question refusePlaintextListener asks, so every plugin listener is treated as public and requires TLS or an explicit acceptance. That last constraint alone removes most of the motivating use cases, which is itself evidence that the shape is wrong.

Recommendation

Do not build a listener plugin surface. Build --listen <scheme>://… with tcp, unix and fd, add $PORT support for the platforms that inject it, and expose an in-process ListenerFunc option for library consumers if and when someone asks. Revisit only if a concrete request arrives that a scheme cannot serve — and note that no such request has arrived yet, which is worth saying out loud, since this issue exists because the idea was raised rather than because a deployment needed it.

Questions

  1. Decision: no listener plugin, as argued? Recommended yes, with the schemes built instead.
  2. Which schemes in the first cut: tcp and unix, or fd as well? Recommended tcp and unix first — fd needs the fail-closed rule about an address whose reachability this process cannot determine, and that rule is better landed with a real socket-activation deployment to test against.
  3. $PORT: read it, or keep the documented entrypoint-script workaround? Recommended read it, on flow server only, ranked below an explicit --listen. It is one line and it removes a documented blocker.
  4. The in-process ListenerFunc: ship it now or wait for the first asker? Recommended wait. It is cheap enough that building it speculatively is not the problem; the problem is that an exported extension point with no consumer gets designed against nothing.

Generated by Claude Code


Generated by Claude Code

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read docs/ARCHITECTURE.md and the listener paths in cmd/flow/serverdev.go, plus pkg/flowstate/v1/auth/issuer.go and the deployment notes at docs/DEPLOYMENT.md:429-435. Confirm how the current HTTP listener and plaintext refusal work, then review the proposed tcp, unix, fd, and $PORT scope. Done means the project records a decision and an agreed implementation scope rather than leaving the plugin question open.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
backend, networking, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.