Skip to content
Open
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
39 changes: 37 additions & 2 deletions src/tether/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1717,11 +1717,39 @@ def guard(
raise typer.Exit(1)


_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}


def _warn_insecure_bind(host: str, api_key: str) -> None:
"""Loudly warn when binding a non-loopback interface with no api_key.

The default host is 127.0.0.1 (localhost only). If an operator explicitly
binds 0.0.0.0 (or any non-loopback address) WITHOUT --api-key, the robot is
drivable by anyone on the network — surface that, don't let it pass silent.
"""
if host in _LOOPBACK_HOSTS:
return
if api_key:
return
err_console.print(
f"[bold yellow]⚠ SECURITY:[/bold yellow] serving on [bold]{host}[/bold] "
f"(reachable from the network) with [bold]no --api-key[/bold]. Anyone who "
f"can reach this host can send /act commands to the robot.\n"
f" Set [cyan]--api-key <key>[/cyan], or bind localhost only with "
f"[cyan]--host 127.0.0.1[/cyan] (the default).",
style=None, markup=True,
)


@app.command()
def serve(
export_dir: str = typer.Argument(help="Path to exported model directory"),
port: int = typer.Option(8000, help="Server port"),
host: str = typer.Option("0.0.0.0", help="Server host"),
host: str = typer.Option(
"127.0.0.1",
help="Server host. Defaults to localhost-only; pass 0.0.0.0 to expose "
"on the network (use --api-key when you do).",
),
transport: str = typer.Option(
"http",
"--transport",
Expand Down Expand Up @@ -2650,6 +2678,8 @@ def _run_mcp_http():
f"(streamable-http)[/bold green]"
)

_warn_insecure_bind(host, api_key)

if transport == "zmq":
console.print("[bold green]Starting ZMQ server...[/bold green]")
from tether.runtime.transports.zmq.factory import create_zmq_server
Expand Down Expand Up @@ -4122,7 +4152,11 @@ def go(
help="Where to cache weights. Default: ~/.cache/tether/models/<id>/",
),
port: int = typer.Option(8000, "--port", help="HTTP port for /act + /health"),
host: str = typer.Option("0.0.0.0", "--host"),
host: str = typer.Option(
"127.0.0.1", "--host",
help="Server host. Defaults to localhost-only; pass 0.0.0.0 to expose "
"on the network (use --api-key when you do).",
),
api_key: str = typer.Option("", "--api-key", help="If set, /act requires X-Tether-Key header"),
dry_run: bool = typer.Option(
False,
Expand Down Expand Up @@ -4372,6 +4406,7 @@ def go(
embodiment_config=embodiment_cfg,
api_key=api_key or None,
)
_warn_insecure_bind(host, api_key)
import uvicorn
uvicorn.run(app_instance, host=host, port=port, log_level="info")

Expand Down
47 changes: 47 additions & 0 deletions tests/test_serve_bind_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""serve/go bind to localhost by default + warn on insecure public binds."""
from __future__ import annotations

import inspect
from unittest.mock import MagicMock

from tether import cli


def _default(func, param: str):
p = inspect.signature(func).parameters[param].default
# typer.Option(...) returns an OptionInfo whose .default holds the value.
return getattr(p, "default", p)


def test_serve_and_go_default_host_is_localhost():
assert _default(cli.serve, "host") == "127.0.0.1"
assert _default(cli.go, "host") == "127.0.0.1"


def test_warns_on_public_bind_without_api_key(monkeypatch):
mock = MagicMock()
monkeypatch.setattr(cli, "err_console", mock)
cli._warn_insecure_bind("0.0.0.0", "")
assert mock.print.called


def test_no_warn_on_loopback(monkeypatch):
for host in ("127.0.0.1", "localhost", "::1"):
mock = MagicMock()
monkeypatch.setattr(cli, "err_console", mock)
cli._warn_insecure_bind(host, "")
assert not mock.print.called, host


def test_no_warn_when_api_key_set(monkeypatch):
mock = MagicMock()
monkeypatch.setattr(cli, "err_console", mock)
cli._warn_insecure_bind("0.0.0.0", "a-secret-key")
assert not mock.print.called


def test_warns_on_arbitrary_public_ip(monkeypatch):
mock = MagicMock()
monkeypatch.setattr(cli, "err_console", mock)
cli._warn_insecure_bind("192.168.1.10", "")
assert mock.print.called
Loading