Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ and rename that heading to the version when you cut the release.

### Security

- **A non-loopback `--public-url` now arms the same credentials guard a
non-loopback bind does.** The guard only ever looked at the bind address, so
a hub on `127.0.0.1` exposed through a Cloudflare Tunnel, ngrok or a local
reverse proxy came up without a word when started with
`--public-url https://hub.example.net` and no `--agent-key`: `/register`,
`/peers` and `/watch-ticket/redeem` open to anyone who reached the tunnel,
and the dashboard granting operator rights to any browser that did. The hub
already read that URL as "this is remote" when deciding what `watch_command`
hands a remote agent; the credentials gate now agrees, and refuses to start
without both `--operator-token` and `--agent-key`. A loopback public URL
(`http://localhost:8765`) is just a nicer address for this machine and arms
nothing, and `--allow-insecure-bind` still starts anyway. `check_bind` in
`caucus-setup-service` applies the same rule, so the installer refuses the
configuration instead of writing a unit that cannot start.
- **`/peers`, `/channels`, `/forms` and `/ping` now require the shared agent
key when one is configured** (an operator or observer token is accepted
too). On a keyed non-loopback hub, these endpoints previously handed anyone
Expand Down
12 changes: 7 additions & 5 deletions docs/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Hub-side (`caucus-hub`):
| `--allowed-origin ORIGIN` (repeatable) | `CAUCUS_ALLOWED_ORIGINS` (comma-separated) | loopback only | A browser console opened from a non-loopback origin gets its `/ui` handshake closed with WebSocket code 1008, and its `/mcp` CORS preflight goes unanswered |
| `--public-url URL` | `CAUCUS_PUBLIC_URL` | unset (advertises the bind address) | `watch_command` and every tool's `hub` field hand a remote agent a `127.0.0.1` address it cannot reach |
| `--mcp-http` / `--no-mcp-http` | `CAUCUS_MCP_HTTP` | on for a loopback bind, off otherwise | `/mcp` is not mounted at all on a non-loopback bind unless this is passed explicitly |
| `--allow-insecure-bind` | (none) | off | A non-loopback `--host` refuses to start unless both `--operator-token` and `--agent-key` are already set, and a wildcard bind (`0.0.0.0` or `::`) also needs `--public-url` |
| `--allow-insecure-bind` | (none) | off | A non-loopback `--host`, or a non-loopback `--public-url` on any bind, refuses to start unless both `--operator-token` and `--agent-key` are already set, and a wildcard bind (`0.0.0.0` or `::`) also needs `--public-url`. A loopback `--public-url` (`http://localhost:8765`) arms nothing |
| `--client-ttl SECONDS` | (none) | `300` | The idle reaper drops a peer sooner or later than expected; a WAN agent slower than this to re-poll loses its slot mid-conversation |

Throughout, "loopback" is one definition shared by the whole package
Expand Down Expand Up @@ -207,10 +207,12 @@ wall.

`caucus-hub`'s `uvicorn.run(...)` call takes no TLS arguments: there is no
`--tls-cert` flag to reach for. Terminate TLS in front of it instead. Because
the reverse proxy is what faces the network, the hub itself can stay bound
to loopback, which sidesteps its own non-loopback-bind refusal entirely; set
`--agent-key` and `--operator-token` anyway, since the hub is reachable from
the network the moment the proxy is:
the reverse proxy is what faces the network, the hub itself stays bound to
loopback — but it still demands `--agent-key` and `--operator-token`, because
`--public-url https://hub.example.net` is you telling it that agents on other
machines dial it. That is the same exposure a non-loopback bind is, reached
through the proxy instead of the socket, and the hub refuses to start without
both credentials:

