diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 97ba689..7c15ba0 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -113,6 +113,41 @@ module.exports = function configureMiddleware(app, { next(); }); + // ── Tailscale authentication helpers ── + + const PROBE_PATHS_TAILSCALE = new Set([ + '/health', '/health/live', '/health/ready', '/healthz', '/readyz', + ]); + + function isTailScaleProbePath(reqPath) { + return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/'); + } + + function extractTailscaleIPs(req) { + const clientIP = req.ip || req.socket?.remoteAddress || ''; + const forwardedFor = req.headers['x-forwarded-for']; + const realIP = req.headers['x-real-ip']; + const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); + const fromTailscale = ipsToCheck.some(ip => + isTailscaleIP(ip.toString().split(',')[0].trim())); + const clientTailscaleIP = ipsToCheck + .map(ip => ip.toString().split(',')[0].trim()) + .find(ip => isTailscaleIP(ip)); + return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP }; + } + + async function isIPInTailnet(clientTailscaleIP) { + const status = await getTailscaleStatus(); + if (!status) return true; // no status = can't verify = allow + + const knownIPs = new Set(); + for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip); + for (const peer of Object.values(status.Peer || {})) { + for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); + } + return knownIPs.has(clientTailscaleIP); + } + // ── Tailscale authentication middleware (optional) ── const tailscaleAuthMiddleware = async (req, res, next) => { if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) { @@ -121,25 +156,11 @@ module.exports = function configureMiddleware(app, { // Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks // don't carry a Tailscale identity header. - if (req.path === '/health' - || req.path === '/health/live' - || req.path === '/health/ready' - || req.path === '/healthz' - || req.path === '/readyz' - || req.path.startsWith('/probe/')) { + if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) { return next(); } - if (req.path.startsWith('/api/v1/tailscale/')) { - return next(); - } - - const clientIP = req.ip || req.socket?.remoteAddress || ''; - const forwardedFor = req.headers['x-forwarded-for']; - const realIP = req.headers['x-real-ip']; - - const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); - const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim())); + const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req); if (!fromTailscale) { return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', { @@ -148,27 +169,14 @@ module.exports = function configureMiddleware(app, { }); } - if (tailscaleConfig.allowedTailnet) { + if (tailscaleConfig.allowedTailnet && clientTailscaleIP) { try { - const status = await getTailscaleStatus(); - if (status) { - const clientTailscaleIP = ipsToCheck - .map(ip => ip.toString().split(',')[0].trim()) - .find(ip => isTailscaleIP(ip)); - - if (clientTailscaleIP) { - const knownIPs = new Set(); - for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip); - for (const peer of Object.values(status.Peer || {})) { - for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip); - } - if (!knownIPs.has(clientTailscaleIP)) { - return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', { - requiresTailscale: true, - clientIP - }); - } - } + const inTailnet = await isIPInTailnet(clientTailscaleIP); + if (!inTailnet) { + return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', { + requiresTailscale: true, + clientIP + }); } } catch (e) { log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });