From eca4a92a33a7ca3e445e1771aa6cccfaaebe67bd Mon Sep 17 00:00:00 2001 From: YetheSamartaka <55753928+YetheSamartaka@users.noreply.github.com> Date: Wed, 14 Jan 2026 09:28:02 +0100 Subject: [PATCH] add: Optional router-level API key that gates router/API/web UI access Optional router-level API key that gates router/API/web UI access (leave empty to disable) ## Supplying the router API key If you set `nomyo-router-api-key` in `config.yaml` (or `NOMYO_ROUTER_API_KEY` env), every request to NOMYO Router must include the key: - HTTP header (recommended): `Authorization: Bearer ` - Query param (fallback): `?api_key=` Examples: ```bash curl -H "Authorization: Bearer $NOMYO_ROUTER_API_KEY" http://localhost:12434/api/tags curl "http://localhost:12434/api/tags?api_key=$NOMYO_ROUTER_API_KEY" ``` --- README.md | 19 +++ config.yaml | 3 + doc/README.md | 14 ++ doc/configuration.md | 42 ++++++ doc/deployment.md | 4 + doc/examples/sample-config.yaml | 3 + doc/usage.md | 15 +++ router.py | 113 +++++++++++++++- static/index.html | 224 +++++++++++++++++++++++++++++--- 9 files changed, 412 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 6c3f402..abbf181 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ endpoints: # Maximum concurrent connections *per endpoint‑model pair* max_concurrent_connections: 2 +# Optional router-level API key to lock down router + dashboard (leave empty to disable) +nomyo-router-api-key: "" + # API keys for remote endpoints # Set an environment variable like OPENAI_KEY # Confirm endpoints are exactly as in endpoints block @@ -45,6 +48,8 @@ pip3 install -r requirements.txt ``` export OPENAI_KEY=YOUR_SECRET_API_KEY +# Optional: router-level key (clients must send Authorization: Bearer) +# export NOMYO_ROUTER_API_KEY=YOUR_ROUTER_KEY ``` finally you can @@ -91,3 +96,17 @@ This way the Ollama backend servers are utilized more efficient than by simply u ![routing](https://github.com/user-attachments/assets/ed05dfbb-fcc8-4ff2-b8ca-3cdce2660c9f) NOMYO Router also supports OpenAI API compatible v1 backend servers. + + +## Supplying the router API key + +If you set `nomyo-router-api-key` in `config.yaml` (or `NOMYO_ROUTER_API_KEY` env), every request to NOMYO Router must include the key: + +- HTTP header (recommended): `Authorization: Bearer ` +- Query param (fallback): `?api_key=` + +Examples: +```bash +curl -H "Authorization: Bearer $NOMYO_ROUTER_API_KEY" http://localhost:12434/api/tags +curl "http://localhost:12434/api/tags?api_key=$NOMYO_ROUTER_API_KEY" +``` diff --git a/config.yaml b/config.yaml index bb8e8f5..6657e78 100644 --- a/config.yaml +++ b/config.yaml @@ -8,6 +8,9 @@ endpoints: # Maximum concurrent connections *per endpoint‑model pair* (equals to OLLAMA_NUM_PARALLEL) max_concurrent_connections: 2 +# Optional router-level API key that gates router/API/web UI access (leave empty to disable) +nomyo-router-api-key: "" + # API keys for remote endpoints # Set an environment variable like OPENAI_KEY # Confirm endpoints are exactly as in endpoints block diff --git a/doc/README.md b/doc/README.md index 70926b7..7978cec 100644 --- a/doc/README.md +++ b/doc/README.md @@ -36,6 +36,8 @@ doc/ endpoints: - http://localhost:11434 max_concurrent_connections: 2 + # Optional router-level API key (leave blank to disable) + nomyo-router-api-key: "" ``` 3. **Run the router**: @@ -135,3 +137,15 @@ For additional help: 5. **Scale as needed** by adding more endpoints Happy routing! 🚀 + + +## Router API key usage + +If the router API key is set (`NOMYO_ROUTER_API_KEY` env or `nomyo-router-api-key` in config), include it in every request: +- Header (preferred): Authorization: Bearer +- Query param: ?api_key= + +Example: +```bash +curl -H "Authorization: Bearer $NOMYO_ROUTER_API_KEY" http://localhost:12434/api/tags +``` diff --git a/doc/configuration.md b/doc/configuration.md index 7ca9b1f..afb7dda 100644 --- a/doc/configuration.md +++ b/doc/configuration.md @@ -14,6 +14,9 @@ endpoints: # Maximum concurrent connections *per endpoint‑model pair* max_concurrent_connections: 2 + +# Optional router-level API key to secure the router and dashboard (leave blank to disable) +nomyo-router-api-key: "" ``` ### Complete Example @@ -29,6 +32,9 @@ endpoints: # Maximum concurrent connections *per endpoint‑model pair* (equals to OLLAMA_NUM_PARALLEL) max_concurrent_connections: 2 +# Optional router-level API key to secure the router and dashboard (leave blank to disable) +nomyo-router-api-key: "" + # API keys for remote endpoints # Set an environment variable like OPENAI_KEY # Confirm endpoints are exactly as in endpoints block @@ -80,6 +86,21 @@ max_concurrent_connections: 4 - When this limit is reached, the router will route requests to other endpoints with available capacity - Higher values allow more parallel requests but may increase memory usage +### `router_api_key` + +**Type**: `str` (optional) + +**Description**: Shared secret that gates access to the NOMYO Router APIs and dashboard. When set, clients must send `Authorization: Bearer ` or an `api_key` query parameter. + +**Example**: +```yaml +nomyo-router-api-key: "super-secret-value" +``` + +**Notes**: +- Leave this blank or omit it to disable router-level authentication. +- You can also set the `NOMYO_ROUTER_API_KEY` environment variable to avoid storing the key in plain text. + ### `api_keys` **Type**: `dict[str, str]` @@ -118,6 +139,15 @@ export NOMYO_ROUTER_CONFIG_PATH=/etc/nomyo-router/config.yaml export NOMYO_ROUTER_DB_PATH=/var/lib/nomyo-router/token_counts.db ``` +### `NOMYO_ROUTER_API_KEY` + +**Description**: Router-level API key. When set, all router endpoints and the dashboard require this key via `Authorization: Bearer ` or the `api_key` query parameter. + +**Example**: +```bash +export NOMYO_ROUTER_API_KEY=your_router_api_key +``` + ### API-Specific Keys You can set API keys directly as environment variables: @@ -195,3 +225,15 @@ The configuration is loaded at startup and cannot be changed without restarting ## Example Configurations See the [examples](examples/) directory for ready-to-use configuration examples. + + +### Using the router API key + +When `router_api_key`/`NOMYO_ROUTER_API_KEY` is set, clients must send it on every request: +- Header (recommended): Authorization: Bearer +- Query param (fallback): ?api_key= + +Example: +```bash +curl -H "Authorization: Bearer $NOMYO_ROUTER_API_KEY" http://localhost:12434/api/tags +``` diff --git a/doc/deployment.md b/doc/deployment.md index f7220f8..0970a35 100644 --- a/doc/deployment.md +++ b/doc/deployment.md @@ -217,6 +217,7 @@ data: endpoints: - http://ollama-service:11434 max_concurrent_connections: 2 + nomyo-router-api-key: "" --- apiVersion: v1 kind: PersistentVolumeClaim @@ -283,6 +284,9 @@ endpoints: max_concurrent_connections: 4 +# Optional router-level API key to secure the router and dashboard (leave blank to disable) +nomyo-router-api-key: "" + api_keys: "https://api.openai.com/v1": "${OPENAI_KEY}" ``` diff --git a/doc/examples/sample-config.yaml b/doc/examples/sample-config.yaml index 9fb8d94..9dbce09 100644 --- a/doc/examples/sample-config.yaml +++ b/doc/examples/sample-config.yaml @@ -6,6 +6,9 @@ endpoints: max_concurrent_connections: 2 +# Optional router-level API key to secure the router and dashboard (leave blank to disable) +nomyo-router-api-key: "" + # Multi-endpoint configuration with local Ollama instances # endpoints: # - http://ollama-worker1:11434 diff --git a/doc/usage.md b/doc/usage.md index 2e5cda2..4912ce8 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -21,6 +21,9 @@ endpoints: - http://localhost:11434 max_concurrent_connections: 2 + +# Optional router-level API key (leave blank to disable) +nomyo-router-api-key: "" ``` ### 3. Run the Router @@ -346,3 +349,15 @@ print(f"Available models: {[m.id for m in response.data]}") ## Examples See the [examples](examples/) directory for complete integration examples. + + +### Authentication to NOMYO Router + +If a router API key is configured, include it with each request: +- Header: Authorization: Bearer +- Query: ?api_key= + +Example (tags): +```bash +curl -H "Authorization: Bearer $NOMYO_ROUTER_API_KEY" http://localhost:12434/api/tags +``` diff --git a/router.py b/router.py index cb5af1f..922661a 100644 --- a/router.py +++ b/router.py @@ -6,14 +6,14 @@ version: 0.5 license: AGPL """ # ------------------------------------------------------------- -import orjson, time, asyncio, yaml, ollama, openai, os, re, aiohttp, ssl, random, base64, io, enhance +import orjson, time, asyncio, yaml, ollama, openai, os, re, aiohttp, ssl, random, base64, io, enhance, secrets from datetime import datetime, timezone from pathlib import Path # Directory containing static files (relative to this script) STATIC_DIR = Path(__file__).parent / "static" from typing import Dict, Set, List, Optional -from urllib.parse import urlparse +from urllib.parse import urlparse, parse_qsl, urlencode from fastapi import FastAPI, Request, HTTPException from fastapi_sse import sse_handler from fastapi.staticfiles import StaticFiles @@ -41,6 +41,20 @@ _subscribers: Set[asyncio.Queue] = set() _subscribers_lock = asyncio.Lock() token_queue: asyncio.Queue[tuple[str, str, int, int]] = asyncio.Queue() +# ------------------------------------------------------------- +# Secret handling +# ------------------------------------------------------------- +def _mask_secrets(text: str) -> str: + """ + Mask common API key patterns to avoid leaking secrets in logs or error payloads. + """ + if not text: + return text + # OpenAI-style keys (sk-...) and generic "api key" mentions + text = re.sub(r"sk-[A-Za-z0-9]{4}[A-Za-z0-9_-]*", "sk-***redacted***", text) + text = re.sub(r"(?i)(api[-_ ]key\\s*[:=]\\s*)([^\\s]+)", r"\\1***redacted***", text) + return text + # ------------------------------------------------------------------ # Globals # ------------------------------------------------------------------ @@ -78,6 +92,8 @@ class Config(BaseSettings): max_concurrent_connections: int = 1 api_keys: Dict[str, str] = Field(default_factory=dict) + # Optional router-level API key used to gate access to this service and dashboard + router_api_key: Optional[str] = Field(default=None, env="NOMYO_ROUTER_API_KEY") # Database configuration db_path: str = Field(default=os.getenv("NOMYO_ROUTER_DB_PATH", "token_counts.db")) @@ -108,6 +124,29 @@ class Config(BaseSettings): with path.open("r", encoding="utf-8") as fp: data = yaml.safe_load(fp) or {} cleaned = cls._expand_env_refs(data) + if isinstance(cleaned, dict): + # Accept hyphenated config key and map it to the field name + key_aliases = [ + # canonical field name + "router_api_key", + # lowercase, hyphen/underscore variants + "nomyo-router-api-key", + "nomyo_router_api_key", + "nomyo-router_api_key", + "nomyo_router-api_key", + # uppercase env-style variants + "NOMYO-ROUTER_API_KEY", + "NOMYO_ROUTER_API_KEY", + ] + for alias in key_aliases: + if alias in cleaned: + cleaned["router_api_key"] = cleaned.get("router_api_key", cleaned.pop(alias)) + break + # If not present in YAML (or empty), fall back to env var explicitly + if not cleaned.get("router_api_key"): + env_key = os.getenv("NOMYO_ROUTER_API_KEY") + if env_key: + cleaned["router_api_key"] = env_key return cls(**cleaned) return cls() @@ -145,6 +184,69 @@ default_headers={ "X-Title": "NOMYO Router", } +# ------------------------------------------------------------- +# Router-level authentication (optional) +# ------------------------------------------------------------- +def _extract_router_api_key(request: Request) -> Optional[str]: + """ + Extract the provided router API key from the Authorization header or `api_key` + query parameter. The middleware uses this to gate access to API routes when + a router_api_key is configured. + """ + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.lower().startswith("bearer "): + return auth_header.split(" ", 1)[1].strip() + query_key = request.query_params.get("api_key") + if query_key: + return query_key + return None + + +def _strip_api_key_from_scope(request: Request) -> None: + """ + Remove api_key from the ASGI scope query string to avoid leaking it in logs. + """ + scope = request.scope + raw_qs = scope.get("query_string", b"") + if not raw_qs: + return + params = parse_qsl(raw_qs.decode("utf-8"), keep_blank_values=True) + filtered = [(k, v) for (k, v) in params if k != "api_key"] + scope["query_string"] = urlencode(filtered).encode("utf-8") + + +@app.middleware("http") +async def enforce_router_api_key(request: Request, call_next): + """ + Enforce the optional NOMYO Router API key for all non-static requests. + When `config.router_api_key` is set, clients must supply the key either in + the Authorization header (`Bearer `) or as `api_key` query parameter. + """ + expected_key = config.router_api_key + if not expected_key or request.method == "OPTIONS": + return await call_next(request) + + path = request.url.path + if path.startswith("/static") or path in {"/", "/favicon.ico"}: + return await call_next(request) + + provided_key = _extract_router_api_key(request) + # Strip the api_key query param from scope so access logs do not leak it + _strip_api_key_from_scope(request) + if provided_key is None: + return JSONResponse( + content={"detail": "Missing NOMYO Router API key"}, + status_code=401, + ) + + if not secrets.compare_digest(str(provided_key), str(expected_key)): + return JSONResponse( + content={"detail": "Invalid NOMYO Router API key"}, + status_code=403, + ) + + return await call_next(request) + # ------------------------------------------------------------- # 3. Global state: per‑endpoint per‑model active connection counters # ------------------------------------------------------------- @@ -165,7 +267,7 @@ def _is_fresh(cached_at: float, ttl: int) -> bool: async def _ensure_success(resp: aiohttp.ClientResponse) -> None: if resp.status >= 400: text = await resp.text() - raise HTTPException(status_code=resp.status, detail=text) + raise HTTPException(status_code=resp.status, detail=_mask_secrets(text)) def _format_connection_issue(url: str, error: Exception) -> str: """ @@ -1809,7 +1911,10 @@ async def config_proxy(request: Request): return {"url": url, "status": "error", "detail": detail} results = await asyncio.gather(*[check_endpoint(ep) for ep in config.endpoints]) - return {"endpoints": results} + return { + "endpoints": results, + "require_router_api_key": bool(config.router_api_key), + } # ------------------------------------------------------------- # 21. API route – OpenAI compatible Embedding diff --git a/static/index.html b/static/index.html index 0d22721..043649e 100644 --- a/static/index.html +++ b/static/index.html @@ -11,6 +11,12 @@ color: #333; padding: 20px; } + body.auth-locked { + background: #bfbfbf; + } + body.auth-locked > *:not(#api-key-modal) { + display: none !important; + } .dark-mode { filter: invert(100%); } @@ -167,6 +173,29 @@ cursor: pointer; font-size: 1.5rem; } + #api-key-modal .modal-content { + width: 420px; + height: auto; + max-width: 90%; + } + #api-key-modal .modal-message { + margin: 0.5rem 0; + } + #api-key-modal input[type="password"] { + width: 100%; + padding: 0.6rem; + box-sizing: border-box; + } + #api-key-modal .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; + } + #api-key-indicator { + font-size: 0.9rem; + color: #555; + } /* ---------- Usage Chart ---------- */ .usage-chart { margin-top: 20px; @@ -269,7 +298,8 @@ />

Router Dashboard

- + +
+ + +

+ + +