DC-029: skip authLimiter for already-authenticated requests
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.
Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.
Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card
4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)
Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
// Tailscale device list panel for status.sami
|
||||
//
|
||||
// Polls /api/v1/tailscale/status (richer payload: online + offline peers with
|
||||
// lastSeen, OS, user) every 30s and renders a device card. Clicking the card
|
||||
// opens a panel that lists every device known to the tailnet — online ones
|
||||
// first, with "last seen Xm ago" for offline ones.
|
||||
//
|
||||
// Self-contained so it doesn't need to be in dist/. Just needs secureFetch,
|
||||
// escapeHtml, and timeAgo (all from dist/core.js, which loads first).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const REFRESH_MS = 30 * 1000; // status doesn't change fast — 30s is plenty
|
||||
const STATUS_URL = '/api/v1/tailscale/status';
|
||||
const DEVICES_URL = '/api/v1/tailscale/devices';
|
||||
|
||||
const OS_ICON = {
|
||||
windows: '🪟',
|
||||
linux: '🐧',
|
||||
darwin: '🍎',
|
||||
android: '🤖',
|
||||
ios: '📱',
|
||||
macos: '🍎',
|
||||
synology:'💾',
|
||||
other: '🖥️',
|
||||
};
|
||||
|
||||
function osKey(raw) {
|
||||
if (!raw) return 'other';
|
||||
const s = String(raw).toLowerCase();
|
||||
if (s.includes('windows')) return 'windows';
|
||||
if (s.includes('darwin') || s.includes('macos') || s.includes('mac')) return 'darwin';
|
||||
if (s.includes('android')) return 'android';
|
||||
if (s.includes('ios')) return 'ios';
|
||||
if (s.includes('linux')) return 'linux';
|
||||
if (s.includes('synology') || s.includes('dsm')) return 'synology';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Module-local cache so the panel render doesn't refetch when the user toggles
|
||||
// between card and panel view.
|
||||
let cache = null; // last successful /status payload
|
||||
let lastFetch = 0; // ms timestamp of last successful fetch
|
||||
let pollTimer = null;
|
||||
let inFlight = null; // promise of in-flight fetch (deduplicate overlapping calls)
|
||||
|
||||
function $ (id) { return document.getElementById(id); }
|
||||
|
||||
function setCard (onlineCount, totalCount, connected, installed) {
|
||||
const card = $('tailscale-card');
|
||||
const dot = $('tailscale-dot');
|
||||
const pill = $('tailscale-pill');
|
||||
const summary = $('tailscale-summary');
|
||||
if (!card || !dot || !pill || !summary) return;
|
||||
|
||||
if (!installed) {
|
||||
card.setAttribute('data-status', 'off');
|
||||
dot.className = 'dot bad at-bl';
|
||||
pill.className = 'badge off';
|
||||
pill.textContent = 'N/A';
|
||||
summary.textContent = 'Tailscale not installed';
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
card.setAttribute('data-status', 'off');
|
||||
dot.className = 'dot bad at-bl';
|
||||
pill.className = 'badge off';
|
||||
pill.textContent = 'OFF';
|
||||
summary.textContent = 'Daemon offline';
|
||||
return;
|
||||
}
|
||||
card.setAttribute('data-status', 'on');
|
||||
dot.className = 'dot ok at-bl';
|
||||
pill.className = 'badge on';
|
||||
pill.textContent = onlineCount + '/' + totalCount;
|
||||
summary.textContent = onlineCount === 1
|
||||
? '1 device online'
|
||||
: onlineCount + ' devices online';
|
||||
}
|
||||
|
||||
function deviceRow (dev) {
|
||||
const online = !!dev.online;
|
||||
const isSelf = !!dev.isSelf;
|
||||
const osIcon = OS_ICON[osKey(dev.os)] || OS_ICON.other;
|
||||
const host = escapeHtml(dev.hostname || dev.id || '(unknown)');
|
||||
const ip = dev.ip ? escapeHtml(dev.ip) : '';
|
||||
const user = dev.user ? escapeHtml(String(dev.user).split('@')[0]) : '';
|
||||
const lastSeen = (!online && dev.lastSeen)
|
||||
? '<span style="color: var(--muted); font-size: 0.75rem; margin-left: 6px;">last seen ' + escapeHtml(timeAgo(dev.lastSeen)) + '</span>'
|
||||
: '';
|
||||
const selfBadge = isSelf
|
||||
? '<span style="background: var(--accent); color: var(--bg); padding: 1px 6px; border-radius: 4px; font-size: 0.65rem; font-weight: 700; margin-left: 6px; letter-spacing: 0.5px;">THIS DEVICE</span>'
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="ts-device" data-online="${online ? '1' : '0'}" style="
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
opacity: ${online ? '1' : '0.55'};
|
||||
">
|
||||
<span style="font-size: 1.4rem; line-height: 1;">${osIcon}</span>
|
||||
<div style="flex: 1; min-width: 0; overflow: hidden;">
|
||||
<div style="display: flex; align-items: center; min-width: 0;">
|
||||
<span style="font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${host}</span>${selfBadge}
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-top: 2px; min-width: 0;">
|
||||
<span style="
|
||||
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: ${online ? 'var(--uptime)' : 'var(--bad-fg)'};
|
||||
box-shadow: 0 0 ${online ? '6px' : '0'} ${online ? 'var(--uptime)' : 'transparent'};
|
||||
flex-shrink: 0;
|
||||
"></span>
|
||||
<span style="font-size: 0.75rem; color: ${online ? 'var(--uptime)' : 'var(--bad-fg)'}; font-weight: 600;">
|
||||
${online ? 'ONLINE' : 'OFFLINE'}
|
||||
</span>
|
||||
${user ? '<span style="color: var(--muted); font-size: 0.75rem;">·</span><span style="color: var(--muted); font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">' + user + '</span>' : ''}
|
||||
${ip ? '<span style="color: var(--muted); font-size: 0.75rem;">·</span><code style="color: var(--muted); font-size: 0.7rem; font-family: ui-monospace, monospace;">' + ip + '</code>' : ''}
|
||||
${lastSeen}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPanel (status) {
|
||||
const list = $('tailscale-device-list');
|
||||
const count = $('tailscale-panel-count');
|
||||
const tailnet = $('tailscale-tailnet');
|
||||
if (!list) return;
|
||||
|
||||
if (!status || !status.installed) {
|
||||
list.innerHTML = '<div style="color: var(--muted); padding: 20px; text-align: center;">Tailscale is not installed on this host.</div>';
|
||||
if (count) count.textContent = '';
|
||||
if (tailnet) tailnet.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!status.connected) {
|
||||
list.innerHTML = '<div style="color: var(--muted); padding: 20px; text-align: center;">Tailscale daemon is not running. Start it with <code>systemctl start tailscaled</code>.</div>';
|
||||
if (count) count.textContent = '';
|
||||
if (tailnet) tailnet.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build merged device list: self first, then online peers, then offline peers
|
||||
const devices = [];
|
||||
if (status.self && status.self.ip) {
|
||||
devices.push({
|
||||
id: 'self', hostname: status.self.hostname, ip: status.self.ip,
|
||||
os: status.self.os, online: !!status.self.online, isSelf: true,
|
||||
lastSeen: status.self.online ? null : null,
|
||||
});
|
||||
}
|
||||
const onlinePeers = [];
|
||||
const offlinePeers = [];
|
||||
for (const dev of (status.devices || [])) {
|
||||
if (dev.isSelf) continue; // already added above
|
||||
const entry = {
|
||||
id: dev.id, hostname: dev.hostname, ip: dev.ip, os: dev.os,
|
||||
online: !!dev.online, user: dev.user,
|
||||
lastSeen: dev.online ? null : dev.lastSeen,
|
||||
};
|
||||
if (entry.online) onlinePeers.push(entry); else offlinePeers.push(entry);
|
||||
}
|
||||
// Sort: hostname alpha within each bucket
|
||||
onlinePeers.sort((a, b) => (a.hostname || '').localeCompare(b.hostname || ''));
|
||||
offlinePeers.sort((a, b) => {
|
||||
// Most-recently-seen first for offline
|
||||
const ta = a.lastSeen ? new Date(a.lastSeen).getTime() : 0;
|
||||
const tb = b.lastSeen ? new Date(b.lastSeen).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
const sorted = devices.concat(onlinePeers, offlinePeers);
|
||||
|
||||
if (sorted.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--muted); padding: 20px; text-align: center;">No devices found in this tailnet.</div>';
|
||||
} else {
|
||||
list.innerHTML = sorted.map(deviceRow).join('');
|
||||
}
|
||||
|
||||
const onlineCount = sorted.filter(d => d.online).length;
|
||||
if (count) {
|
||||
count.textContent = sorted.length === 1
|
||||
? '1 device total'
|
||||
: sorted.length + ' devices total · ' + onlineCount + ' online';
|
||||
}
|
||||
if (tailnet) {
|
||||
const tn = status.self && status.self.tailnetName;
|
||||
tailnet.textContent = tn ? '(' + tn + ')' : '';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStatus (force) {
|
||||
// Dedupe overlapping calls + skip if we have a fresh cache
|
||||
const now = Date.now();
|
||||
if (!force && cache && (now - lastFetch) < REFRESH_MS) return cache;
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await secureFetch(STATUS_URL, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('status ' + res.status);
|
||||
const data = await res.json();
|
||||
cache = data;
|
||||
lastFetch = Date.now();
|
||||
return data;
|
||||
} catch (e) {
|
||||
// If /status fails, the container might be on an older build without it.
|
||||
// Fall back to /devices (smaller payload, online only).
|
||||
try {
|
||||
const res2 = await secureFetch(DEVICES_URL, { cache: 'no-store' });
|
||||
if (!res2.ok) throw new Error('devices ' + res2.status);
|
||||
const d = await res2.json();
|
||||
cache = {
|
||||
installed: true,
|
||||
connected: true,
|
||||
self: d.devices && d.devices.find(x => x.isSelf),
|
||||
devices: (d.devices || []).filter(x => !x.isSelf),
|
||||
};
|
||||
lastFetch = Date.now();
|
||||
return cache;
|
||||
} catch (e2) {
|
||||
// Surface a graceful "unknown" state rather than throwing
|
||||
if (typeof errorHandler !== 'undefined') {
|
||||
errorHandler.logError('[Tailscale] Fetch', e2, { function: 'fetchStatus' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
async function refresh () {
|
||||
const status = await fetchStatus(false);
|
||||
if (!status) {
|
||||
setCard(0, 0, false, false);
|
||||
return;
|
||||
}
|
||||
const installed = !!status.installed;
|
||||
const connected = !!status.connected;
|
||||
const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0);
|
||||
const online = (status.devices ? status.devices.filter(d => d.online).length : 0)
|
||||
+ (status.self && status.self.online ? 1 : 0);
|
||||
setCard(online, total, connected, installed);
|
||||
// Only re-render the panel if it's currently open (saves DOM thrash)
|
||||
const panel = $('tailscale-panel');
|
||||
if (panel && panel.style.display !== 'none') {
|
||||
renderPanel(status);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel () {
|
||||
const panel = $('tailscale-panel');
|
||||
if (!panel) return;
|
||||
const isOpen = panel.style.display !== 'none';
|
||||
if (isOpen) {
|
||||
panel.style.display = 'none';
|
||||
} else {
|
||||
panel.style.display = '';
|
||||
// Force a refresh when opening so the user always sees fresh data
|
||||
fetchStatus(true).then(renderPanel);
|
||||
}
|
||||
}
|
||||
|
||||
function init () {
|
||||
// Card click → toggle panel (skip if click was on the refresh button)
|
||||
const card = $('tailscale-card');
|
||||
if (card) {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target && e.target.id === 'tailscale-refresh-btn') return;
|
||||
togglePanel();
|
||||
});
|
||||
}
|
||||
// Cursor hint
|
||||
if (card) card.style.cursor = 'pointer';
|
||||
|
||||
// Refresh button → force fetch
|
||||
const btn = $('tailscale-refresh-btn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
btn.disabled = true;
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '…';
|
||||
try {
|
||||
const status = await fetchStatus(true);
|
||||
if (status) {
|
||||
renderPanel(status);
|
||||
// Update card too
|
||||
const total = (status.devices ? status.devices.length : 0) + (status.self ? 1 : 0);
|
||||
const online = (status.devices ? status.devices.filter(d => d.online).length : 0)
|
||||
+ (status.self && status.self.online ? 1 : 0);
|
||||
setCard(online, total, status.connected, status.installed);
|
||||
}
|
||||
} finally {
|
||||
btn.innerHTML = orig;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close button on panel
|
||||
const closeBtn = $('tailscale-panel-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
const panel = $('tailscale-panel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initial fetch + periodic poll
|
||||
refresh();
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(refresh, REFRESH_MS);
|
||||
}
|
||||
|
||||
// Re-init if the card is later rendered (e.g. after TOTP auth completes)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
// Defer slightly to let dist/core.js's secureFetch be defined
|
||||
setTimeout(init, 0);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user