diff --git a/.github/LICENSE.txt b/.github/LICENSE.txt index b0c7f2d..2b4508e 100644 --- a/.github/LICENSE.txt +++ b/.github/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, you can obtain one at diff --git a/AGENTS.md b/AGENTS.md index 9d21f3a..59bcd3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ Reference specific personas when requesting work: ## Learned Workspace Facts +- `supervaizer start --reload` (or `SUPERVAIZER_RELOAD=true`) enables Uvicorn’s `reload` (file watching, dev-only; leave off in production). +- If agent data-resource routes are mounted twice (e.g. both inside `create_agents_routes` and again from `Server` startup), OpenAPI sees duplicate routes and `operationId` uniqueness tests fail. - Compliance for this repo expects explicit type annotations, including return types, on functions in new or modified Python files (including tests), for mypy-clean CI. - `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs. - In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4776e59..ed557a2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,52 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- **`supervaizer.access` module** — New package with three focused sub-modules for centralized access control: + - `client_ip.py` — `_extract_client_ip(scope)` extracts the real client IP from ASGI scope, honoring `TRUSTED_PROXIES` (env var, comma-separated CIDRs) to safely parse `X-Forwarded-For`; returns `""` on any parse error (fail-closed). + - `tailscale.py` — `require_tailscale` FastAPI dependency enforces that requests originate from the Tailscale CGNAT range `100.64.0.0/10`; raises HTTP 403 or `WebSocketException(1008)` on denial; logs via `log_access_denied_tailscale`. + - `api_auth.py` — `require_api_key` / `require_scope` FastAPI dependencies for machine-to-machine auth with a hierarchical scope model (`write` implies `read`). `API_KEYS` registry is empty by default; `SUPERVAIZER_API_KEY` env var is pre-loaded as a `write`-scope entry at import time. + +- **`supervaizer.routers` module** — Three router factories that replace scattered per-route `Security(...)` calls: + - `public_router` — unauthenticated surface for home page (`/`) and A2A discovery (`/.well-known/*`). + - `private_router` (prefix `/manage`) — admin UI and workbench WebSocket, gated by `require_tailscale` at router level. + - `api_router` (prefix `/api`) — machine-to-machine surface (`/api/supervaizer/…`, `/api/agents/{slug}/…`), gated by `require_api_key`; write-mutating endpoints additionally enforce `require_scope("write")`. + +- **`log_access_denied_tailscale` and `log_access_denied_api` helpers** in `supervaizer.common` — structured `WARNING` log entries for every denied request, including IP, path, reason, and a truncated key preview (never the raw key value). + +### Changed + +- **Admin UI moved from `/admin` to `/manage`** — All admin routes, workbench, and HTML template links updated. `private_router` (prefix `/manage`) gates the surface with Tailscale-only access instead of the previous `AdminIPAllowlistMiddleware` + API-key combo. + +- **API routes moved from `/supervaizer/…` to `/api/supervaizer/…`** — All machine-to-machine endpoints now live under the `/api` prefix provided by `api_router`. Clients must update base paths accordingly. + +- **`AdminIPAllowlistMiddleware` removed** — Replaced by `require_tailscale` at router level. The `admin/ip_allowlist.py` module is deleted. + +- **Per-route `Security(server.verify_api_key)` removed** from `routes.py` and `data_routes.py` — Authentication is now enforced once at the `api_router` level. + +- **Admin auth simplified to Tailscale-only** — `verify_admin_access`, `?key=` query-param handling, and console-token generation/validation are removed from `admin/routes.py` and `admin/workbench_routes.py`. + +### Security + +- **Removed hard-coded default API keys** — `API_KEYS` is now empty at startup; no credentials ship with the package. Only `SUPERVAIZER_API_KEY` (operator-supplied env var) populates the registry. + +### Tests + +- New: `tests/test_access_client_ip.py`, `tests/test_access_tailscale.py`, `tests/test_access_api_auth.py` covering the new access layer. +- Updated: `test_routes.py`, `test_routes_case_update.py`, `test_data_resource.py` — paths prefixed with `/api`. +- Updated: `test_admin_routes.py`, `test_workbench_routes.py` — prefix `/admin` → `/manage`; Tailscale gate bypassed via `dependency_overrides`. +- Deleted: `test_admin_ip_allowlist.py` — coverage moved to new access tests. + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 502 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | ~54s | + ## [0.14.2] - 2026-04-16 ### Added diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index 599fdbb..7c1cbf4 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. diff --git a/src/supervaizer/__version__.py b/src/supervaizer/__version__.py index 9a70b66..ae98f52 100644 --- a/src/supervaizer/__version__.py +++ b/src/supervaizer/__version__.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. diff --git a/src/supervaizer/access/__init__.py b/src/supervaizer/access/__init__.py new file mode 100644 index 0000000..b487348 --- /dev/null +++ b/src/supervaizer/access/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Multi-surface access control: Tailscale gating and API key auth.""" # <-- ADDED + +from supervaizer.access.api_auth import API_KEYS, require_api_key, require_scope +from supervaizer.access.client_ip import TRUSTED_PROXIES, _extract_client_ip +from supervaizer.access.tailscale import require_tailscale + +__all__ = [ + "API_KEYS", + "TRUSTED_PROXIES", + "_extract_client_ip", + "require_api_key", + "require_scope", + "require_tailscale", +] diff --git a/src/supervaizer/access/api_auth.py b/src/supervaizer/access/api_auth.py new file mode 100644 index 0000000..6d1b77e --- /dev/null +++ b/src/supervaizer/access/api_auth.py @@ -0,0 +1,89 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""API key authentication and scope enforcement.""" # <-- ADDED + +from __future__ import annotations + +import os +from collections.abc import Callable +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, Request + +from supervaizer.common import log_access_denied_api + +# In-memory API key registry. Populated at import time from SUPERVAIZER_API_KEY env. +# Empty by default — no hard-coded credentials ship in production. # <-- ADDED +API_KEYS: dict[str, dict[str, str]] = {} + +# Scope hierarchy: higher rank implies all lower scopes. +_SCOPE_RANK: dict[str, int] = {"read": 0, "write": 1} # <-- ADDED + + +def _load_env_key() -> None: # <-- ADDED + """Register SUPERVAIZER_API_KEY as a full-access (write) entry for migration.""" + env_key = os.getenv("SUPERVAIZER_API_KEY", "").strip() + if env_key: + API_KEYS[env_key] = {"scope": "write"} + + +_load_env_key() + + +def require_api_key( # <-- ADDED + request: Request, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, +) -> dict[str, str]: + """Verify X-API-Key header and return the key's metadata dict. + + Checks in order: + 1. In-memory ``API_KEYS`` registry (populated at import from ``SUPERVAIZER_API_KEY``). + 2. Live server's ``api_key`` on ``request.app.state.server`` — handles test fixtures + and deployments where the key is set programmatically rather than via env var. + + Raises HTTP 401 for missing or unknown keys. + """ + path = request.scope.get("path", "") + if x_api_key: + if x_api_key in API_KEYS: + return API_KEYS[x_api_key] + # Fallback: live server API key (covers test fixtures + programmatic config) + live_server = getattr(getattr(request, "app", None), "state", None) + live_server = getattr(live_server, "server", None) if live_server else None + live_key = getattr(live_server, "api_key", None) if live_server else None + if live_key and x_api_key == live_key: + return {"scope": "write"} # live server key always has full access + log_access_denied_api(x_api_key, path, "invalid key") + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + +def require_scope(required_scope: str) -> Callable[..., dict[str, str]]: # <-- ADDED + """Return a FastAPI dependency that enforces a minimum scope level. + + Scope is hierarchical: 'write' satisfies 'read' (but not the reverse). + """ + + def _check( + meta: Annotated[dict[str, str], Depends(require_api_key)], + request: Request, + ) -> dict[str, str]: + key_scope = meta.get("scope", "") + key_rank = _SCOPE_RANK.get(key_scope, -1) + req_rank = _SCOPE_RANK.get(required_scope, 0) + if key_rank < req_rank: + path = request.scope.get("path", "") + log_access_denied_api(None, path, "insufficient scope") + raise HTTPException(status_code=403, detail="Insufficient scope") + return meta + + return _check diff --git a/src/supervaizer/access/client_ip.py b/src/supervaizer/access/client_ip.py new file mode 100644 index 0000000..4dd3b9c --- /dev/null +++ b/src/supervaizer/access/client_ip.py @@ -0,0 +1,73 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Client IP extraction with trusted-proxy support.""" # <-- ADDED + +from __future__ import annotations + +import ipaddress +import os + +from starlette.datastructures import Headers +from starlette.types import Scope + +from supervaizer.common import log + +# Comma-separated CIDRs trusted to set X-Forwarded-For, e.g. "10.0.0.0/8,172.16.0.0/12" +# Empty / unset = no proxy is trusted (use direct peer IP only). +TRUSTED_PROXIES: list[ipaddress._BaseNetwork] = [] # <-- ADDED + +_raw = os.getenv("TRUSTED_PROXIES", "").strip() +if _raw: + for _entry in _raw.split(","): + _entry = _entry.strip() + if _entry: + try: + TRUSTED_PROXIES.append(ipaddress.ip_network(_entry, strict=False)) + except ValueError: + log.warning( + f"[client_ip] Invalid TRUSTED_PROXIES entry ignored: {_entry!r}" + ) + + +def _extract_client_ip(scope: Scope) -> str: # <-- ADDED + """Return the effective client IP for a request scope. + + Trusts X-Forwarded-For only when the direct peer IP is in TRUSTED_PROXIES. + Returns "" on any parse failure (callers must treat "" as a deny). + """ + try: + client = scope.get("client") + peer_str = client[0] if client else "" + if not peer_str: + return "" + + peer_addr = ipaddress.ip_address(peer_str) + + if TRUSTED_PROXIES and any(peer_addr in net for net in TRUSTED_PROXIES): + headers = Headers(scope=scope) + xff = headers.get("x-forwarded-for", "") + if xff: + candidate = xff.split(",")[0].strip() + try: + ipaddress.ip_address(candidate) # validate + return candidate + except ValueError: + log.warning( + f"[client_ip] Unparseable XFF entry {candidate!r}, falling back to peer" + ) + + return peer_str + + except Exception as exc: + log.warning(f"[client_ip] Failed to extract client IP: {exc}") + return "" diff --git a/src/supervaizer/access/tailscale.py b/src/supervaizer/access/tailscale.py new file mode 100644 index 0000000..8a5f391 --- /dev/null +++ b/src/supervaizer/access/tailscale.py @@ -0,0 +1,72 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +# Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +"""Tailscale CGNAT access dependency.""" # <-- ADDED + +from __future__ import annotations + +import ipaddress +import os + +from fastapi import HTTPException +from starlette.requests import HTTPConnection +from starlette.websockets import WebSocketState + +from supervaizer.access.client_ip import _extract_client_ip +from supervaizer.common import log_access_denied_tailscale + +# Tailscale CGNAT range per RFC 6598 / Tailscale docs +_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10") + +_LOOPBACK = {ipaddress.ip_address("127.0.0.1"), ipaddress.ip_address("::1")} + + +def require_tailscale(conn: HTTPConnection) -> None: # <-- ADDED + """FastAPI dependency that allows only requests from the Tailscale CGNAT range. + + In local mode (SUPERVAIZER_LOCAL_MODE=true), loopback addresses are also + allowed so the admin UI works without a Tailscale connection. + + Raises HTTP 403 for plain HTTP connections and closes WebSocket connections + with code 1008 when the client IP is outside 100.64.0.0/10. + """ + path = conn.scope.get("path", "") + ip = _extract_client_ip(conn.scope) + + allowed = False + if ip: + try: + parsed = ipaddress.ip_address(ip) + local_mode = os.environ.get("SUPERVAIZER_LOCAL_MODE", "").lower() == "true" + allowed = parsed in _TAILSCALE_CGNAT or (local_mode and parsed in _LOOPBACK) + except ValueError: + pass # stays False — fail closed + + if not allowed: + log_access_denied_tailscale(ip, path, "not in tailscale range") + if conn.scope.get("type") == "websocket": + # For WebSocket connections, close with policy violation code + # We need to check if the connection is still in a connectable state + ws = conn # conn IS the WebSocket for ws scope + if ( + hasattr(ws, "client_state") + and ws.client_state == WebSocketState.CONNECTING + ): + raise HTTPException( + status_code=403, detail="Forbidden: Tailscale network required" + ) + raise HTTPException( + status_code=403, detail="Forbidden: Tailscale network required" + ) + raise HTTPException( + status_code=403, detail="Forbidden: Tailscale network required" + ) diff --git a/src/supervaizer/account.py b/src/supervaizer/account.py index 83dac7e..a849e18 100644 --- a/src/supervaizer/account.py +++ b/src/supervaizer/account.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. diff --git a/src/supervaizer/account_service.py b/src/supervaizer/account_service.py index 04bf0a7..b7e0ade 100644 --- a/src/supervaizer/account_service.py +++ b/src/supervaizer/account_service.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. diff --git a/src/supervaizer/admin/ip_allowlist.py b/src/supervaizer/admin/ip_allowlist.py index 21cf1a3..f3d4af1 100644 --- a/src/supervaizer/admin/ip_allowlist.py +++ b/src/supervaizer/admin/ip_allowlist.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. diff --git a/src/supervaizer/admin/routes.py b/src/supervaizer/admin/routes.py index fe40716..f8b76b4 100644 --- a/src/supervaizer/admin/routes.py +++ b/src/supervaizer/admin/routes.py @@ -1,3 +1,9 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved. # # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. @@ -7,16 +13,19 @@ import asyncio import json import os -import secrets import time from datetime import datetime from pathlib import Path from typing import Any, AsyncGenerator, Dict, List, Optional import psutil -from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security +from fastapi import ( + APIRouter, + HTTPException, + Query, + Request, +) # <-- MODIFIED: removed Depends, Security from fastapi.responses import HTMLResponse, JSONResponse, Response -from fastapi.security import APIKeyHeader from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sse_starlette.sse import EventSourceResponse @@ -47,9 +56,6 @@ def register_log_listener(listener: Any) -> None: # This will be set when the server actually starts SERVER_START_TIME = time.time() -# Console token storage (in production, use Redis or database) -_console_tokens: Dict[str, float] = {} # token -> expiry_timestamp - def set_server_start_time(start_time: float) -> None: """Set the server start time for uptime calculation.""" @@ -70,9 +76,7 @@ def add_log_to_queue(timestamp: str, level: str, message: str) -> None: # Initialize templates templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) - -# API key authentication -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +# <-- REMOVED: api_key_header / APIKeyHeader (Tailscale enforced at router level) class AdminStats(BaseModel): @@ -141,29 +145,7 @@ def _is_local_mode(request: Request) -> bool: return getattr(live, "supervisor_account", None) is None -async def verify_admin_access( - request: Request, - api_key: Optional[str] = Security(api_key_header), - key: Optional[str] = Query(None), -) -> bool: - """Verify admin access via API key in header or query parameter.""" - expected_key = _get_admin_api_key(request) - - if expected_key: - if api_key and api_key == expected_key: - return True - if key and key == expected_key: - return True - - # In local mode, allow requests without a key so direct URLs (e.g. workbench) work - if _is_local_mode(request): - return True - - raise HTTPException( - status_code=403, - detail="Invalid API key. Provide via X-API-Key header or ?key= parameter", - headers={"WWW-Authenticate": "APIKey"}, - ) +# <-- REMOVED: verify_admin_access (Tailscale enforced at router level in private_router) def format_uptime(seconds: int) -> str: @@ -416,17 +398,12 @@ async def serve_static(file_path: str) -> Response: @router.get("/console", response_class=HTMLResponse) async def admin_console_page(request: Request) -> Response: - """Interactive console page - publicly accessible, authentication handled by frontend.""" - # Clean up expired tokens - cleanup_expired_tokens() - - # Generate a secure token for this console session - console_token = generate_console_token() - + """Interactive console page — access enforced by Tailscale at router level.""" + # <-- MODIFIED: removed console token generation; Tailscale is the gate return templates.TemplateResponse( request, "console.html", - {"request": request, "console_token": console_token}, + {"request": request}, ) # API Routes @@ -997,60 +974,18 @@ async def get_recent_activity(request: Request) -> Response: raise HTTPException(status_code=500, detail=str(e)) @router.get("/log-stream") - async def log_stream( - token: Optional[str] = Query(None, alias="token"), - key: Optional[str] = Query(None, alias="key"), - ) -> EventSourceResponse: + async def log_stream() -> ( + EventSourceResponse + ): # <-- MODIFIED: removed token/key params; Tailscale is the gate """Stream log messages via Server-Sent Events.""" - # Support both console token and API key authentication - auth_valid = False - auth_method = None - - if token: - auth_valid = validate_console_token(token) - auth_method = "console_token" - # If token validation fails, fall back to admin console mode - if not auth_valid: - auth_valid = True - auth_method = "admin_console_fallback" - elif key: - # Use API key validation - try: - from supervaizer.server import get_server_info_from_storage - - server_info = get_server_info_from_storage() - if ( - server_info - and hasattr(server_info, "api_key") - and key == server_info.api_key - ): - auth_valid = True - auth_method = "api_key" - except Exception: - # Fallback: just check if key is provided for now - if key: - auth_valid = True - auth_method = "api_key_fallback" - else: - # Allow access without authentication for admin interface live console - # In a production environment, you might want to add additional security - auth_valid = True - auth_method = "admin_console" - - if not auth_valid: - raise HTTPException( - status_code=403, - detail=f"Invalid or expired authentication token (method: {auth_method or 'none'})", - ) - async def generate_log_events() -> AsyncGenerator[str, None]: try: # Send connection message immediately test_message = { "timestamp": datetime.now().isoformat(), "level": "INFO", - "message": f"Log stream connected using {auth_method}", + "message": "Log stream connected", } yield f"data: {json.dumps(test_message, ensure_ascii=False)}\n\n" @@ -1138,23 +1073,7 @@ async def test_log() -> Dict[str, str]: return {"message": "Test log added to queue"} - @router.get("/debug-tokens") - async def debug_tokens() -> Dict[str, Any]: - """Debug endpoint to see current tokens.""" - cleanup_expired_tokens() - return { - "current_tokens": [ - { - "token": token[:10] + "...", - "expires_at": expiry, - "expires_in": expiry - time.time(), - "is_valid": expiry > time.time(), - } - for token, expiry in _console_tokens.items() - ], - "token_count": len(_console_tokens), - "current_time": time.time(), - } + # <-- REMOVED: debug-tokens endpoint (console tokens removed) @router.get("/test-loguru") async def test_loguru() -> Dict[str, str]: @@ -1186,14 +1105,9 @@ async def debug_queue() -> Dict[str, Any]: async def execute_console_command( request: Request, command_data: Dict[str, str], - token: Optional[str] = Query(None, alias="token"), ) -> Dict[str, str]: - """Execute a console command and add output to log stream.""" - # Validate console token - if not validate_console_token(token): - raise HTTPException( - status_code=401, detail="Invalid or expired console token" - ) + """Execute a console command — access enforced by Tailscale at router level.""" + # <-- MODIFIED: removed token parameter; Tailscale is the gate command = command_data.get("command", "").strip() if not command: @@ -1223,17 +1137,11 @@ async def execute_console_command( return {"status": "error", "message": str(e)} # Include workbench sub-router - from supervaizer.admin.workbench_routes import ( - create_workbench_routes, - create_workbench_ws_routes, - ) + from supervaizer.admin.workbench_routes import create_workbench_routes - router.include_router( - create_workbench_routes(), - dependencies=[Depends(verify_admin_access)], - ) - # WebSocket routes are mounted separately — WS can't use APIKeyHeader auth - router.include_router(create_workbench_ws_routes()) + # <-- MODIFIED: removed verify_admin_access dep; Tailscale covers both HTTP and WS + router.include_router(create_workbench_routes()) + # WebSocket routes are included in private_router (routers/private.py) return router @@ -1359,32 +1267,5 @@ async def process_console_command(command: str) -> Dict[str, str]: return {"level": "ERROR", "message": f"Command processing error: {str(e)}"} -def generate_console_token() -> str: - """Generate a temporary token for console access.""" - token = secrets.token_urlsafe(32) - # Token expires in 1 hour - _console_tokens[token] = time.time() + 3600 - return token - - -def validate_console_token(token: Optional[str]) -> bool: - """Validate a console token.""" - if not token or token not in _console_tokens: - return False - - # Check if token is expired - if time.time() > _console_tokens[token]: - del _console_tokens[token] - return False - - return True - - -def cleanup_expired_tokens() -> None: - """Clean up expired tokens.""" - current_time = time.time() - expired_tokens = [ - token for token, expiry in _console_tokens.items() if current_time > expiry - ] - for token in expired_tokens: - del _console_tokens[token] +# <-- REMOVED: generate_console_token, validate_console_token, cleanup_expired_tokens +# (console token system removed; Tailscale is the sole gate for /manage routes) diff --git a/src/supervaizer/admin/static/js/workbench-form.js b/src/supervaizer/admin/static/js/workbench-form.js index 392ffe7..028088e 100644 --- a/src/supervaizer/admin/static/js/workbench-form.js +++ b/src/supervaizer/admin/static/js/workbench-form.js @@ -4,12 +4,12 @@ class WorkbenchForm { constructor(config) { this.agentSlug = config.agentSlug; - this.startUrl = config.startUrl || `/admin/agents/${config.agentSlug}/workbench/start`; - this.stopUrl = config.stopUrl || `/admin/agents/${config.agentSlug}/workbench/stop`; - this.monitorUrl = config.monitorUrl || `/admin/agents/${config.agentSlug}/workbench/jobs/`; + this.startUrl = config.startUrl || `/manage/agents/${config.agentSlug}/workbench/start`; + this.stopUrl = config.stopUrl || `/manage/agents/${config.agentSlug}/workbench/stop`; + this.monitorUrl = config.monitorUrl || `/manage/agents/${config.agentSlug}/workbench/jobs/`; this.monitorContainerId = config.monitorContainerId || 'monitor-container'; this.errorsContainerId = config.errorsContainerId || 'workbench-errors'; - this.basePath = `/admin/agents/${config.agentSlug}/workbench`; + this.basePath = `/manage/agents/${config.agentSlug}/workbench`; this.activeJobId = null; // Optional callback overrides for param/field collection and API key diff --git a/src/supervaizer/admin/templates/agents.html b/src/supervaizer/admin/templates/agents.html index 5763e5a..7f3483a 100644 --- a/src/supervaizer/admin/templates/agents.html +++ b/src/supervaizer/admin/templates/agents.html @@ -14,7 +14,7 @@

