From c522e8b2aa0fdd5e3080349b83ce60b085f2d06c Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 4 Oct 2026 09:51:37 +0200 Subject: [PATCH] Refuse to open the agent port without TLS unless agent_insecure says so Without agent_tls the control plane served plaintext h2c on every interface, and any machine reaching it could register as any node. The server now refuses to start that way unless agent_insecure: true, and then binds loopback when agent_listen names only a port. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 7 ++++++ deploy/examples/server.yaml | 6 ++++-- docs/reference.md | 13 +++++++++++ internal/cli/server.go | 36 +++++++++++++++++++++++++++++-- internal/cli/server_agent_test.go | 29 +++++++++++++++++++++++++ internal/config/config.go | 9 ++++++++ internal/docgen/reference_gen.go | 1 + 7 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 internal/cli/server_agent_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b952d..1cd0d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ APIs change between minors without a deprecation period. architecture, checks the signature and the checksum, and hands it over. Releases are fetched over https only (loopback excepted), and `LLMGW_RELEASE_KEY` replaces the trusted key in development builds only. +- **The agent port is not opened without TLS by accident.** A control plane + with no `agent_tls` refused nothing: its agent port was plaintext, on every + interface, and any machine reaching it could register as any node — be + given replicas and so be sent prompts. It now refuses to start unless + `agent_insecure: true` is set, and then listens on loopback unless + `agent_listen` names an address. `server init` writes `agent_tls`, as + before. ### Everything that can be changed on a running cluster, from the console diff --git a/deploy/examples/server.yaml b/deploy/examples/server.yaml index a24e4e7..5f6edf4 100644 --- a/deploy/examples/server.yaml +++ b/deploy/examples/server.yaml @@ -17,8 +17,10 @@ agent_listen: ":7443" # Mutual TLS on the agent port (plan §5.6). With it, a node's identity is taken # from its certificate's common name and an agent claiming a different node id -# is refused. Without it the port is plaintext HTTP/2 and anyone who can reach -# it can claim to be any node, so it must stay on a trusted network. +# is refused. Without it the port would be plaintext HTTP/2 and anyone who can +# reach it could claim to be any node, so the control plane refuses to start +# unless `agent_insecure: true` is set — and then listens on loopback unless +# agent_listen names an address. That is for one machine or a test. # # These certificates come from llmgw's own authority, not a public one: public # authorities stopped issuing the clientAuth usage, so no Let's Encrypt diff --git a/docs/reference.md b/docs/reference.md index ab6c09b..9317f79 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -173,6 +173,7 @@ rather than repeated. | `threads` | `Threads` | [Server](#server) | | `landing` | `Landing` | [Server](#server) | | `agent_tls` | `tlsconf.Config` | [Server](#server) | +| `agent_insecure` | `bool` | [Server](#server) | | `monitoring` | `Monitoring` | [Server](#server) | | `tracing` | `Tracing` | [Server](#server) | | `mirror` | `Mirror` | [Server](#server) | @@ -1376,6 +1377,18 @@ trusted certificate, while this one is mutual and private. Takes a [tlsconf.Config](#tlsconfconfig) block. +### `agent_insecure` + +`bool` + +AgentInsecure lets a control plane run its agent port without +agent_tls: plaintext, and any machine that reaches it may register as +any node — be given replicas, and so be sent users' prompts — or, in +the other direction, a machine in the path may tell every agent what +to run. Refused unless said here, and then the port listens on +loopback unless agent_listen names an address. For one machine, a +test, or a network nothing untrusted reaches. + ### `monitoring` `Monitoring` diff --git a/internal/cli/server.go b/internal/cli/server.go index d8501c4..b7ee8d8 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -427,10 +427,20 @@ func newServerCmd() *cobra.Command { // network only. agentSrv.Handler = h2c.NewHandler(mux, &http2.Server{}) } - if agentLn, err = net.Listen("tcp", cfg.AgentListen); err != nil { + agentAddr, aerr := agentListenAddr(cfg) + if aerr != nil { _ = apiLn.Close() _ = adminLn.Close() - return fmt.Errorf("listening on %s: %w", cfg.AgentListen, err) + return aerr + } + if !cfg.AgentTLS.Enabled() { + log.Warn("the agent port has no TLS (agent_insecure): agents are not authenticated and "+ + "traffic is plaintext; use only on one machine or a trusted network", "listen", agentAddr) + } + if agentLn, err = net.Listen("tcp", agentAddr); err != nil { + _ = apiLn.Close() + _ = adminLn.Close() + return fmt.Errorf("listening on %s: %w", agentAddr, err) } if cfg.AgentTLS.Enabled() { tc, terr := cfg.AgentTLS.ServerConfig() @@ -761,3 +771,25 @@ func servableCheck(llamaVersion string) func(context.Context, catalog.Model) err return nil } } + +// errAgentInsecure is a control plane asked to open its agent port with no +// TLS and not told that is meant. +var errAgentInsecure = errors.New("agent_tls is not set, so the agent port would be plaintext and anyone " + + "reaching it could register as a node: set agent_tls (llmgw server init writes it), or " + + "agent_insecure: true for one machine or a test") + +// agentListenAddr is where the agent port listens: agent_listen with TLS; +// without it, refused unless agent_insecure says so, and then on loopback +// when agent_listen names only a port. +func agentListenAddr(cfg config.Server) (string, error) { + if cfg.AgentTLS.Enabled() { + return cfg.AgentListen, nil + } + if !cfg.AgentInsecure { + return "", errAgentInsecure + } + if host, port, err := net.SplitHostPort(cfg.AgentListen); err == nil && host == "" { + return net.JoinHostPort("127.0.0.1", port), nil + } + return cfg.AgentListen, nil +} diff --git a/internal/cli/server_agent_test.go b/internal/cli/server_agent_test.go new file mode 100644 index 0000000..47e8fb6 --- /dev/null +++ b/internal/cli/server_agent_test.go @@ -0,0 +1,29 @@ +package cli + +import ( + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf" +) + +func TestTheAgentPortOpensWithoutTLSOnlyWhenToldAndThenOnLoopback(t *testing.T) { + cfg := config.Default() + if _, err := agentListenAddr(cfg); err == nil { + t.Error("a control plane with no agent_tls opened its agent port") + } + cfg.AgentInsecure = true + if a, _ := agentListenAddr(cfg); a != "127.0.0.1:7443" { + t.Errorf("insecure, port only: %s", a) + } + cfg.AgentListen = "10.0.0.5:7443" + if a, _ := agentListenAddr(cfg); a != "10.0.0.5:7443" { + t.Errorf("insecure, an address named: %s", a) + } + cfg.AgentInsecure = false + cfg.AgentListen = ":7443" + cfg.AgentTLS = tlsconf.Config{CA: "ca.pem", Cert: "s.pem", Key: "s-key.pem"} + if a, err := agentListenAddr(cfg); err != nil || a != ":7443" { + t.Errorf("with TLS: %s %v", a, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index bf09a06..6fbccd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,14 @@ type Server struct { // cover the client-facing one: that surface is fronted by a publicly // trusted certificate, while this one is mutual and private. AgentTLS tlsconf.Config `koanf:"agent_tls"` + // AgentInsecure lets a control plane run its agent port without + // agent_tls: plaintext, and any machine that reaches it may register as + // any node — be given replicas, and so be sent users' prompts — or, in + // the other direction, a machine in the path may tell every agent what + // to run. Refused unless said here, and then the port listens on + // loopback unless agent_listen names an address. For one machine, a + // test, or a network nothing untrusted reaches. + AgentInsecure bool `koanf:"agent_insecure"` // Monitoring is where the dashboard reads its history from. Monitoring Monitoring `koanf:"monitoring"` @@ -592,6 +600,7 @@ func (s Server) Validate() error { if err := s.AgentTLS.Validate(); err != nil { return err } + if s.Auth.Enabled && s.Auth.Store == "" { return fmt.Errorf("auth.store is required when auth is enabled") } diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 408eb59..f6a9709 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -213,6 +213,7 @@ var Reference = []Block{ {Key: "threads", Type: "Threads", Doc: "Threads keeps conversations and agent memory without API keys, for a\ngateway on one trusted machine. With auth on they live in the auth store,\nper project, and this block is ignored.", Nested: "config.Threads"}, {Key: "landing", Type: "Landing", Doc: "Landing configures the page served at \"/\".", Nested: "config.Landing"}, {Key: "agent_tls", Type: "tlsconf.Config", Doc: "AgentTLS authenticates the fleet-facing port. It deliberately does not\ncover the client-facing one: that surface is fronted by a publicly\ntrusted certificate, while this one is mutual and private.", Nested: "tlsconf.Config"}, + {Key: "agent_insecure", Type: "bool", Doc: "AgentInsecure lets a control plane run its agent port without\nagent_tls: plaintext, and any machine that reaches it may register as\nany node — be given replicas, and so be sent users' prompts — or, in\nthe other direction, a machine in the path may tell every agent what\nto run. Refused unless said here, and then the port listens on\nloopback unless agent_listen names an address. For one machine, a\ntest, or a network nothing untrusted reaches.", Nested: ""}, {Key: "monitoring", Type: "Monitoring", Doc: "Monitoring is where the dashboard reads its history from.", Nested: "config.Monitoring"}, {Key: "tracing", Type: "Tracing", Doc: "Tracing sends a trace of each inference request to an OpenTelemetry\ncollector. Off unless an endpoint is set here or in\nOTEL_EXPORTER_OTLP_ENDPOINT.", Nested: "config.Tracing"}, {Key: "mirror", Type: "Mirror", Doc: "Mirror points the control plane at a model mirror it can administer.\nEmpty means there is none, and /admin/mirror stays unmounted.", Nested: "config.Mirror"},