diff --git a/CHANGELOG.md b/CHANGELOG.md index 482f9e54..f5e4c841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,22 @@ NAT64 spellings of them — were refused whatever this switch said, before and a not moved. What changed is that it is no longer the only thing left standing in a production deployment that copied the example. +### The supervisor answers on loopback, not on every address the host has + +The supervisor's port was published without an interface in front of it, so it bound every address +the machine had and answered anything that could route to it. This is the service that holds the +Docker socket, so reaching it is root on the host by way of four verbs, and `SUPERVISOR_TOKEN` is a +shared secret rather than a network boundary. The documentation already said not to expose it +outside the deployment network; the compose file did. + +It is now published on `127.0.0.1`, like the computer's own port and the two Bots'. If you reach the +supervisor from another machine, that stops working and it was the thing worth stopping: put the +caller on the host, or run the server inside the compose network, where it reaches the supervisor as +`supervisor:4300` and never uses the published mapping at all. `SUPERVISOR_PORT` still chooses the +host port. + +Nothing changes for a default deployment. `scripts/start.sh` already reached it on `localhost`. + ## 0.0.4 ### A click citing a ref this deployment cannot resolve is refused diff --git a/README.md b/README.md index c57b4af8..60b37f83 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you - **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail. - **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it. - **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events. -- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. +- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. The supervisor binds there too, because it holds the Docker socket and its token is a shared secret rather than a network boundary. - **Durable threads and memory**: conversations survive restarts through CopilotKit Intelligence, and each deployment stamps the threads it owns. ## Bring your own agent diff --git a/docker-compose.yml b/docker-compose.yml index 6fcd2a52..a832ffc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -147,7 +147,16 @@ services: SPIRE_AGENT_SOCKET_VOLUME: ${COMPOSE_PROJECT_NAME:-openbot}_spire-agent-socket ports: # For the server on the host to ask for a Bot's computer. - - "${SUPERVISOR_PORT:-4500}:4300" + # + # Loopback only, like the computer's own port and for a stronger version of the same reason. + # This process holds the Docker socket, so reaching it is root on the host through four verbs, + # and SUPERVISOR_TOKEN is a shared secret rather than a network boundary. Published without an + # interface in front of it this answered every address the host has. + # + # A deployment running the server inside this network does not use this mapping at all: it sets + # COMPUTER_NETWORK and reaches the supervisor as `supervisor:4300`, which is unaffected because + # the process still listens on every interface inside its own container. + - "127.0.0.1:${SUPERVISOR_PORT:-4500}:4300" volumes: # Read-only because this service only ever needs to ask; it is still root-equivalent, which is # the whole reason nothing else here gets it. diff --git a/docs/architecture.md b/docs/architecture.md index 6751e95f..9a0759b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ With `COMPUTER_SUPERVISOR_URL`, each Bot gets its own computer container, worksp A command on the computer inherits PATH, locale and terminal names, and the proxy variables, not the rest of the process environment. Userinfo is stripped from a proxy URL. `COMPUTER_SHELL_ENV` names anything else a deployment wants passed. -The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it. +The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network: Docker Compose binds it to `127.0.0.1:4500`, and a deployment running the server inside the compose network reaches it as `supervisor:4300` and needs no published port at all. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it. ## Human control and secrets diff --git a/supervisor/src/index.ts b/supervisor/src/index.ts index 779d19b6..41fc2e08 100644 --- a/supervisor/src/index.ts +++ b/supervisor/src/index.ts @@ -32,6 +32,13 @@ import { namesFor } from "./names"; * processes on the same network from driving it, but even with the token the worst available action * is cycling a computer that already belongs to a Bot. * + * Neither is the network the boundary, but it is the layer in front of both. Compose publishes this + * port on `127.0.0.1` rather than on every address the host has, for the same reason the computer's + * own port is bound there: a secret in an environment variable is one leak away from being known, + * and this process holds the Docker socket. This listener stays on every interface inside its own + * container, which is what the published mapping forwards to and what a server running inside the + * compose network connects to as `supervisor:4300`. + * * Refusing to start without it matches the computer. This process holds the Docker socket, which is * root on the host, so missing authentication is a deployment failure. */ diff --git a/tests/compose.test.ts b/tests/compose.test.ts index c80b6eba..ac18455f 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -38,6 +38,39 @@ test("publishes every service on a settable port with the documented default", ( } }); +/** + * The services that answer to a secret are published to the host's loopback and no further. + * + * A published port with no interface in front of it binds every address the host has, so the + * service answers anything that can route to the machine. That is the wrong default for all of + * these and worst for the supervisor, which holds the Docker socket: reaching it is root on the + * host by way of four verbs, and `SUPERVISOR_TOKEN` is a shared secret rather than a network + * boundary. The computer says the same thing about itself in a comment beside its own port, and + * this is that reasoning applied to every service that has one. + * + * Named ports rather than a blanket rule, so adding a service is a decision about where it should + * answer rather than something this test quietly grants. + */ +test("publishes every service that holds a secret on loopback only", () => { + const compose = readFileSync( + join(import.meta.dir, "..", "docker-compose.yml"), + "utf8", + ); + + for (const name of [ + "SUPERVISOR_PORT", + "COMPUTER_PORT", + "BOT_PORT", + "LANGGRAPH_PORT", + ]) { + const published = compose.match( + new RegExp(`^\\s*- "(.*)\\$\\{${name}:-\\d+\\}:\\d+"`, "m"), + ); + expect(published).not.toBeNull(); + expect(published?.[1]).toBe("127.0.0.1:"); + } +}); + /** * Both Bots are reachable at whatever `OPENAI_BASE_URL` names. *