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
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 32 additions & 7 deletions INSTALLATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions src/vidxp/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions src/vidxp/api_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)

Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/vidxp/api_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
29 changes: 26 additions & 3 deletions src/vidxp/cli_commands/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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."""

Expand All @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions src/vidxp/network_share.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion src/vidxp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
from vidxp.repository_layout import RepositoryLayout


DEFAULT_HTTP_PORT = 32191


class ApplicationMode(StrEnum):
local = "local"
remote = "remote"
Expand Down Expand Up @@ -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(
Expand Down
Loading