refactor(routes): Phase 3.2 - standardize tailscale.js (explicit deps + throw-based errors)
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user