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
+1 -1
View File
@@ -1 +1 @@
a5f51e4
fef7e07
@@ -116,4 +116,136 @@ describe('authLimiter [DC-027] path coverage', () => {
expect(RATE_LIMITS.STRICT.max).toBeLessThan(RATE_LIMITS.GENERAL.max);
expect(RATE_LIMITS.STRICT.windowMs).toBe(RATE_LIMITS.GENERAL.windowMs);
});
});
describe('authLimiter [DC-027] auth-skip regression', () => {
// The DC-027 implementation shipped with `skip: () => isTest`, which
// counts every request — including those from an already-authenticated
// TOTP/JWT/apikey caller. Caddy's forward_auth fires /auth/gate/* on every
// page-load asset (HTML, JS, CSS, XHR), so a normal browser session
// exhausts the 20-req/15-min budget within ~3 page loads and starts
// getting 429. The fix: skip when req.auth?.type is set by the upstream
// jwtApiKeyAuthMiddleware. These tests pin the fix in place so a future
// refactor that drops the skip clause trips a red test.
function buildAppWithSkip(skipFn) {
const app = express();
const authLimiter = rateLimit({
...RATE_LIMITS.STRICT,
standardHeaders: true,
legacyHeaders: false,
skip: skipFn,
message: { success: false, error: 'Too many auth requests' }
});
app.use('/api/v1/auth/gate', authLimiter);
app.use((req, res, next) => {
// Simulate jwtApiKeyAuthMiddleware populating req.auth
// (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware → authLimiter)
const sessionCookie = req.headers.cookie || '';
if (sessionCookie.includes('dashcaddy_session=')) {
req.auth = { type: 'session', scope: ['admin'] };
}
next();
});
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
return app;
}
test('skips when req.auth.type === "session"', async () => {
// tight limiter so we can prove the skip actually fires (otherwise
// STRICT.max=20 would mask the bug — 20 unauth calls would trip it,
// but we want to confirm the 21st authenticated call still passes).
const app = express();
// Simulate jwtApiKeyAuthMiddleware populating req.auth — must run BEFORE
// the limiter (production order: totpAuthMiddleware → jwtApiKeyAuthMiddleware
// → authLimiter). Use max=3 to confirm the skip actually fires.
app.use((req, res, next) => {
req.auth = { type: 'session', scope: ['admin'] };
next();
});
const tightLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey',
message: { success: false, error: 'Too many auth requests' }
});
app.use('/api/v1/auth/gate', tightLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
// 10 calls with a valid session — all should pass thanks to the skip
for (let i = 0; i < 10; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
}
});
test('skips when req.auth.type === "jwt"', async () => {
const app = express();
app.use((req, res, next) => {
req.auth = { type: 'jwt', scope: ['admin'] };
next();
});
const tightLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey',
});
app.use('/api/v1/auth/gate', tightLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
for (let i = 0; i < 10; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
}
});
test('skips when req.auth.type === "apikey"', async () => {
const app = express();
app.use((req, res, next) => {
req.auth = { type: 'apikey', scope: ['read'] };
next();
});
const tightLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey',
});
app.use('/api/v1/auth/gate', tightLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
for (let i = 0; i < 10; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
}
});
test('still counts UNAUTHENTICATED requests (security defense preserved)', async () => {
const app = express();
// NO auth middleware — req.auth is undefined for every request
const tightLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey',
message: { success: false, error: 'Too many auth requests' }
});
app.use('/api/v1/auth/gate', tightLimiter);
app.get('/api/v1/auth/gate/plex', (req, res) => res.json({ ok: true }));
// First 3 unauth calls pass
for (let i = 0; i < 3; i++) {
const res = await request(app).get('/api/v1/auth/gate/plex');
expect(res.status).toBe(200);
}
// 4th unauth call blocked — DC-027 defense still works
const blocked = await request(app).get('/api/v1/auth/gate/plex');
expect(blocked.status).toBe(429);
expect(blocked.body.error).toMatch(/too many/i);
});
});
+21 -10
View File
@@ -216,8 +216,13 @@ module.exports = function(deps) {
};
function buildLoginPage(service) {
// Pre-auth check via <meta http-equiv="refresh"> so it fires even when JS is
// disabled or blocked. The cookie is sent automatically because we hit the
// same origin (plex.sami); if the API returns 200 the user has a valid
// session and we render the auto-login body; if 401, the meta-refresh kicks
// in and sends them to status.sami to authenticate first.
const SHELL = (body) => `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>__TITLE__</title>
<html><head><meta charset="utf-8"><meta http-equiv="Cache-Control" content="no-store"><title>__TITLE__</title>
<style>body{background:__BG__;color:#e0e0e0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:12px}a{color:__ACCENT__}#d{font-size:12px;color:#888;max-width:80vw;overflow:auto;white-space:pre-wrap}</style>
</head><body><p id="m">__TITLE__</p><div id="d"></div>
<script>(function(){
@@ -226,7 +231,13 @@ function go(u){setTimeout(function(){location.replace(u)},300)}
function fail(msg,info){m.innerHTML=msg;d.textContent=info||''}
function ft(svc){return fetch('/dashcaddy-api/api/auth/app-token/'+svc,{credentials:'include'})}
function merge(ck,j,name){try{var c=JSON.parse(ls.getItem(ck)||'{}');if(c.Servers&&c.Servers.length){var s=c.Servers[0];s.AccessToken=j.token;s.UserId=j.userId||s.UserId||'';s.DateLastAccessed=Date.now();ls.setItem(ck,JSON.stringify(c));return}}catch(e){}ls.setItem(ck,JSON.stringify({Servers:[{Id:j.serverId||'',Name:j.serverName||name,UserId:j.userId||'',AccessToken:j.token,ManualAddress:location.origin,LastConnectionMode:2,DateLastAccessed:Date.now()}]}))}
// Pre-check session before attempting auto-login. If the user is not logged
// in, redirect to status.sami for TOTP auth first. The return= param sends
// them back to this login page after authenticating so auto-login can run.
fetch('/dashcaddy-api/api/auth/totp/check-session',{credentials:'include',cache:'no-store'}).then(function(r){return r.json()}).then(function(st){
if(!st||!st.success||!st.authenticated){go('https://status.sami?auth=required&return='+encodeURIComponent(location.href));return}
${body}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Auth check error: '+e.message)})
})()</script></body></html>`;
const pages = {
@@ -237,31 +248,31 @@ d.textContent='Fetching token from DashCaddy...';
ft('chat').then(function(r){d.textContent+=' Status: '+r.status;return r.text()}).then(function(t){
d.textContent+='\\n'+t.substring(0,300);
try{var j=JSON.parse(t);if(j.token){ls.setItem('token',j.token);go('/?direct=1')}
else{fail('Auto-login unavailable. <a href="/auth?nologin=1">Sign in manually</a>','No token field in response')}}
catch(e){fail('Auto-login error. <a href="/auth?nologin=1">Sign in manually</a>','Parse error: '+e.message)}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/auth?nologin=1">Sign in manually</a>','Fetch error: '+e.message)})`
else{fail('Auto-login unavailable. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','No token field in response')}}
catch(e){fail('Auto-login error. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Parse error: '+e.message)}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Fetch error: '+e.message)})`
},
plex: {
title: 'Signing in to Plex...', bg: '#1f1f1f', accent: '#e5a00d',
body: `if(ls.getItem('myPlexAccessToken')){go('/web/?direct=1');return}
ft('plex').then(function(r){return r.json()}).then(function(j){
if(j.token){ls.setItem('myPlexAccessToken',j.token);d.textContent='Token stored, redirecting...';go('/web/?direct=1')}
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/?direct=1">Open Plex manually</a>','Error: '+e.message)})`
else{fail('Auto-login unavailable. <a href="/web/?direct=1">Open Plex manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
},
jellyfin: {
title: 'Signing in to Jellyfin...', bg: '#101014', accent: '#00a4dc',
body: `ft('jellyfin').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('jellyfin_credentials',j,'Jellyfin');merge('_jellyfin_credentials',j,'Jellyfin');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Jellyfin manually</a>','Error: '+e.message)})`
else{fail('Auto-login unavailable. <a href="/web/">Open Jellyfin manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
},
emby: {
title: 'Signing in to Emby...', bg: '#101014', accent: '#52b54b',
body: `ft('emby').then(function(r){return r.json()}).then(function(j){
if(j.token){merge('emby_credentials',j,'Emby');merge('_emby_credentials',j,'Emby');d.textContent='Token stored, redirecting...';go('/web/')}
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="/web/">Open Emby manually</a>','Error: '+e.message)})`
else{fail('Auto-login unavailable. <a href="/web/">Open Emby manually</a> or <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">re-authenticate at DashCaddy</a>',JSON.stringify(j))}
}).catch(function(e){fail('Could not reach DashCaddy. <a href="https://status.sami?auth=required&return='+encodeURIComponent(location.href)+'">Sign in at DashCaddy</a>','Error: '+e.message)})`
},
};
+8 -1
View File
@@ -490,7 +490,14 @@ module.exports = function configureMiddleware(app, {
...RATE_LIMITS.STRICT,
standardHeaders: true,
legacyHeaders: false,
skip: () => isTest,
// SECURITY [DC-027]: rate limit credential scraping. Skip when the caller
// is already authenticated — req.auth.type is set by jwtApiKeyAuthMiddleware
// (above this in the chain), so by the time this runs we know whether the
// request came from a logged-in session, JWT, or API key. Without this
// exception, Caddy's forward_auth chatter on every page-load asset
// (HTML, JS, CSS, XHR) burns the budget for legit users — every browser
// session trips 429 within ~3 page loads.
skip: (req) => isTest || req.auth?.type === 'session' || req.auth?.type === 'jwt' || req.auth?.type === 'apikey',
message: { success: false, error: 'Too many auth requests, please try again later' }
});
app.use('/api/v1/auth/keys', authLimiter);
+41
View File
@@ -276,6 +276,29 @@
</div>
</div>
<div class="card" data-app="tailscale" data-status="off" id="tailscale-card"
title="Devices on your Tailscale network. Online = connected right now. Offline = known to the network but not currently connected.">
<span id="tailscale-dot" class="dot bad at-bl"></span>
<div class="row">
<div class="logo-wrap">
<svg viewBox="0 0 24 24" class="service-icon" aria-hidden="true">
<path d="M12 2L4 7v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V7l-8-5z" fill="none" stroke="#7D8FE3" stroke-width="2" stroke-linejoin="round"/>
<circle cx="12" cy="10" r="2.5" fill="#7D8FE3"/>
<path d="M12 13v4M9 19h6" stroke="#7D8FE3" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<span class="name">Tailscale</span>
<span class="spacer"></span>
<span id="tailscale-pill" class="badge off"></span>
</div>
<div class="response-row">
<span id="tailscale-summary" class="response-time" style="font-size: 0.7rem;">Loading…</span>
</div>
<div class="btn-row">
<button id="tailscale-refresh-btn" title="Refresh device list"></button>
</div>
</div>
<div class="card" data-app="ca" data-status="off">
<span id="dot-ca-grid" class="dot bad at-bl"></span>
<div class="row">
@@ -302,6 +325,21 @@
</div>
</div>
<!-- Tailscale Device Panel (collapsible, populated by tailscale-devices.js) -->
<div id="tailscale-panel" style="display: none; margin-bottom: 16px; padding: 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.1rem; font-weight: 600;">Devices on Tailscale</span>
<span id="tailscale-panel-count" style="color: var(--muted); font-size: 0.85rem;"></span>
</div>
<div style="display: flex; gap: 8px;">
<span id="tailscale-tailnet" style="color: var(--muted); font-size: 0.8rem; align-self: center;"></span>
<button id="tailscale-panel-close" class="btn-sm" title="Close panel" style="padding: 4px 10px;"></button>
</div>
</div>
<div id="tailscale-device-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px;"></div>
</div>
<!-- Service Filter Bar -->
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
@@ -906,6 +944,9 @@
<script src="/js/xterm.min.js" defer></script>
<script src="/js/xterm-fit.min.js" defer></script>
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
<script src="/js/tailscale-devices.js" defer></script>
<!-- Bundled JS (built with: npm run build) -->
<script src="/dist/core.js" defer></script>
<script src="/dist/features.js" defer></script>
+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);
}
})();