diff --git a/Dockerfile b/Dockerfile index 75c0d46..b27524f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PATH="/opt/vidxp/bin:${PATH}" \ PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + VIDXP_HTTP_PORT=8000 USER vidxp WORKDIR /var/lib/vidxp diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index d8ddca4..f0f50c7 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -179,10 +179,19 @@ vidxp doctor --modalities dialogue,actor ### 5. Start the selected surface - CLI: `vidxp --help` -- Browser UI: `vidxp ui` -- Local HTTP API and remote MCP: `vidxp-api` +- Loopback browser UI: `vidxp ui` +- LAN-shared unauthenticated browser UI: `vidxp ui --share` +- Local HTTP API and MCP: `vidxp-api` +- LAN-shared authenticated HTTP API and MCP: `vidxp-api --share` - Local stdio MCP: `vidxp-mcp` +The browser UI binds to loopback unless `--share` is present. In share mode, +VidXP gives Streamlit an explicit wildcard bind and Streamlit prints both the +Local and Network URLs. The UI has no authentication, so share it only on a +trusted network. +VidXP suppresses Streamlit's first-run email prompt and disables Streamlit +usage-statistics collection for this managed launch. + ## First CLI index ```bash @@ -263,15 +272,31 @@ Install `local-worker,server`, prepare models, then run: vidxp-api ``` +The default is reachable only from the same machine. To deliberately share it +on the machine's detected LAN address, run `vidxp-api --share`. Share mode: + +- generates and then reuses an app-owned bearer token; +- binds Uvicorn to the detected LAN address; +- configures the HTTP and MCP Host-header policies for that address; and +- prints the exact health URL, Streamable HTTP MCP URL, and bearer token. + +The managed token is stored as `api-share-token` in VidXP's platform-native +configuration directory. Share mode uses plain HTTP and is intended for a +trusted local network; use the supported reverse-proxy deployment when TLS is +required. + The unauthenticated local default is deliberately loopback-only: | Endpoint | Purpose | |---|---| -| `http://127.0.0.1:8000/docs` | Interactive OpenAPI | -| `http://127.0.0.1:8000/openapi.json` | Machine-readable contract | -| `http://127.0.0.1:8000/health` | Process liveness | -| `http://127.0.0.1:8000/ready` | Aggregate runtime readiness | -| `http://127.0.0.1:8000/mcp` | Streamable HTTP MCP | +| `http://127.0.0.1:32191/docs` | Interactive OpenAPI | +| `http://127.0.0.1:32191/openapi.json` | Machine-readable contract | +| `http://127.0.0.1:32191/health` | Process liveness | +| `http://127.0.0.1:32191/ready` | Aggregate runtime readiness | +| `http://127.0.0.1:32191/mcp` | Streamable HTTP MCP | + +Native installs default to port `32191` to avoid the heavily reused development +port `8000`. Use `vidxp-api --port ` when a specific port is required. Do not bind an unauthenticated API to a non-loopback address. Public deployments require static bearer or OIDC authentication and should use the diff --git a/README.md b/README.md index fff63e4..401136f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ uv tool install --python 3.14 --torch-backend cpu \ vidxp ui ``` +`vidxp ui` binds to loopback by default. Use `vidxp ui --share` only when you +intend to expose the unauthenticated browser interface on the local network. +Streamlit prints its Local and Network URLs when it starts. VidXP disables +Streamlit's first-run email prompt and usage-statistics collection. + If the `vidxp` command is not found, run `uv tool update-shell` once and reopen the terminal. diff --git a/src/vidxp/api.py b/src/vidxp/api.py index 8431d5c..7f4d86d 100644 --- a/src/vidxp/api.py +++ b/src/vidxp/api.py @@ -91,6 +91,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: def health() -> HealthResponse: return HealthResponse() + @app.get( + "/favicon.ico", + include_in_schema=False, + status_code=204, + ) + def favicon() -> Response: + return Response(status_code=204) + @app.get( "/ready", response_model=ReadinessResponse, diff --git a/src/vidxp/api_cli.py b/src/vidxp/api_cli.py index 4b49c32..e482041 100644 --- a/src/vidxp/api_cli.py +++ b/src/vidxp/api_cli.py @@ -4,6 +4,21 @@ from collections.abc import Sequence from pathlib import Path +from vidxp.network_share import ( + load_or_create_api_share_token, + primary_lan_address, +) + + +def _port(value: str) -> int: + try: + port = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("port must be an integer") from exc + if not 1 <= port <= 65535: + raise argparse.ArgumentTypeError("port must be between 1 and 65535") + return port + def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -17,9 +32,65 @@ def _arguments(values: Sequence[str] | None = None) -> argparse.Namespace: type=Path, help="Store VidXP models and the default repository here.", ) + parser.add_argument( + "--port", + type=_port, + metavar="PORT", + help="Listen on this port instead of the VidXP default.", + ) + parser.add_argument( + "--share", + action="store_true", + help=( + "Share the API and MCP on this machine's LAN address using an " + "app-managed bearer token." + ), + ) return parser.parse_args(values) +def _shared_settings(settings, *, host: str, token: str): + from vidxp.settings import HttpAuthMode, VidXPSettings + + if settings.http_auth_mode not in {HttpAuthMode.none, HttpAuthMode.static}: + raise ValueError( + "--share supports the app-managed static bearer mode, not OIDC." + ) + active_token = ( + settings.http_static_bearer_token.get_secret_value() + if settings.http_auth_mode == HttpAuthMode.static + and settings.http_static_bearer_token is not None + else token + ) + payload = settings.model_dump(mode="python") + payload.update( + { + "http_bind_host": host, + "http_auth_mode": HttpAuthMode.static, + "http_static_bearer_token": active_token, + "http_trusted_hosts": tuple( + dict.fromkeys((*settings.http_trusted_hosts, host)) + ), + "mcp_allowed_hosts": tuple( + dict.fromkeys((*settings.mcp_allowed_hosts, f"{host}:*")) + ), + } + ) + return VidXPSettings.model_validate(payload), active_token + + +def _print_share_details(settings, token: str) -> None: + origin = f"http://{settings.http_bind_host}:{settings.http_port}" + print("VidXP network sharing is enabled.", flush=True) + print(f"Health: {origin}/health", flush=True) + print(f"MCP: {origin}/mcp", flush=True) + print(f"Bearer token: {token}", flush=True) + print( + "Keep this token private. LAN traffic uses HTTP and is not encrypted.", + flush=True, + ) + + def main(arguments: Sequence[str] | None = None) -> None: options = _arguments(arguments) @@ -33,7 +104,30 @@ def main(arguments: Sequence[str] | None = None) -> None: if options.data_dir is not None else VidXPSettings() ) + if options.port is not None: + payload = settings.model_dump(mode="python") + payload["http_port"] = options.port + settings = VidXPSettings.model_validate(payload) + share_token = None + if options.share: + if settings.http_auth_mode.value not in {"none", "static"}: + raise ValueError( + "--share supports the app-managed static bearer mode, not OIDC." + ) + configured_token = ( + settings.http_static_bearer_token.get_secret_value() + if settings.http_auth_mode.value == "static" + and settings.http_static_bearer_token is not None + else None + ) + settings, share_token = _shared_settings( + settings, + host=primary_lan_address(), + token=configured_token or load_or_create_api_share_token(), + ) settings.validate_http_server() + if share_token is not None: + _print_share_details(settings, share_token) uvicorn.run( create_app(settings), host=settings.http_bind_host, diff --git a/src/vidxp/api_middleware.py b/src/vidxp/api_middleware.py index c13ae4d..9ae5175 100644 --- a/src/vidxp/api_middleware.py +++ b/src/vidxp/api_middleware.py @@ -16,7 +16,7 @@ from vidxp.authentication import Authenticator -PUBLIC_HTTP_PATHS = frozenset({"/health", "/ready"}) +PUBLIC_HTTP_PATHS = frozenset({"/favicon.ico", "/health", "/ready"}) UPLOAD_PATH = "/api/v1/media" diff --git a/src/vidxp/cli_commands/runtime.py b/src/vidxp/cli_commands/runtime.py index 560827b..49503b4 100644 --- a/src/vidxp/cli_commands/runtime.py +++ b/src/vidxp/cli_commands/runtime.py @@ -33,6 +33,7 @@ media_runtime_config_path, save_media_runtime_configuration, ) +from vidxp.network_share import is_loopback_host def _format_bytes(size: int) -> str: @@ -513,6 +514,16 @@ def ui( help="Streamlit server port.", ), ] = None, + share: Annotated[ + bool, + typer.Option( + "--share", + help=( + "Share the unauthenticated browser interface on this " + "machine's LAN address." + ), + ), + ] = False, ) -> None: """Launch Streamlit with the selected repository configuration.""" @@ -537,11 +548,23 @@ def ui( ) from exc raise - streamlit_arguments = [] - if host is not None: - streamlit_arguments.append(f"--server.address={host}") + if share and host is not None: + raise typer.BadParameter("--share cannot be combined with --host.") + active_host = "0.0.0.0" if share else (host or "127.0.0.1") + streamlit_arguments = [ + "--server.showEmailPrompt=false", + "--browser.gatherUsageStats=false", + f"--server.address={active_host}", + ] if port is not None: streamlit_arguments.append(f"--server.port={port}") + if share or not is_loopback_host(active_host): + typer.secho( + "WARNING: The browser interface has no authentication and is " + "reachable from the network.", + fg=typer.colors.YELLOW, + err=True, + ) if show_progress: emit_progress("Starting the browser interface...") try: diff --git a/src/vidxp/network_share.py b/src/vidxp/network_share.py new file mode 100644 index 0000000..0cad6cc --- /dev/null +++ b/src/vidxp/network_share.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import ipaddress +import os +import socket +from pathlib import Path +from secrets import token_urlsafe + +from vidxp.app_paths import default_config_directory + + +API_SHARE_TOKEN_FILE = "api-share-token" + + +def _usable_ipv4(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + return ( + address.version == 4 + and not address.is_loopback + and not address.is_unspecified + and not address.is_link_local + ) + + +def primary_lan_address() -> str: + """Resolve the IPv4 address selected by the host's default route.""" + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + # UDP connect selects a route without sending application data. + probe.connect(("192.0.2.1", 9)) + candidate = str(probe.getsockname()[0]) + if _usable_ipv4(candidate): + return candidate + except OSError: + pass + + try: + addresses = socket.getaddrinfo( + socket.gethostname(), + None, + family=socket.AF_INET, + type=socket.SOCK_STREAM, + ) + except OSError as exc: + raise RuntimeError( + "VidXP could not determine a LAN address for sharing." + ) from exc + for item in addresses: + candidate = str(item[4][0]) + if _usable_ipv4(candidate): + return candidate + raise RuntimeError("VidXP could not determine a LAN address for sharing.") + + +def api_share_token_path() -> Path: + return default_config_directory() / API_SHARE_TOKEN_FILE + + +def load_or_create_api_share_token(path: Path | None = None) -> str: + """Return the stable app-owned bearer token used by API share mode.""" + + target = path or api_share_token_path() + target.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + try: + existing = target.read_text(encoding="utf-8").strip() + except FileNotFoundError: + existing = "" + if existing: + if len(existing) < 32: + raise RuntimeError(f"The managed VidXP API token is invalid: {target}") + return existing + + token = token_urlsafe(32) + try: + descriptor = os.open( + target, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + except FileExistsError: + existing = target.read_text(encoding="utf-8").strip() + if len(existing) < 32: + raise RuntimeError(f"The managed VidXP API token is invalid: {target}") + return existing + with os.fdopen(descriptor, "w", encoding="utf-8") as destination: + destination.write(token + "\n") + destination.flush() + os.fsync(destination.fileno()) + return token + + +def is_loopback_host(host: str) -> bool: + if host.lower() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False diff --git a/src/vidxp/settings.py b/src/vidxp/settings.py index 18a2f1c..8cc0922 100644 --- a/src/vidxp/settings.py +++ b/src/vidxp/settings.py @@ -26,6 +26,9 @@ from vidxp.repository_layout import RepositoryLayout +DEFAULT_HTTP_PORT = 32191 + + class ApplicationMode(StrEnum): local = "local" remote = "remote" @@ -79,7 +82,7 @@ class VidXPSettings(BaseSettings): le=3600, ) http_bind_host: str = Field(default="127.0.0.1", min_length=1) - http_port: int = Field(default=8000, gt=0, le=65535) + http_port: int = Field(default=DEFAULT_HTTP_PORT, gt=0, le=65535) http_auth_mode: HttpAuthMode = HttpAuthMode.none http_static_bearer_token: SecretStr | None = None http_oidc_issuer: str | None = Field( diff --git a/tests/test_api.py b/tests/test_api.py index 658c0e1..097c9b4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -181,10 +181,13 @@ def test_health_and_minimal_readiness_are_public(self): ) with TestClient(create_app(context=context)) as client: health = client.get("/health") + favicon = client.get("/favicon.ico") ready = client.get("/ready") self.assertEqual(health.status_code, 200) self.assertEqual(health.json(), {"status": "ok"}) + self.assertEqual(favicon.status_code, 204) + self.assertEqual(favicon.content, b"") self.assertEqual(ready.status_code, 200) self.assertEqual(ready.json(), {"ready": True, "status": "ready"}) diff --git a/tests/test_api_cli.py b/tests/test_api_cli.py index 60682e6..996cfb0 100644 --- a/tests/test_api_cli.py +++ b/tests/test_api_cli.py @@ -1,8 +1,10 @@ import unittest from contextlib import redirect_stdout from io import StringIO +from unittest.mock import patch -from vidxp.api_cli import main +from vidxp.api_cli import _shared_settings, main +from vidxp.settings import HttpAuthMode, VidXPSettings class ApiCliTests(unittest.TestCase): @@ -14,6 +16,84 @@ def test_help_exits_without_starting_the_service(self): self.assertEqual(caught.exception.code, 0) self.assertIn("VIDXP_* environment variables", output.getvalue()) + self.assertIn("--port", output.getvalue()) + self.assertIn("--share", output.getvalue()) + + def test_share_mode_configures_managed_static_server(self): + settings, token = _shared_settings( + VidXPSettings(), + host="192.168.100.131", + token="x" * 43, + ) + + self.assertEqual(settings.http_bind_host, "192.168.100.131") + self.assertEqual(settings.http_auth_mode, HttpAuthMode.static) + self.assertEqual(token, "x" * 43) + self.assertIn("192.168.100.131", settings.http_trusted_hosts) + self.assertIn("192.168.100.131:*", settings.mcp_allowed_hosts) + settings.validate_http_server() + + def test_share_mode_reuses_an_explicit_static_token(self): + settings, token = _shared_settings( + VidXPSettings( + http_auth_mode=HttpAuthMode.static, + http_static_bearer_token="configured-token-1234567890123456", + ), + host="192.168.100.131", + token="managed-token-123456789012345678", + ) + + self.assertEqual(token, "configured-token-1234567890123456") + + def test_main_shares_on_the_detected_address(self): + import uvicorn + from vidxp import api + + output = StringIO() + with ( + patch.object(uvicorn, "run") as run, + patch.object( + api, + "create_app", + ) as create_app, + patch( + "vidxp.api_cli.primary_lan_address", + return_value="192.168.100.131", + ), + patch( + "vidxp.api_cli.load_or_create_api_share_token", + return_value="x" * 43, + ), + redirect_stdout(output), + ): + main(["--share"]) + + run.assert_called_once_with( + create_app.return_value, + host="192.168.100.131", + port=32191, + ) + self.assertIn( + "MCP: http://192.168.100.131:32191/mcp", + output.getvalue(), + ) + self.assertIn("Bearer token:", output.getvalue()) + + def test_main_accepts_an_explicit_port(self): + import uvicorn + from vidxp import api + + with ( + patch.object(uvicorn, "run") as run, + patch.object(api, "create_app") as create_app, + ): + main(["--port", "32192"]) + + run.assert_called_once_with( + create_app.return_value, + host="127.0.0.1", + port=32192, + ) if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index b819fe9..81fb3d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -334,12 +334,39 @@ def test_ui_shutdown_stops_its_local_worker(self): with patch( "vidxp.frontend.main", side_effect=SystemExit(0), - ): + ) as frontend: result = self.invoke(["ui"]) self.assertEqual(result.exit_code, 0, result.output) + self.assertIn( + "--server.address=127.0.0.1", + frontend.call_args.args[0], + ) + self.assertIn( + "--server.showEmailPrompt=false", + frontend.call_args.args[0], + ) + self.assertIn( + "--browser.gatherUsageStats=false", + frontend.call_args.args[0], + ) self.jobs.stop_worker.assert_called_once_with() + def test_ui_share_uses_streamlit_wildcard_bind_and_warns(self): + with patch( + "vidxp.frontend.main", + side_effect=SystemExit(0), + ) as frontend: + result = self.invoke(["ui", "--share"]) + + self.assertEqual(result.exit_code, 0, result.output) + arguments = frontend.call_args.args[0] + self.assertIn("--server.address=0.0.0.0", arguments) + self.assertIn("--server.showEmailPrompt=false", arguments) + self.assertIn("--browser.gatherUsageStats=false", arguments) + self.assertIn("has no authentication", result.output) + self.assertNotIn("Browser UI:", result.output) + def test_snippet_rejects_an_inverted_time_range_before_submission(self): result = self.invoke( ["artifacts", "snippet", MEDIA_ID, "3", "2"] diff --git a/tests/test_network_share.py b/tests/test_network_share.py new file mode 100644 index 0000000..4248451 --- /dev/null +++ b/tests/test_network_share.py @@ -0,0 +1,32 @@ +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from vidxp.network_share import load_or_create_api_share_token + + +class NetworkShareTests(unittest.TestCase): + def test_managed_token_is_stable_and_private(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "api-share-token" + + first = load_or_create_api_share_token(path) + second = load_or_create_api_share_token(path) + + self.assertEqual(first, second) + self.assertGreaterEqual(len(first), 32) + if os.name != "nt": + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_invalid_existing_token_is_rejected(self): + with TemporaryDirectory() as directory: + path = Path(directory) / "api-share-token" + path.write_text("short\n", encoding="utf-8") + + with self.assertRaisesRegex(RuntimeError, "token is invalid"): + load_or_create_api_share_token(path) + + +if __name__ == "__main__": + unittest.main()