diff --git a/dashcaddy-api/routes/tailscale.js b/dashcaddy-api/routes/tailscale.js index 6c75f86..9c93f87 100644 --- a/dashcaddy-api/routes/tailscale.js +++ b/dashcaddy-api/routes/tailscale.js @@ -2,14 +2,37 @@ const express = require('express'); const fs = require('fs'); const { TAILSCALE } = require('../constants'); const { exists } = require('../fs-helpers'); +const { ValidationError, NotFoundError } = require('../errors'); -module.exports = function(ctx) { +/** + * Tailscale route factory + * @param {Object} deps - Explicit dependencies + * @param {Object} deps.tailscale - Tailscale manager + * @param {Object} deps.caddy - Caddy manager + * @param {Object} deps.servicesStateManager - Services state manager + * @param {Object} deps.credentialManager - Credential manager + * @param {Function} deps.buildDomain - Domain builder function + * @param {Function} deps.asyncHandler - Async route handler wrapper + * @param {string} deps.SERVICES_FILE - Path to services.json + * @param {Object} deps.log - Logger instance + * @returns {express.Router} + */ +module.exports = function({ + tailscale, + caddy, + servicesStateManager, + credentialManager, + buildDomain, + asyncHandler, + SERVICES_FILE, + log +}) { const router = express.Router(); // Get Tailscale status and configuration - router.get('/status', ctx.asyncHandler(async (req, res) => { - const status = await ctx.tailscale.getStatus(); - const localIP = await ctx.tailscale.getLocalIP(); + router.get('/status', asyncHandler(async (req, res) => { + const status = await tailscale.getStatus(); + const localIP = await tailscale.getLocalIP(); if (!status) { return res.json({ @@ -46,37 +69,37 @@ module.exports = function(ctx) { tailnetName: status.MagicDNSSuffix, online: status.Self?.Online }, - config: ctx.tailscale.config, + config: tailscale.config, devices, deviceCount: devices.length }); }, 'tailscale-status')); // Update Tailscale configuration - router.post('/config', ctx.asyncHandler(async (req, res) => { + router.post('/config', asyncHandler(async (req, res) => { const { enabled, requireAuth, allowedTailnet } = req.body; - if (typeof enabled !== 'undefined') ctx.tailscale.config.enabled = enabled; - if (typeof requireAuth !== 'undefined') ctx.tailscale.config.requireAuth = requireAuth; - if (typeof allowedTailnet !== 'undefined') ctx.tailscale.config.allowedTailnet = allowedTailnet; + if (typeof enabled !== 'undefined') tailscale.config.enabled = enabled; + if (typeof requireAuth !== 'undefined') tailscale.config.requireAuth = requireAuth; + if (typeof allowedTailnet !== 'undefined') tailscale.config.allowedTailnet = allowedTailnet; - await ctx.tailscale.save(); + await tailscale.save(); res.json({ success: true, message: 'Tailscale configuration updated', - config: ctx.tailscale.config + config: tailscale.config }); }, 'tailscale-config')); // Check if a request is coming from Tailscale - router.get('/check-connection', ctx.asyncHandler(async (req, res) => { + router.get('/check-connection', asyncHandler(async (req, res) => { const clientIP = req.ip || req.connection?.remoteAddress || ''; const forwardedFor = req.headers['x-forwarded-for']; const realIP = req.headers['x-real-ip']; const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); - const isTailscale = ipsToCheck.some(ip => ctx.tailscale.isTailscaleIP(ip.toString().split(',')[0].trim())); + const isTailscale = ipsToCheck.some(ip => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim())); res.json({ success: true, @@ -88,8 +111,8 @@ module.exports = function(ctx) { }, 'tailscale-check')); // Get Tailscale device list - router.get('/devices', ctx.asyncHandler(async (req, res) => { - const status = await ctx.tailscale.getStatus(); + router.get('/devices', asyncHandler(async (req, res) => { + const status = await tailscale.getStatus(); if (!status || !status.Peer) { return res.json({ success: true, devices: [] }); } @@ -122,15 +145,15 @@ module.exports = function(ctx) { }, 'tailscale-devices')); // Toggle Tailscale-only mode for an existing service - router.post('/protect-service', ctx.asyncHandler(async (req, res) => { + router.post('/protect-service', asyncHandler(async (req, res) => { const { subdomain, tailscaleOnly, allowedIPs } = req.body; if (!subdomain) { throw new ValidationError('subdomain is required'); } - let content = await ctx.caddy.read(); - const domain = ctx.buildDomain(subdomain); + let content = await caddy.read(); + const domain = buildDomain(subdomain); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); const match = content.match(blockRegex); @@ -147,18 +170,18 @@ module.exports = function(ctx) { const [ip, port] = proxyMatch[1].split(':'); - const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', { + const newConfig = caddy.generateConfig(subdomain, ip, port || '80', { tailscaleOnly: tailscaleOnly !== false, allowedIPs: allowedIPs || [] }); - const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig)); + const caddyResult = await caddy.modify(c => c.replace(blockRegex, newConfig)); if (!caddyResult.success) { - return ctx.errorResponse(res, 500, `[DC-303] Failed to reload Caddy: ${caddyResult.error}`); + throw new Error(`Failed to reload Caddy: ${caddyResult.error}`); } - if (await exists(ctx.SERVICES_FILE)) { - await ctx.servicesStateManager.update(services => { + if (await exists(SERVICES_FILE)) { + await servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === subdomain); if (serviceIndex !== -1) { services[serviceIndex].tailscaleOnly = tailscaleOnly !== false; @@ -177,7 +200,7 @@ module.exports = function(ctx) { // ── Tailscale API Integration (OAuth 2.0) ── // Save OAuth client credentials + validate by exchanging for a token - router.post('/oauth-config', ctx.asyncHandler(async (req, res) => { + router.post('/oauth-config', asyncHandler(async (req, res) => { const { clientId, clientSecret, tailnet } = req.body; if (!clientId || !clientSecret || !tailnet) { @@ -192,7 +215,7 @@ module.exports = function(ctx) { }); if (!tokenRes.ok) { - return ctx.errorResponse(res, 400, `OAuth validation failed: HTTP ${tokenRes.status}`); + throw new ValidationError(`OAuth validation failed: HTTP ${tokenRes.status}`); } const tokenData = await tokenRes.json(); @@ -203,85 +226,85 @@ module.exports = function(ctx) { }); if (!testRes.ok) { - return ctx.errorResponse(res, 400, `API test failed: HTTP ${testRes.status}. Check tailnet name and OAuth scopes (needs devices:read, acl:read).`); + throw new ValidationError(`API test failed: HTTP ${testRes.status}. Check tailnet name and OAuth scopes (needs devices:read, acl:read).`); } // Store credentials securely - await ctx.credentialManager.store('tailscale.oauth.client_id', clientId, { provider: 'tailscale' }); - await ctx.credentialManager.store('tailscale.oauth.client_secret', clientSecret, { provider: 'tailscale', tailnet }); + await credentialManager.store('tailscale.oauth.client_id', clientId, { provider: 'tailscale' }); + await credentialManager.store('tailscale.oauth.client_secret', clientSecret, { provider: 'tailscale', tailnet }); // Update config - ctx.tailscale.config.oauthConfigured = true; - ctx.tailscale.config.tailnet = tailnet; - if (!ctx.tailscale.config.allowedTailnet) { - const status = await ctx.tailscale.getStatus(); + tailscale.config.oauthConfigured = true; + tailscale.config.tailnet = tailnet; + if (!tailscale.config.allowedTailnet) { + const status = await tailscale.getStatus(); if (status?.MagicDNSSuffix) { - ctx.tailscale.config.allowedTailnet = status.MagicDNSSuffix; + tailscale.config.allowedTailnet = status.MagicDNSSuffix; } } - await ctx.tailscale.save(); + await tailscale.save(); // Start background sync - ctx.tailscale.startSync(); + tailscale.startSync(); // Trigger initial sync try { - await ctx.tailscale.syncAPI(); + await tailscale.syncAPI(); } catch (e) { - ctx.log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message }); + log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message }); } - res.json({ success: true, config: ctx.tailscale.config }); + res.json({ success: true, config: tailscale.config }); }, 'tailscale-oauth-config')); // Remove OAuth credentials and disable API sync - router.delete('/oauth-config', ctx.asyncHandler(async (req, res) => { - await ctx.credentialManager.delete('tailscale.oauth.client_id'); - await ctx.credentialManager.delete('tailscale.oauth.client_secret'); + router.delete('/oauth-config', asyncHandler(async (req, res) => { + await credentialManager.delete('tailscale.oauth.client_id'); + await credentialManager.delete('tailscale.oauth.client_secret'); - ctx.tailscale.config.oauthConfigured = false; - ctx.tailscale.config.tailnet = null; - ctx.tailscale.config.lastSync = null; - await ctx.tailscale.save(); + tailscale.config.oauthConfigured = false; + tailscale.config.tailnet = null; + tailscale.config.lastSync = null; + await tailscale.save(); - ctx.tailscale.stopSync(); + tailscale.stopSync(); res.json({ success: true, message: 'Tailscale OAuth credentials removed' }); }, 'tailscale-oauth-delete')); // Get enriched device list from Tailscale API - router.get('/api-devices', ctx.asyncHandler(async (req, res) => { - if (!ctx.tailscale.config.oauthConfigured) { + router.get('/api-devices', asyncHandler(async (req, res) => { + if (!tailscale.config.oauthConfigured) { throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); } // Return cached devices from last sync res.json({ success: true, - devices: ctx.tailscale.config.devices || [], - lastSync: ctx.tailscale.config.lastSync + devices: tailscale.config.devices || [], + lastSync: tailscale.config.lastSync }); }, 'tailscale-api-devices')); // Manually trigger an API sync - router.post('/sync', ctx.asyncHandler(async (req, res) => { - if (!ctx.tailscale.config.oauthConfigured) { + router.post('/sync', asyncHandler(async (req, res) => { + if (!tailscale.config.oauthConfigured) { throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); } - const devices = await ctx.tailscale.syncAPI(); + const devices = await tailscale.syncAPI(); res.json({ success: true, devices: devices || [], - lastSync: ctx.tailscale.config.lastSync + lastSync: tailscale.config.lastSync }); }, 'tailscale-sync')); // Fetch ACL policy (read-only) - router.get('/acl', ctx.asyncHandler(async (req, res) => { - const token = await ctx.tailscale.getAccessToken(); - const tailnet = ctx.tailscale.config.tailnet; + router.get('/acl', asyncHandler(async (req, res) => { + const token = await tailscale.getAccessToken(); + const tailnet = tailscale.config.tailnet; if (!token || !tailnet) { throw new ValidationError('Tailscale API not configured'); } @@ -290,7 +313,7 @@ module.exports = function(ctx) { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } }); if (!aclRes.ok) { - return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`); + throw new Error(`ACL fetch failed: HTTP ${aclRes.status}`); } const acl = await aclRes.json(); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index dfb7cdf..172ad90 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -345,7 +345,16 @@ async function createApp() { asyncHandler: ctx.asyncHandler, logError: ctx.logError })); - apiRouter.use('/tailscale', tailscaleRoutes(ctx)); + apiRouter.use('/tailscale', tailscaleRoutes({ + tailscale: ctx.tailscale, + caddy: ctx.caddy, + servicesStateManager: ctx.servicesStateManager, + credentialManager: ctx.credentialManager, + buildDomain: ctx.buildDomain, + asyncHandler: ctx.asyncHandler, + SERVICES_FILE: ctx.SERVICES_FILE, + log: ctx.log + })); apiRouter.use(sitesRoutes(ctx)); apiRouter.use(credentialsRoutes({ credentialManager: ctx.credentialManager,