fix(webui): gate bootstrap API token issuance

This commit is contained in:
chengyongru
2026-07-08 21:01:48 +08:00
committed by Xubin Ren
parent 7204d88a4c
commit 88143a8bf0
16 changed files with 219 additions and 85 deletions
+30 -5
View File
@@ -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]")
+6
View File
@@ -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
View File
@@ -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 {}