refactor(routes): Phase 3.2 - standardize tailscale.js (explicit deps + throw-based errors)

This commit is contained in:
Krystie
2026-03-29 20:06:13 -07:00
parent 4e96c62708
commit f6b103aed7
2 changed files with 91 additions and 59 deletions

View File

@@ -2,14 +2,37 @@ const express = require('express');
const fs = require('fs'); const fs = require('fs');
const { TAILSCALE } = require('../constants'); const { TAILSCALE } = require('../constants');
const { exists } = require('../fs-helpers'); 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(); const router = express.Router();
// Get Tailscale status and configuration // Get Tailscale status and configuration
router.get('/status', ctx.asyncHandler(async (req, res) => { router.get('/status', asyncHandler(async (req, res) => {
const status = await ctx.tailscale.getStatus(); const status = await tailscale.getStatus();
const localIP = await ctx.tailscale.getLocalIP(); const localIP = await tailscale.getLocalIP();
if (!status) { if (!status) {
return res.json({ return res.json({
@@ -46,37 +69,37 @@ module.exports = function(ctx) {
tailnetName: status.MagicDNSSuffix, tailnetName: status.MagicDNSSuffix,
online: status.Self?.Online online: status.Self?.Online
}, },
config: ctx.tailscale.config, config: tailscale.config,
devices, devices,
deviceCount: devices.length deviceCount: devices.length
}); });
}, 'tailscale-status')); }, 'tailscale-status'));
// Update Tailscale configuration // Update Tailscale configuration
router.post('/config', ctx.asyncHandler(async (req, res) => { router.post('/config', asyncHandler(async (req, res) => {
const { enabled, requireAuth, allowedTailnet } = req.body; const { enabled, requireAuth, allowedTailnet } = req.body;
if (typeof enabled !== 'undefined') ctx.tailscale.config.enabled = enabled; if (typeof enabled !== 'undefined') tailscale.config.enabled = enabled;
if (typeof requireAuth !== 'undefined') ctx.tailscale.config.requireAuth = requireAuth; if (typeof requireAuth !== 'undefined') tailscale.config.requireAuth = requireAuth;
if (typeof allowedTailnet !== 'undefined') ctx.tailscale.config.allowedTailnet = allowedTailnet; if (typeof allowedTailnet !== 'undefined') tailscale.config.allowedTailnet = allowedTailnet;
await ctx.tailscale.save(); await tailscale.save();
res.json({ res.json({
success: true, success: true,
message: 'Tailscale configuration updated', message: 'Tailscale configuration updated',
config: ctx.tailscale.config config: tailscale.config
}); });
}, 'tailscale-config')); }, 'tailscale-config'));
// Check if a request is coming from Tailscale // 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 clientIP = req.ip || req.connection?.remoteAddress || '';
const forwardedFor = req.headers['x-forwarded-for']; const forwardedFor = req.headers['x-forwarded-for'];
const realIP = req.headers['x-real-ip']; const realIP = req.headers['x-real-ip'];
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean); 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({ res.json({
success: true, success: true,
@@ -88,8 +111,8 @@ module.exports = function(ctx) {
}, 'tailscale-check')); }, 'tailscale-check'));
// Get Tailscale device list // Get Tailscale device list
router.get('/devices', ctx.asyncHandler(async (req, res) => { router.get('/devices', asyncHandler(async (req, res) => {
const status = await ctx.tailscale.getStatus(); const status = await tailscale.getStatus();
if (!status || !status.Peer) { if (!status || !status.Peer) {
return res.json({ success: true, devices: [] }); return res.json({ success: true, devices: [] });
} }
@@ -122,15 +145,15 @@ module.exports = function(ctx) {
}, 'tailscale-devices')); }, 'tailscale-devices'));
// Toggle Tailscale-only mode for an existing service // 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; const { subdomain, tailscaleOnly, allowedIPs } = req.body;
if (!subdomain) { if (!subdomain) {
throw new ValidationError('subdomain is required'); throw new ValidationError('subdomain is required');
} }
let content = await ctx.caddy.read(); let content = await caddy.read();
const domain = ctx.buildDomain(subdomain); const domain = buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');
const match = content.match(blockRegex); const match = content.match(blockRegex);
@@ -147,18 +170,18 @@ module.exports = function(ctx) {
const [ip, port] = proxyMatch[1].split(':'); 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, tailscaleOnly: tailscaleOnly !== false,
allowedIPs: allowedIPs || [] 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) { 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)) { if (await exists(SERVICES_FILE)) {
await ctx.servicesStateManager.update(services => { await servicesStateManager.update(services => {
const serviceIndex = services.findIndex(s => s.id === subdomain); const serviceIndex = services.findIndex(s => s.id === subdomain);
if (serviceIndex !== -1) { if (serviceIndex !== -1) {
services[serviceIndex].tailscaleOnly = tailscaleOnly !== false; services[serviceIndex].tailscaleOnly = tailscaleOnly !== false;
@@ -177,7 +200,7 @@ module.exports = function(ctx) {
// ── Tailscale API Integration (OAuth 2.0) ── // ── Tailscale API Integration (OAuth 2.0) ──
// Save OAuth client credentials + validate by exchanging for a token // 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; const { clientId, clientSecret, tailnet } = req.body;
if (!clientId || !clientSecret || !tailnet) { if (!clientId || !clientSecret || !tailnet) {
@@ -192,7 +215,7 @@ module.exports = function(ctx) {
}); });
if (!tokenRes.ok) { 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(); const tokenData = await tokenRes.json();
@@ -203,85 +226,85 @@ module.exports = function(ctx) {
}); });
if (!testRes.ok) { 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 // Store credentials securely
await ctx.credentialManager.store('tailscale.oauth.client_id', clientId, { provider: 'tailscale' }); await 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_secret', clientSecret, { provider: 'tailscale', tailnet });
// Update config // Update config
ctx.tailscale.config.oauthConfigured = true; tailscale.config.oauthConfigured = true;
ctx.tailscale.config.tailnet = tailnet; tailscale.config.tailnet = tailnet;
if (!ctx.tailscale.config.allowedTailnet) { if (!tailscale.config.allowedTailnet) {
const status = await ctx.tailscale.getStatus(); const status = await tailscale.getStatus();
if (status?.MagicDNSSuffix) { if (status?.MagicDNSSuffix) {
ctx.tailscale.config.allowedTailnet = status.MagicDNSSuffix; tailscale.config.allowedTailnet = status.MagicDNSSuffix;
} }
} }
await ctx.tailscale.save(); await tailscale.save();
// Start background sync // Start background sync
ctx.tailscale.startSync(); tailscale.startSync();
// Trigger initial sync // Trigger initial sync
try { try {
await ctx.tailscale.syncAPI(); await tailscale.syncAPI();
} catch (e) { } 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')); }, 'tailscale-oauth-config'));
// Remove OAuth credentials and disable API sync // Remove OAuth credentials and disable API sync
router.delete('/oauth-config', ctx.asyncHandler(async (req, res) => { router.delete('/oauth-config', asyncHandler(async (req, res) => {
await ctx.credentialManager.delete('tailscale.oauth.client_id'); await credentialManager.delete('tailscale.oauth.client_id');
await ctx.credentialManager.delete('tailscale.oauth.client_secret'); await credentialManager.delete('tailscale.oauth.client_secret');
ctx.tailscale.config.oauthConfigured = false; tailscale.config.oauthConfigured = false;
ctx.tailscale.config.tailnet = null; tailscale.config.tailnet = null;
ctx.tailscale.config.lastSync = null; tailscale.config.lastSync = null;
await ctx.tailscale.save(); await tailscale.save();
ctx.tailscale.stopSync(); tailscale.stopSync();
res.json({ success: true, message: 'Tailscale OAuth credentials removed' }); res.json({ success: true, message: 'Tailscale OAuth credentials removed' });
}, 'tailscale-oauth-delete')); }, 'tailscale-oauth-delete'));
// Get enriched device list from Tailscale API // Get enriched device list from Tailscale API
router.get('/api-devices', ctx.asyncHandler(async (req, res) => { router.get('/api-devices', asyncHandler(async (req, res) => {
if (!ctx.tailscale.config.oauthConfigured) { if (!tailscale.config.oauthConfigured) {
throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); throw new ValidationError('Tailscale API not configured. Set up OAuth first.');
} }
// Return cached devices from last sync // Return cached devices from last sync
res.json({ res.json({
success: true, success: true,
devices: ctx.tailscale.config.devices || [], devices: tailscale.config.devices || [],
lastSync: ctx.tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
}, 'tailscale-api-devices')); }, 'tailscale-api-devices'));
// Manually trigger an API sync // Manually trigger an API sync
router.post('/sync', ctx.asyncHandler(async (req, res) => { router.post('/sync', asyncHandler(async (req, res) => {
if (!ctx.tailscale.config.oauthConfigured) { if (!tailscale.config.oauthConfigured) {
throw new ValidationError('Tailscale API not configured. Set up OAuth first.'); throw new ValidationError('Tailscale API not configured. Set up OAuth first.');
} }
const devices = await ctx.tailscale.syncAPI(); const devices = await tailscale.syncAPI();
res.json({ res.json({
success: true, success: true,
devices: devices || [], devices: devices || [],
lastSync: ctx.tailscale.config.lastSync lastSync: tailscale.config.lastSync
}); });
}, 'tailscale-sync')); }, 'tailscale-sync'));
// Fetch ACL policy (read-only) // Fetch ACL policy (read-only)
router.get('/acl', ctx.asyncHandler(async (req, res) => { router.get('/acl', asyncHandler(async (req, res) => {
const token = await ctx.tailscale.getAccessToken(); const token = await tailscale.getAccessToken();
const tailnet = ctx.tailscale.config.tailnet; const tailnet = tailscale.config.tailnet;
if (!token || !tailnet) { if (!token || !tailnet) {
throw new ValidationError('Tailscale API not configured'); throw new ValidationError('Tailscale API not configured');
} }
@@ -290,7 +313,7 @@ module.exports = function(ctx) {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
}); });
if (!aclRes.ok) { 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(); const acl = await aclRes.json();

View File

@@ -345,7 +345,16 @@ async function createApp() {
asyncHandler: ctx.asyncHandler, asyncHandler: ctx.asyncHandler,
logError: ctx.logError 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(sitesRoutes(ctx));
apiRouter.use(credentialsRoutes({ apiRouter.use(credentialsRoutes({
credentialManager: ctx.credentialManager, credentialManager: ctx.credentialManager,