DC-029: skip authLimiter for already-authenticated requests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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:
Krystie
2026-07-02 18:27:03 -07:00
parent 57de3cb8e3
commit a92eeceae5
6 changed files with 532 additions and 12 deletions
+329
View File
@@ -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);
}
})();