mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
feat(webui): bypass tokens for trusted proxy auth
This commit is contained in:
parent
5cd14a42df
commit
465a918cf8
@ -82,7 +82,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
|||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token`, `tokenIssueSecret`, or a fully configured `trustedProxyAuth` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||||
> The gateway health route itself is intentionally minimal and unauthenticated. When the
|
> The gateway health route itself is intentionally minimal and unauthenticated. When the
|
||||||
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
|
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
|
||||||
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
|
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
|
||||||
@ -93,7 +93,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
|||||||
|
|
||||||
For a local `cloudflared` process in front of nanobot, Cloudflare Access can
|
For a local `cloudflared` process in front of nanobot, Cloudflare Access can
|
||||||
authenticate the user before forwarding the request and add
|
authenticate the user before forwarding the request and add
|
||||||
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy bootstrap only when the
|
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy no-token mode only when the
|
||||||
direct TCP peer is the tunnel process and the assertion is non-empty:
|
direct TCP peer is the tunnel process and the assertion is non-empty:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -113,11 +113,14 @@ direct TCP peer is the tunnel process and the assertion is non-empty:
|
|||||||
```
|
```
|
||||||
|
|
||||||
This is two-part authorization: a trusted direct loopback peer **and** a
|
This is two-part authorization: a trusted direct loopback peer **and** a
|
||||||
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bootstrap
|
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
|
||||||
bypass. Nanobot trusts the assertion but does not cryptographically validate
|
For this flow `/webui/bootstrap` returns connection metadata without a
|
||||||
the JWT, so configure the tunnel and Access policy carefully and do not expose
|
bootstrap token or REST API token; the proxy assertion authorizes the WebSocket
|
||||||
the nanobot listener directly to untrusted clients. Forwarded client headers
|
handshake and REST requests directly. Nanobot trusts the assertion but does
|
||||||
such as `X-Forwarded-For` do not establish proxy trust.
|
not cryptographically validate the JWT, so configure the tunnel and Access
|
||||||
|
policy carefully and do not expose the nanobot listener directly to untrusted
|
||||||
|
clients. Forwarded client headers such as `X-Forwarded-For` do not establish
|
||||||
|
proxy trust.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ ws://{host}:{port}{path}?client_id={id}&token={token}
|
|||||||
| Parameter | Required | Description |
|
| Parameter | Required | Description |
|
||||||
|-----------|----------|-------------|
|
|-----------|----------|-------------|
|
||||||
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
|
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
|
||||||
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. |
|
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured, unless the request comes through an authenticated `trustedProxyAuth` peer. |
|
||||||
|
|
||||||
## Wire Protocol
|
## Wire Protocol
|
||||||
|
|
||||||
@ -222,11 +222,11 @@ All fields go under `channels.websocket` in `config.json`.
|
|||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
|-------|------|---------|-------------|
|
|-------|------|---------|-------------|
|
||||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. A trusted proxy assertion bypasses this requirement. |
|
||||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token, unless `trustedProxyAuth` authenticates the direct proxy peer. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret`, `token`, or a fully configured `trustedProxyAuth`. |
|
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` issues tokens for local/secret-authenticated requests; trusted-proxy requests intentionally receive no bootstrap or API token. |
|
||||||
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part bootstrap authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap. |
|
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part no-token authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap or WebSocket/API access. |
|
||||||
| `trustedProxyAuth.trustedPeerCidrs` | list of CIDR strings | — | Direct TCP peer networks that may present the assertion. IPv4, IPv6, and IPv4-mapped IPv6 peers are supported; universal CIDRs (`0.0.0.0/0`, `::/0`) are rejected. |
|
| `trustedProxyAuth.trustedPeerCidrs` | list of CIDR strings | — | Direct TCP peer networks that may present the assertion. IPv4, IPv6, and IPv4-mapped IPv6 peers are supported; universal CIDRs (`0.0.0.0/0`, `::/0`) are rejected. |
|
||||||
| `trustedProxyAuth.assertionHeader` | string | — | HTTP header whose non-empty value proves the upstream proxy authenticated the request. Nanobot trusts this value but does not cryptographically validate it. |
|
| `trustedProxyAuth.assertionHeader` | string | — | HTTP header whose non-empty value proves the upstream proxy authenticated the request. Nanobot trusts this value but does not cryptographically validate it. |
|
||||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||||
@ -273,16 +273,18 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
|||||||
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
||||||
4. The token is consumed (single use) and cannot be reused.
|
4. The token is consumed (single use) and cannot be reused.
|
||||||
|
|
||||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
The embedded WebUI's `/webui/bootstrap` route returns a WebSocket token and
|
||||||
|
REST `api_token` for local or secret-authenticated requests. When
|
||||||
|
`trustedProxyAuth` authenticates the direct proxy peer, it returns connection
|
||||||
|
metadata only: no bootstrap token, no REST API token, and no token query
|
||||||
|
parameter is required for the WebSocket handshake or subsequent REST requests.
|
||||||
|
|
||||||
It returns a separate `api_token` for REST routes to same-machine localhost
|
### Trusted proxy no-token bootstrap
|
||||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`,
|
|
||||||
the static `token`, or the configured trusted proxy assertion.
|
|
||||||
|
|
||||||
### Trusted proxy bootstrap
|
|
||||||
|
|
||||||
`trustedProxyAuth` is an opt-in alternative for deployments where an
|
`trustedProxyAuth` is an opt-in alternative for deployments where an
|
||||||
identity-aware reverse proxy authenticates the user before connecting to nanobot.
|
identity-aware reverse proxy authenticates the user before connecting to nanobot.
|
||||||
|
The proxy assertion becomes the authentication boundary for the entire WebUI
|
||||||
|
surface: `/webui/bootstrap`, the WebSocket handshake, and REST API routes.
|
||||||
Bootstrap is accepted only when **both** the direct TCP peer matches one of
|
Bootstrap is accepted only when **both** the direct TCP peer matches one of
|
||||||
`trustedPeerCidrs` and the configured assertion header is present and non-empty.
|
`trustedPeerCidrs` and the configured assertion header is present and non-empty.
|
||||||
A trusted address by itself is never sufficient.
|
A trusted address by itself is never sufficient.
|
||||||
|
|||||||
@ -61,6 +61,9 @@ from nanobot.session.webui_turns import (
|
|||||||
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||||
from nanobot.webui.forking import handle_webui_fork_chat
|
from nanobot.webui.forking import handle_webui_fork_chat
|
||||||
from nanobot.webui.gateway_services import GatewayServices
|
from nanobot.webui.gateway_services import GatewayServices
|
||||||
|
from nanobot.webui.http_utils import (
|
||||||
|
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||||
|
)
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
normalize_config_path as _normalize_config_path,
|
normalize_config_path as _normalize_config_path,
|
||||||
)
|
)
|
||||||
@ -220,11 +223,11 @@ class WebSocketConfig(Base):
|
|||||||
def wildcard_host_requires_auth(self) -> Self:
|
def wildcard_host_requires_auth(self) -> Self:
|
||||||
if self.host not in ("0.0.0.0", "::"):
|
if self.host not in ("0.0.0.0", "::"):
|
||||||
return self
|
return self
|
||||||
if self.token.strip() or self.token_issue_secret.strip():
|
if self.token.strip() or self.token_issue_secret.strip() or self.trusted_proxy_auth is not None:
|
||||||
return self
|
return self
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"host is 0.0.0.0 (all interfaces) but neither token nor "
|
"host is 0.0.0.0 (all interfaces) but neither token, token_issue_secret, "
|
||||||
"token_issue_secret is set — set one to prevent unauthenticated access"
|
"nor trusted_proxy_auth is set — set one to prevent unauthenticated access"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -480,16 +483,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
|
async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
|
||||||
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
|
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
|
||||||
got, query = _parse_request_path(request.path)
|
got, query = _parse_request_path(request.path)
|
||||||
|
expected_ws = self._expected_path()
|
||||||
|
|
||||||
# WebSocket upgrade — channel handles this itself
|
# WebSocket upgrade — channel handles this itself
|
||||||
expected_ws = self._expected_path()
|
|
||||||
if got == expected_ws and _is_websocket_upgrade(request):
|
if got == expected_ws and _is_websocket_upgrade(request):
|
||||||
client_id = _query_first(query, "client_id") or ""
|
client_id = _query_first(query, "client_id") or ""
|
||||||
if len(client_id) > 128:
|
if len(client_id) > 128:
|
||||||
client_id = client_id[:128]
|
client_id = client_id[:128]
|
||||||
if not self.is_allowed(client_id):
|
if not self.is_allowed(client_id):
|
||||||
return connection.respond(403, "Forbidden")
|
return connection.respond(403, "Forbidden")
|
||||||
return self._authorize_websocket_handshake(connection, query)
|
return self._authorize_websocket_handshake(connection, query, request.headers)
|
||||||
|
|
||||||
# Everything else goes to the HTTP handler
|
# Everything else goes to the HTTP handler
|
||||||
return await self._http_router.dispatch(connection, request)
|
return await self._http_router.dispatch(connection, request)
|
||||||
@ -498,7 +501,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
query: dict[str, list[str]],
|
query: dict[str, list[str]],
|
||||||
|
headers: Any = None,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
|
if _is_trusted_proxy_authenticated_request(connection, headers or {}, self.config):
|
||||||
|
self._webui_connections.add(connection)
|
||||||
|
return None
|
||||||
|
|
||||||
supplied = _query_first(query, "token")
|
supplied = _query_first(query, "token")
|
||||||
static_token = self.config.token.strip()
|
static_token = self.config.token.strip()
|
||||||
|
|
||||||
|
|||||||
@ -3351,7 +3351,7 @@ def test_trusted_proxy_rejects_untrusted_peer_spoof(bus: MagicMock) -> None:
|
|||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust(
|
def test_trusted_proxy_bootstrap_has_no_tokens(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
assertion = "opaque-upstream-assertion"
|
assertion = "opaque-upstream-assertion"
|
||||||
@ -3376,9 +3376,36 @@ def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust(
|
|||||||
assert assertion not in body
|
assert assertion not in body
|
||||||
assert assertion not in repr(log.mock_calls)
|
assert assertion not in repr(log.mock_calls)
|
||||||
payload = json.loads(body)
|
payload = json.loads(body)
|
||||||
assert payload["token"].startswith("nbwt_")
|
assert "token" not in payload
|
||||||
assert payload["api_token"].startswith("nbwt_")
|
assert "api_token" not in payload
|
||||||
assert payload["api_token"] != payload["token"]
|
assert payload["ws_path"] == "/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trusted_proxy_authorizes_rest_without_api_token(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, **_trusted_proxy_config())
|
||||||
|
response = await channel.gateway.http.dispatch(
|
||||||
|
_LOCAL,
|
||||||
|
_FakeReq(
|
||||||
|
{
|
||||||
|
"Host": "nanobot.example",
|
||||||
|
"Cf-Access-Jwt-Assertion": "present",
|
||||||
|
},
|
||||||
|
path="/api/sessions",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_trusted_proxy_authorizes_websocket_without_token(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, **_trusted_proxy_config())
|
||||||
|
response = channel._authorize_websocket_handshake(
|
||||||
|
_LOCAL,
|
||||||
|
{},
|
||||||
|
{"Cf-Access-Jwt-Assertion": "present"},
|
||||||
|
)
|
||||||
|
assert response is None
|
||||||
|
assert _LOCAL in channel._webui_connections
|
||||||
|
|
||||||
|
|
||||||
def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> None:
|
def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> None:
|
||||||
@ -3397,7 +3424,7 @@ def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> N
|
|||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> None:
|
def test_trusted_proxy_bypasses_bootstrap_secret_and_tokens(bus: MagicMock) -> None:
|
||||||
channel = _ch(
|
channel = _ch(
|
||||||
bus,
|
bus,
|
||||||
tokenIssueSecret="route-secret",
|
tokenIssueSecret="route-secret",
|
||||||
@ -3407,7 +3434,10 @@ def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> Non
|
|||||||
_LOCAL,
|
_LOCAL,
|
||||||
_FakeReq({"Cf-Access-Jwt-Assertion": "present"}),
|
_FakeReq({"Cf-Access-Jwt-Assertion": "present"}),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 200
|
||||||
|
payload = json.loads(resp.body)
|
||||||
|
assert "token" not in payload
|
||||||
|
assert "api_token" not in payload
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -3461,6 +3491,11 @@ def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
|
|||||||
assert channel.config.host == "0.0.0.0"
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wildcard_host_with_trusted_proxy_auth_is_valid(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus, host="0.0.0.0", **_trusted_proxy_config())
|
||||||
|
assert channel.config.host == "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
|
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic_core import ValidationError
|
from pydantic_core import ValidationError
|
||||||
|
|||||||
@ -266,6 +266,8 @@ class GatewayHTTPHandler:
|
|||||||
# -- Token management ---------------------------------------------------
|
# -- Token management ---------------------------------------------------
|
||||||
|
|
||||||
def check_api_token(self, request: WsRequest) -> bool:
|
def check_api_token(self, request: WsRequest) -> bool:
|
||||||
|
if getattr(request, "_nanobot_trusted_proxy_authenticated", False):
|
||||||
|
return True
|
||||||
return self.tokens.check_api_token(request)
|
return self.tokens.check_api_token(request)
|
||||||
|
|
||||||
# -- Main dispatch ------------------------------------------------------
|
# -- Main dispatch ------------------------------------------------------
|
||||||
@ -275,6 +277,11 @@ class GatewayHTTPHandler:
|
|||||||
got, _ = _parse_request_path(request.path)
|
got, _ = _parse_request_path(request.path)
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
response: Any | None = None
|
response: Any | None = None
|
||||||
|
setattr(
|
||||||
|
request,
|
||||||
|
"_nanobot_trusted_proxy_authenticated",
|
||||||
|
_is_trusted_proxy_authenticated_request(connection, request.headers, self.config),
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._dispatch_resolved(connection, request, got)
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
@ -380,13 +387,27 @@ class GatewayHTTPHandler:
|
|||||||
request.headers,
|
request.headers,
|
||||||
self.config,
|
self.config,
|
||||||
)
|
)
|
||||||
if secret:
|
if not is_proxy_authenticated:
|
||||||
if not _issue_route_secret_matches(request.headers, secret):
|
if secret:
|
||||||
return _http_error(401, "Unauthorized")
|
if not _issue_route_secret_matches(request.headers, secret):
|
||||||
elif not (is_local_browser or is_proxy_authenticated):
|
return _http_error(401, "Unauthorized")
|
||||||
return _http_error(403, "bootstrap is localhost-only")
|
elif not is_local_browser:
|
||||||
|
return _http_error(403, "bootstrap is localhost-only")
|
||||||
|
|
||||||
api_token_allowed = bool(secret) or is_local_browser or is_proxy_authenticated
|
if is_proxy_authenticated:
|
||||||
|
payload = {
|
||||||
|
"ws_path": _normalize_config_path(self.config.path),
|
||||||
|
"ws_url": self._bootstrap_ws_url(request),
|
||||||
|
"limits": self.ingress.bootstrap_limits(
|
||||||
|
max_frame_bytes=self.config.max_message_bytes,
|
||||||
|
),
|
||||||
|
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
||||||
|
"runtime_surface": self._runtime_surface,
|
||||||
|
"runtime_capabilities": self._capabilities,
|
||||||
|
}
|
||||||
|
return _http_json_response(payload)
|
||||||
|
|
||||||
|
api_token_allowed = bool(secret) or is_local_browser
|
||||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||||
return _http_response(
|
return _http_response(
|
||||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||||
|
|||||||
@ -70,7 +70,7 @@ type BootState =
|
|||||||
status: "ready";
|
status: "ready";
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
tokenExpiresAt: number;
|
tokenExpiresAt: number | null;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
ingressLimits: BootstrapResponse["limits"] | null;
|
ingressLimits: BootstrapResponse["limits"] | null;
|
||||||
runtimeSurface: RuntimeSurface;
|
runtimeSurface: RuntimeSurface;
|
||||||
@ -733,7 +733,9 @@ export default function App() {
|
|||||||
? toRuntimeSurface(boot.runtime_surface)
|
? toRuntimeSurface(boot.runtime_surface)
|
||||||
: fallbackSurface;
|
: fallbackSurface;
|
||||||
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
||||||
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
|
const tokenExpiresAt = boot.expires_in
|
||||||
|
? bootstrapTokenExpiresAt(boot.expires_in)
|
||||||
|
: null;
|
||||||
if (runtimeHost.socketFactory) {
|
if (runtimeHost.socketFactory) {
|
||||||
client.updateUrl(url, runtimeHost.socketFactory);
|
client.updateUrl(url, runtimeHost.socketFactory);
|
||||||
} else {
|
} else {
|
||||||
@ -744,7 +746,7 @@ export default function App() {
|
|||||||
current.status === "ready" && current.client === client
|
current.status === "ready" && current.client === client
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
token: boot.api_token,
|
token: boot.api_token ?? "",
|
||||||
tokenExpiresAt,
|
tokenExpiresAt,
|
||||||
modelName: boot.model_name ?? current.modelName,
|
modelName: boot.model_name ?? current.modelName,
|
||||||
ingressLimits: boot.limits ?? current.ingressLimits,
|
ingressLimits: boot.limits ?? current.ingressLimits,
|
||||||
@ -752,7 +754,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
return { token: boot.api_token, url };
|
return { token: boot.api_token ?? "", url };
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@ -787,8 +789,10 @@ export default function App() {
|
|||||||
setState({
|
setState({
|
||||||
status: "ready",
|
status: "ready",
|
||||||
client,
|
client,
|
||||||
token: boot.api_token,
|
token: boot.api_token ?? "",
|
||||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
tokenExpiresAt: boot.expires_in
|
||||||
|
? bootstrapTokenExpiresAt(boot.expires_in)
|
||||||
|
: null,
|
||||||
modelName: boot.model_name ?? null,
|
modelName: boot.model_name ?? null,
|
||||||
ingressLimits: boot.limits ?? null,
|
ingressLimits: boot.limits ?? null,
|
||||||
runtimeSurface,
|
runtimeSurface,
|
||||||
@ -813,7 +817,7 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status !== "ready") return;
|
if (state.status !== "ready" || state.tokenExpiresAt === null) return;
|
||||||
const client = state.client;
|
const client = state.client;
|
||||||
const timer = window.setTimeout(async () => {
|
const timer = window.setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -87,13 +87,8 @@ export async function fetchBootstrap(
|
|||||||
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as BootstrapResponse;
|
const body = (await res.json()) as BootstrapResponse;
|
||||||
if (!body.token || !body.ws_path) {
|
if (!body.ws_path) {
|
||||||
throw new Error("bootstrap response missing token or ws_path");
|
throw new Error("bootstrap response missing ws_path");
|
||||||
}
|
|
||||||
if (!body.api_token) {
|
|
||||||
throw new BootstrapAuthRequiredError(
|
|
||||||
"bootstrap authentication required: missing api_token",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
@ -107,10 +102,10 @@ export async function fetchBootstrap(
|
|||||||
*/
|
*/
|
||||||
export function deriveWsUrl(
|
export function deriveWsUrl(
|
||||||
wsPath: string,
|
wsPath: string,
|
||||||
token: string,
|
token: string | null | undefined,
|
||||||
wsUrl?: string | null,
|
wsUrl?: string | null,
|
||||||
): string {
|
): string {
|
||||||
const query = `?token=${encodeURIComponent(token)}`;
|
const query = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||||
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
|
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
|
||||||
if (typeof window !== "undefined" && window.location.port === "5173") {
|
if (typeof window !== "undefined" && window.location.port === "5173") {
|
||||||
const host = window.location.hostname.includes(":")
|
const host = window.location.hostname.includes(":")
|
||||||
@ -127,6 +122,7 @@ export function deriveWsUrl(
|
|||||||
return `${scheme}://${authority}${path}${query}`;
|
return `${scheme}://${authority}${path}${query}`;
|
||||||
}
|
}
|
||||||
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
|
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
|
||||||
|
if (!token) return wsUrl;
|
||||||
const join = wsUrl.includes("?") ? "&" : "?";
|
const join = wsUrl.includes("?") ? "&" : "?";
|
||||||
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
|
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -390,11 +390,11 @@ export interface SidebarStatePayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BootstrapResponse {
|
export interface BootstrapResponse {
|
||||||
token: string;
|
token?: string;
|
||||||
api_token: string;
|
api_token?: string;
|
||||||
ws_path: string;
|
ws_path: string;
|
||||||
ws_url?: string | null;
|
ws_url?: string | null;
|
||||||
expires_in: number;
|
expires_in?: number;
|
||||||
limits?: WebUIIngressLimits;
|
limits?: WebUIIngressLimits;
|
||||||
model_name?: string | null;
|
model_name?: string | null;
|
||||||
runtime_surface?: RuntimeSurface;
|
runtime_surface?: RuntimeSurface;
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BootstrapAuthRequiredError,
|
|
||||||
consumeUrlBootstrapSecret,
|
consumeUrlBootstrapSecret,
|
||||||
deriveWsUrl,
|
deriveWsUrl,
|
||||||
fetchBootstrap,
|
fetchBootstrap,
|
||||||
@ -57,6 +56,12 @@ describe("bootstrap helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not append a token for trusted-proxy websocket URLs", () => {
|
||||||
|
expect(deriveWsUrl("/", undefined, "wss://proxy.example/")).toBe(
|
||||||
|
"wss://proxy.example/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("times out when the bootstrap endpoint never responds", async () => {
|
it("times out when the bootstrap endpoint never responds", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||||
@ -69,21 +74,19 @@ describe("bootstrap helpers", () => {
|
|||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats bootstrap responses without an API token as auth-required", async () => {
|
it("accepts tokenless trusted-proxy bootstrap responses", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async () => ({
|
vi.fn(async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }),
|
json: async () => ({ ws_path: "/", ws_url: "wss://proxy.example/" }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const promise = fetchBootstrap();
|
await expect(fetchBootstrap()).resolves.toMatchObject({
|
||||||
await expect(promise).rejects.toMatchObject({
|
ws_path: "/",
|
||||||
name: "BootstrapAuthRequiredError",
|
ws_url: "wss://proxy.example/",
|
||||||
message: "bootstrap authentication required: missing api_token",
|
|
||||||
});
|
});
|
||||||
await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("consumes bootstrap secrets from the URL fragment", () => {
|
it("consumes bootstrap secrets from the URL fragment", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user