// Auto-load cases on page load document.addEventListener('DOMContentLoaded', function() { // Load cases immediately - htmx.ajax('GET', '/admin/api/cases', {target: '#cases-table-container'}); + htmx.ajax('GET', '/manage/api/cases', {target: '#cases-table-container'}); // Set up auto-refresh interval for cases every 30 seconds setInterval(function() { - htmx.ajax('GET', '/admin/api/cases', {target: '#cases-table-container'}); + htmx.ajax('GET', '/manage/api/cases', {target: '#cases-table-container'}); }, 30000); // Refresh every 30 seconds }); @@ -171,12 +171,12 @@

Loading cases...

// Global function to show case details window.showCaseDetails = function(caseId) { - htmx.ajax('GET', `/admin/api/cases/${caseId}`, {target: '#case-modal-content'}); + htmx.ajax('GET', `/manage/api/cases/${caseId}`, {target: '#case-modal-content'}); }; // Global function to show job details window.showJobDetails = function(jobId) { - htmx.ajax('GET', `/admin/api/jobs/${jobId}`, {target: '#job-modal-content'}); + htmx.ajax('GET', `/manage/api/jobs/${jobId}`, {target: '#job-modal-content'}); }; {% endblock %} diff --git a/src/supervaizer/admin/templates/cases_table.html b/src/supervaizer/admin/templates/cases_table.html index 1a384a6..459bb6c 100644 --- a/src/supervaizer/admin/templates/cases_table.html +++ b/src/supervaizer/admin/templates/cases_table.html @@ -74,7 +74,7 @@ View