fix(webui): gate bootstrap API token issuance
This commit is contained in:
+30
-5
@@ -925,11 +925,30 @@ def _host_for_local_browser(host: str) -> str:
|
||||
return host
|
||||
|
||||
|
||||
def _webui_bootstrap_secret(config: Config) -> str:
|
||||
ws_cfg = _webui_config_dict(config)
|
||||
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
|
||||
|
||||
|
||||
def _webui_browser_url(config: Config) -> str:
|
||||
from urllib.parse import quote
|
||||
|
||||
ws_cfg = _webui_config_dict(config)
|
||||
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
|
||||
port = int(ws_cfg.get("port") or 8765)
|
||||
return f"http://{host}:{port}"
|
||||
base_url = f"http://{host}:{port}"
|
||||
secret = _webui_bootstrap_secret(config)
|
||||
if not secret:
|
||||
return base_url
|
||||
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
|
||||
|
||||
|
||||
def _webui_display_url(url: str) -> str:
|
||||
marker = "bootstrapSecret="
|
||||
if marker not in url:
|
||||
return url
|
||||
prefix, _ = url.split(marker, 1)
|
||||
return f"{prefix}{marker}<redacted>"
|
||||
|
||||
|
||||
def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool) -> bool:
|
||||
@@ -942,7 +961,8 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
|
||||
|
||||
needs_enable = not model.enabled
|
||||
needs_port = port is not None and model.port != port
|
||||
if not needs_enable and not needs_port:
|
||||
needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
|
||||
if not needs_enable and not needs_port and not needs_secret:
|
||||
return False
|
||||
|
||||
target_port = port if port is not None else model.port
|
||||
@@ -950,11 +970,11 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
|
||||
console.print("[bold]Local WebUI setup[/bold]")
|
||||
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
|
||||
console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
|
||||
console.print(" Auth: localhost bootstrap issues short-lived WebSocket tokens")
|
||||
console.print(" Auth: generated WebUI bootstrap secret stored in config")
|
||||
console.print(
|
||||
" LAN access requires an explicit host change plus a WebUI password in config."
|
||||
)
|
||||
_confirm_webui_action("Enable the local WebUI channel in this config?", yes=yes)
|
||||
_confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
|
||||
|
||||
if not model.enabled:
|
||||
model.enabled = True
|
||||
@@ -968,6 +988,11 @@ def _ensure_local_webui_channel(config: Config, *, port: int | None, yes: bool)
|
||||
if not model.websocket_requires_token:
|
||||
model.websocket_requires_token = True
|
||||
changed = True
|
||||
if needs_secret:
|
||||
import secrets
|
||||
|
||||
model.token_issue_secret = secrets.token_urlsafe(32)
|
||||
changed = True
|
||||
|
||||
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
|
||||
return changed
|
||||
@@ -1250,7 +1275,7 @@ def webui(
|
||||
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
|
||||
|
||||
console.print()
|
||||
console.print(f"WebUI: [cyan]{webui_url}[/cyan]")
|
||||
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
|
||||
console.print(f"Gateway health: [cyan]http://{runtime_config.gateway.host}:{effective_gateway_port}/health[/cyan]")
|
||||
if no_open:
|
||||
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
|
||||
|
||||
@@ -50,6 +50,12 @@ class GatewayTokenStore:
|
||||
self.api_tokens[token_value] = expiry
|
||||
return token_value
|
||||
|
||||
def issue_api_token(self, ttl_s: int | float) -> str:
|
||||
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
|
||||
expiry = time.monotonic() + float(ttl_s)
|
||||
self.api_tokens[token_value] = expiry
|
||||
return token_value
|
||||
|
||||
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
||||
if not token_value:
|
||||
return False
|
||||
|
||||
+20
-13
@@ -306,33 +306,40 @@ class GatewayHTTPHandler:
|
||||
|
||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||
api_token_allowed = bool(secret)
|
||||
if secret:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not _is_localhost(connection):
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
|
||||
if not self.tokens.can_issue(include_api_token=True):
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
status=429,
|
||||
content_type="application/json; charset=utf-8",
|
||||
)
|
||||
token = self.tokens.issue_token(self.config.token_ttl_s, api_token=True)
|
||||
token = self.tokens.issue_token(self.config.token_ttl_s)
|
||||
api_token = (
|
||||
self.tokens.issue_api_token(self.config.token_ttl_s)
|
||||
if api_token_allowed
|
||||
else None
|
||||
)
|
||||
|
||||
ws_url = self._bootstrap_ws_url(request)
|
||||
expected_path = _normalize_config_path(self.config.path)
|
||||
return _http_json_response(
|
||||
{
|
||||
"token": token,
|
||||
"ws_path": expected_path,
|
||||
"ws_url": ws_url,
|
||||
"expires_in": self.config.token_ttl_s,
|
||||
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
||||
"runtime_surface": self._runtime_surface,
|
||||
"runtime_capabilities": self._capabilities,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"token": token,
|
||||
"ws_path": expected_path,
|
||||
"ws_url": ws_url,
|
||||
"expires_in": self.config.token_ttl_s,
|
||||
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
||||
"runtime_surface": self._runtime_surface,
|
||||
"runtime_capabilities": self._capabilities,
|
||||
}
|
||||
if api_token is not None:
|
||||
payload["api_token"] = api_token
|
||||
return _http_json_response(payload)
|
||||
|
||||
def _bootstrap_ws_url(self, request: Any) -> str:
|
||||
headers = getattr(request, "headers", {}) or {}
|
||||
|
||||
Reference in New Issue
Block a user