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 <router_key>`
- Query param (fallback): `?api_key=<router_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"
```
This commit is contained in:
YetheSamartaka 2026-01-14 09:28:02 +01:00
parent 6828411f95
commit eca4a92a33
9 changed files with 412 additions and 25 deletions

View file

@ -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 @@
/></a>
<div class="header-row">
<h1>Router Dashboard</h1>
<button id="total-tokens-btn">Stats Total</button><span id="aggregation-status" class="loading" style="margin-left:8px;"></span>
<button id="total-tokens-btn">Stats Total</button>
<span id="aggregation-status" class="loading" style="margin-left:8px;"></span>
</div>
<button onclick="toggleDarkMode()" id="dark-mode-button">
@ -353,14 +383,109 @@
</table>
</div>
<script>
<script>
let psRows = new Map();
let statsModal = null;
let statsChart = null;
let rawTimeSeries = null;
let totalTokensChart = null;
let usageSource = null;
// Global placeholders for stats modal handling
let statsModal = null; // stats modal element
let statsChart = null; // Chart.js instance inside the modal
let rawTimeSeries = null; // raw timeseries data for the current model
let totalTokensChart = null; // Chart.js instance for total tokens modal
const API_KEY_STORAGE_KEY = "nomyo-router-api-key";
let apiKeyWaiters = [];
function getStoredApiKey() {
return localStorage.getItem(API_KEY_STORAGE_KEY) || "";
}
function setStoredApiKey(key) {
if (key) {
localStorage.setItem(API_KEY_STORAGE_KEY, key);
} else {
localStorage.removeItem(API_KEY_STORAGE_KEY);
}
updateApiKeyIndicator();
}
function updateApiKeyIndicator() {
const indicator = document.getElementById("api-key-indicator");
if (!indicator) return;
const hasKey = !!getStoredApiKey();
indicator.textContent = hasKey ? "API key set" : "API key not set";
indicator.style.color = hasKey ? "green" : "#b22222";
}
function buildAuthedUrl(url) {
const key = getStoredApiKey();
if (!key) return url;
try {
const u = new URL(url, window.location.origin);
if (!u.searchParams.has("api_key")) {
u.searchParams.set("api_key", key);
}
return u.toString();
} catch (err) {
return url;
}
}
function showApiKeyModal(reasonText) {
const overlay = document.getElementById("api-key-modal");
if (!overlay) return Promise.resolve();
document.body.classList.add("auth-locked");
const reason = document.getElementById("api-key-reason");
if (reason) {
reason.textContent =
reasonText ||
"Enter the NOMYO Router API key to continue.";
}
const status = document.getElementById("api-key-status");
if (status) {
status.textContent = "";
}
const input = document.getElementById("api-key-input");
if (input) {
input.value = getStoredApiKey();
setTimeout(() => input.focus(), 10);
}
overlay.style.display = "flex";
return new Promise((resolve) => apiKeyWaiters.push(resolve));
}
function closeApiKeyModal(statusMessage) {
const overlay = document.getElementById("api-key-modal");
const status = document.getElementById("api-key-status");
if (status) {
status.textContent = statusMessage || "";
}
if (overlay) {
overlay.style.display = "none";
}
if (getStoredApiKey()) {
document.body.classList.remove("auth-locked");
}
while (apiKeyWaiters.length) {
const resolve = apiKeyWaiters.pop();
resolve();
}
}
async function authedFetch(url, options = {}, allowRetry = true) {
const headers = new Headers(options.headers || {});
const key = getStoredApiKey();
if (key) {
headers.set("Authorization", `Bearer ${key}`);
}
const response = await fetch(url, { ...options, headers });
if ((response.status === 401 || response.status === 403) && allowRetry) {
await showApiKeyModal("Enter the NOMYO Router API key to continue.");
return authedFetch(url, options, false);
}
return response;
}
/* Integrated modal initialization and close handling into the main load block */
@ -505,11 +630,11 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
chart.data.labels = labels;
chart.data.datasets[0].data = inputData;
chart.data.datasets[1].data = outputData;
chart.update();
chart.update();
}
/* ---------- Utility ---------- */
async function fetchJSON(url) {
const resp = await fetch(url);
async function fetchJSON(url, options = {}) {
const resp = await authedFetch(url, options);
if (!resp.ok) {
throw new Error(`Failed ${url}: ${resp.status}`);
}
@ -524,6 +649,9 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
async function loadEndpoints() {
try {
const data = await fetchJSON("/api/config");
if (data.require_router_api_key && !getStoredApiKey()) {
showApiKeyModal("Enter the NOMYO Router API key to load the dashboard.");
}
const body = document.getElementById("endpoints-body");
body.innerHTML = data.endpoints
.map((e) => {
@ -580,7 +708,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
);
if (!dest) return;
try {
const resp = await fetch(
const resp = await authedFetch(
`/api/copy?source=${encodeURIComponent(source)}&destination=${encodeURIComponent(dest)}`,
{ method: "POST" },
);
@ -613,7 +741,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
);
if (ok) {
try {
const resp = await fetch(
const resp = await authedFetch(
`/api/delete?model=${encodeURIComponent(model)}`,
{ method: "DELETE" },
);
@ -689,8 +817,12 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
return Math.abs(hash);
}
async function loadUsage() {
// Create the EventSource once and keep it around
const source = new EventSource("/api/usage-stream");
if (usageSource) {
usageSource.close();
usageSource = null;
}
const source = new EventSource(buildAuthedUrl("/api/usage-stream"));
usageSource = source;
// Helper that receives the payload and renders the chart
const renderChart = (data) => {
@ -734,7 +866,8 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
psRows.forEach((row, model) => {
let tokenTotal = 0;
for (const ep in tokens) {
tokenTotal += tokens[ep][model] || 0;
const endpointTokens = tokens[ep] || {};
tokenTotal += endpointTokens[model] || 0;
}
const tokenCell = row.querySelector(".token-usage");
if (tokenCell) tokenCell.textContent = tokenTotal;
@ -744,14 +877,44 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
}
};
source.onerror = (err) => {
source.onerror = async (err) => {
console.error("SSE connection error. Retrying...", err);
source.close();
await showApiKeyModal("Enter the NOMYO Router API key to view live usage.");
loadUsage();
};
window.addEventListener("beforeunload", () => source.close());
}
/* ---------- Init ---------- */
window.addEventListener("load", () => {
updateApiKeyIndicator();
const apiKeyModal = document.getElementById("api-key-modal");
if (apiKeyModal) {
apiKeyModal.addEventListener("click", (e) => {
if (e.target === apiKeyModal || e.target.matches(".close-btn")) {
closeApiKeyModal();
}
});
}
const saveKeyBtn = document.getElementById("api-key-save");
if (saveKeyBtn) {
saveKeyBtn.addEventListener("click", () => {
const key = document.getElementById("api-key-input")?.value.trim();
setStoredApiKey(key);
closeApiKeyModal(key ? "API key saved." : "API key cleared.");
loadUsage();
});
}
const clearKeyBtn = document.getElementById("api-key-clear");
if (clearKeyBtn) {
clearKeyBtn.addEventListener("click", () => {
setStoredApiKey("");
closeApiKeyModal("API key cleared.");
loadUsage();
});
}
loadEndpoints();
loadTags();
loadPS();
@ -765,7 +928,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
e.preventDefault();
const model = e.target.dataset.model;
try {
const resp = await fetch(
const resp = await authedFetch(
`/api/show?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
@ -802,7 +965,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
return;
}
try {
const resp = await fetch(
const resp = await authedFetch(
`/api/pull?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
@ -836,7 +999,7 @@ function renderTimeSeriesChart(timeSeriesData, chart, minutes) {
e.preventDefault();
const model = e.target.dataset.model;
try {
const resp = await fetch(
const resp = await authedFetch(
`/api/stats?model=${encodeURIComponent(model)}`,
{ method: "POST" },
);
@ -1003,14 +1166,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (totalBtn) {
totalBtn.addEventListener('click', async () => {
try {
const resp = await fetch('/api/token_counts');
const resp = await authedFetch('/api/token_counts');
const data = await resp.json();
const modal = document.getElementById('total-tokens-modal');
const numberEl = document.getElementById('total-tokens-number');
numberEl.textContent = data.total_tokens;
document.getElementById('aggregation-status').textContent = 'Aggregating...';
try {
const aggResp = await fetch('/api/aggregate_time_series_days', {
const aggResp = await authedFetch('/api/aggregate_time_series_days', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days: 30 , trim_old: true})
@ -1119,6 +1282,25 @@ document.addEventListener('DOMContentLoaded', () => {
});
</script>
<div id="api-key-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>
<h2>Router API Key</h2>
<p id="api-key-reason" class="modal-message">Enter the NOMYO Router API key to continue.</p>
<input
id="api-key-input"
type="password"
placeholder="NOMYO Router API key"
autocomplete="off"
/>
<div class="modal-actions">
<button id="api-key-clear">Clear</button>
<button id="api-key-save">Save</button>
</div>
<p id="api-key-status" class="loading"></p>
</div>
</div>
<div id="show-modal" class="modal">
<div class="modal-content">
<span class="close-btn">&times;</span>