```bash
export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)"
Expand Down
156 changes: 128 additions & 28 deletions src/caucus/hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -3084,6 +3084,40 @@ def _collect_allowed_hosts(cli: list[str] | None, port: int) -> list[str]:
return hosts


def _doors_block(*, operator: bool, agent: bool, requirement: str) -> str:
"""Render the two-doors inventory shared by both credential refusals.

The flag names, the environment variables and the ``openssl`` lines have to
read identically whether the hub was refused for its bind address or for the
public URL it advertises; spelling them twice is how one of the two ends up
naming a flag that was renamed in the other.

Args:
operator: Whether an operator token is already configured.
agent: Whether an agent key is already configured.
requirement: The sentence introducing the generation snippet, which
names *why* both credentials are demanded in this particular case.

Returns:
The doors inventory, the requirement sentence and the two ``export``
lines, ending with a newline.
"""
mark = {True: "already set", False: "MISSING"}
return (
" agent door /register and /mcp - join the room, read everything\n"
" said in it\n"
f" --agent-key KEY (env CAUCUS_AGENT_KEY): {mark[agent]}\n"
" operator door /ui - pause, stop, kick, read the whole transcript\n"
" --operator-token TOKEN (env CAUCUS_OPERATOR_TOKEN):"
f" {mark[operator]}\n"
"\n"
f"{requirement} Generate and set them:\n"
"\n"
' export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)"\n'
' export CAUCUS_OPERATOR_TOKEN="$(openssl rand -hex 24)"\n'
)


def _insecure_bind_message(
host: str,
*,
Expand Down Expand Up @@ -3134,18 +3168,12 @@ def _insecure_bind_message(
"from other machines, and by default neither of its two doors is locked\n"
"nor does it know the address to hand those machines.\n"
"\n"
" agent door /register and /mcp - join the room, read everything\n"
" said in it\n"
f" --agent-key KEY (env CAUCUS_AGENT_KEY): {mark[agent]}\n"
" operator door /ui - pause, stop, kick, read the whole transcript\n"
" --operator-token TOKEN (env CAUCUS_OPERATOR_TOKEN):"
f" {mark[operator]}\n"
"\n"
"Both are required on a non-loopback bind. Generate and set them:\n"
"\n"
' export CAUCUS_AGENT_KEY="$(openssl rand -hex 24)"\n'
' export CAUCUS_OPERATOR_TOKEN="$(openssl rand -hex 24)"\n'
f" caucus-hub --host {host}"
+ _doors_block(
operator=operator,
agent=agent,
requirement="Both are required on a non-loopback bind.",
)
+ f" caucus-hub --host {host}"
+ (" --public-url https://hub.example.net\n" if wildcard else "\n")
+ reach
+ "\n"
Expand All @@ -3155,6 +3183,52 @@ def _insecure_bind_message(
)


def _insecure_public_url_message(
public_url: str,
*,
operator: bool,
agent: bool,
) -> str:
"""Compose the refusal shown when an advertised hub is under-configured.

The sibling of :func:`_insecure_bind_message` for the deployment where the
socket is *not* the exposure: the hub binds to loopback and something in
front of it (a tunnel, a reverse proxy, a port forward) carries the outside
world in. Nothing about the bind address betrays that, so the only signal
the hub has is the operator declaring a public URL that is not loopback --
and that declaration has to be taken as seriously as a non-loopback bind,
because the doors behind it are exactly as open.

Args:
public_url: The non-loopback base URL the operator asked to advertise.
operator: Whether an operator token is already configured.
agent: Whether an agent key is already configured.

Returns:
The multi-line refusal text, ready to hand to ``parser.error``.
"""
return (
f"refusing to start: --public-url {public_url} says agents on other\n"
"machines dial this hub, and by default neither of its two doors is\n"
"locked. The bind is loopback, so the socket is not the exposure --\n"
"whatever sits in front of it is, and anything reaching that front\n"
"reaches both of these:\n"
"\n"
+ _doors_block(
operator=operator,
agent=agent,
requirement="Both are required once the hub is advertised off-box.",
)
+ f" caucus-hub --host 127.0.0.1 --public-url {public_url}\n"
"\n"
"Not what you meant? A loopback public URL (http://localhost:8765) is\n"
"just a nicer address for this machine and needs none of this.\n"
"Meant it, behind something that already authenticates? "
"--allow-insecure-bind\n"
"starts anyway, with both doors open."
)


def _mount_mcp_http(
*,
host: str,
Expand Down Expand Up @@ -3347,9 +3421,10 @@ def main() -> None:
"--allow-insecure-bind",
action="store_true",
help=(
"start on a non-loopback address without --operator-token and "
"--agent-key. Both doors stay open to anything that can reach the "
"port; only for a network that already authenticates"
"start without --operator-token and --agent-key on a non-loopback "
"address, or behind a non-loopback --public-url. Both doors stay "
"open to anything that can reach the hub; only for a network that "
"already authenticates"
),
)
parser.add_argument(
Expand Down Expand Up @@ -3420,24 +3495,49 @@ def main() -> None:
# anything dials, so without --public-url the hub would advertise the
# 127.0.0.1 rewrite and hand every remote agent a watcher command pointing
# back at its own machine. Fix that at the source rather than downstream.
# A loopback bind is not proof the hub is unreachable: behind a tunnel or a
# reverse proxy the socket stays on 127.0.0.1 while the world dials the
# front. The one thing the operator tells us in that shape is --public-url,
# and _mount_mcp_http already reads a non-loopback one as "remote" (see its
# `remote=` argument). The credentials gate has to read it the same way, or
# the exact deployment that most needs both doors locked is the one that
# starts without either. A loopback public URL is just a prettier address
# for this machine and arms nothing.
wildcard = args.host in _WILDCARD_HOSTS
# Parsed defensively: the value is still unvalidated here, and one that
# urlparse finds no hostname in must fall through to validate_public_url
# below rather than be read as an exposure. A URL that *does* name a
# non-loopback host but fails validation for another reason (a path, say)
# hits this gate first, which is the right order: missing credentials on an
# advertised hub outrank the shape of the address being advertised.
advertised_host = urlparse(public_url).hostname if public_url else None
advertised_remote = (
public_url
if advertised_host is not None and not is_loopback_host(advertised_host)
else None
)
under_configured = not (args.operator_token and args.agent_key) or (
wildcard and not public_url
)
if (
not is_loopback_host(args.host)
and not args.allow_insecure_bind
and under_configured
):
parser.error(
_insecure_bind_message(
args.host,
operator=bool(args.operator_token),
agent=bool(args.agent_key),
public_url=bool(public_url),
wildcard=wildcard,
if not args.allow_insecure_bind and under_configured:
if not is_loopback_host(args.host):
parser.error(
_insecure_bind_message(
args.host,
operator=bool(args.operator_token),
agent=bool(args.agent_key),
public_url=bool(public_url),
wildcard=wildcard,
)
)
elif advertised_remote is not None:
parser.error(
_insecure_public_url_message(
advertised_remote,
operator=bool(args.operator_token),
agent=bool(args.agent_key),
)
)
)

if public_url is not None:
try:
Expand Down
31 changes: 28 additions & 3 deletions src/caucus/setup_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import urllib.request
from pathlib import Path
from typing import Any, Literal
from urllib.parse import urlparse

from .urlguard import is_loopback_host, validate_public_url

Expand Down Expand Up @@ -377,6 +378,12 @@ def check_bind(
operator finds out while installing, not from a unit that loads and then
exits.

A loopback bind behind a non-loopback ``--public-url`` is the same exposure
reached a different way -- a tunnel or a reverse proxy carries the world to
a socket that never left this machine -- and ``caucus-hub`` refuses it at
startup for that reason. Refusing it here too is what keeps the installer
from writing a unit that cannot start.

Args:
host: Address the hub would bind to.
operator_token: Token that would gate operator access, if any.
Expand All @@ -385,14 +392,32 @@ def check_bind(
public_url: Base URL agents would be told to reach the hub at, if any.

Raises:
SetupError: For a non-loopback host missing a credential, or a wildcard
host with no public URL.
SetupError: For a non-loopback host missing a credential, a wildcard
host with no public URL, or a loopback host advertised under a
non-loopback public URL without both credentials.
"""
if is_loopback_host(host):
advertised_host = urlparse(public_url).hostname if public_url else None
advertised_remote = advertised_host is not None and not is_loopback_host(
advertised_host
)
if is_loopback_host(host) and not advertised_remote:
return
if operator_token and agent_key and (public_url or host not in WILDCARD_HOSTS):
return
wildcard = host in WILDCARD_HOSTS
if is_loopback_host(host):
raise SetupError(
f"refusing to advertise {public_url} without --operator-token and\n"
"--agent-key. The bind stays on loopback, but a public URL says\n"
"agents on other machines dial this hub, and whatever carries them\n"
"in reaches a dashboard that grants full operator rights to any\n"
"browser and a /register any client can walk through.\n"
"Drop --public-url, or run:\n"
f" caucus-setup-service --host {host} \\\n"
' --operator-token "$(openssl rand -hex 24)" \\\n'
' --agent-key "$(openssl rand -hex 24)" \\\n'
f" --public-url {public_url}"
)
raise SetupError(
f"refusing to bind {host} without --operator-token and --agent-key"
+ (" and --public-url.\n" if wildcard else ".\n")
Expand Down
Loading
Loading