Files
dashcaddy/dashcaddy-api/routes/tailscale.js
Krystie 503de258b8 [grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work
Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
2026-08-12 17:34:10 -07:00

344 lines
11 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const { TAILSCALE, REGEX } = require('../src/utilities/constants');
const { exists } = require('../src/utilities/fs-helpers');
const { ValidationError, NotFoundError } = require('../src/utilities/errors');
const { ok, successMessage, unauthorized } = require('../src/utils/responses');
/**
* 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 {Function} deps.ok - Success response helper
* @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,
ok,
SERVICES_FILE,
log
}) {
const router = express.Router();
// Get Tailscale status and configuration
router.get('/status', asyncHandler(async (req, res) => {
const status = await tailscale.getStatus();
const localIP = await tailscale.getLocalIP();
if (!status) {
return ok(res, {
installed: false,
connected: false,
message: 'Tailscale not available or not running'
});
}
const devices = [];
if (status.Peer) {
for (const [id, peer] of Object.entries(status.Peer)) {
devices.push({
id,
hostname: peer.HostName,
ip: peer.TailscaleIPs?.[0],
os: peer.OS,
online: peer.Online,
lastSeen: peer.LastSeen,
user: peer.UserID
});
}
}
ok(res, {
installed: true,
connected: status.BackendState === 'Running',
backendState: status.BackendState,
self: {
hostname: status.Self?.HostName,
ip: localIP,
tailnetName: status.MagicDNSSuffix,
online: status.Self?.Online
},
config: tailscale.config,
devices,
deviceCount: devices.length
});
}, 'tailscale-status'));
// Update Tailscale configuration
router.post('/config', asyncHandler(async (req, res) => {
const { enabled, requireAuth, allowedTailnet } = req.body;
// Validate allowedTailnet is a safe CIDR/domain string if provided
if (typeof allowedTailnet !== 'undefined' && allowedTailnet !== null) {
if (typeof allowedTailnet !== 'string' || allowedTailnet.length > 255) {
throw new ValidationError('allowedTailnet must be a string (max 255 chars)');
}
// Block shell metacharacters and path traversal
if (/[;&|`$()<>\\]/.test(allowedTailnet)) {
throw new ValidationError('allowedTailnet contains invalid characters');
}
}
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 tailscale.save();
ok(res, {
message: 'Tailscale configuration updated',
config: tailscale.config
});
}, 'tailscale-config'));
// Check if a request is coming from Tailscale
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 => tailscale.isTailscaleIP(ip.toString().split(',')[0].trim()));
ok(res, {
isTailscale,
clientIP,
forwardedFor: forwardedFor || null,
realIP: realIP || null
});
}, 'tailscale-check'));
// Get Tailscale device list
router.get('/devices', asyncHandler(async (req, res) => {
const status = await tailscale.getStatus();
if (!status || !status.Peer) {
return ok(res, { devices: [] });
}
const devices = [];
for (const [id, peer] of Object.entries(status.Peer)) {
if (peer.Online) {
devices.push({
id,
hostname: peer.HostName,
ip: peer.TailscaleIPs?.[0],
os: peer.OS,
user: peer.UserID
});
}
}
if (status.Self) {
devices.unshift({
id: 'self',
hostname: status.Self.HostName,
ip: status.Self.TailscaleIPs?.[0],
os: status.Self.OS,
user: status.Self.UserID,
isSelf: true
});
}
ok(res, { devices });
}, 'tailscale-devices'));
// Toggle Tailscale-only mode for an existing service
router.post('/protect-service', asyncHandler(async (req, res) => {
const { subdomain, tailscaleOnly, allowedIPs } = req.body;
if (!subdomain) {
throw new ValidationError('subdomain is required');
}
// Validate subdomain before it is interpolated into a regex
if (!REGEX.SUBDOMAIN.test(subdomain)) {
throw new ValidationError('[DC-301] Invalid subdomain format');
}
const content = await caddy.read();
const domain = buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');
const match = content.match(blockRegex);
if (!match) {
const { NotFoundError } = require('../src/utilities/errors');
throw new NotFoundError(`Service ${domain} in Caddyfile`);
}
const proxyMatch = match[0].match(/reverse_proxy\s+([^\s\n]+)/);
if (!proxyMatch) {
throw new ValidationError('Could not parse service configuration');
}
const [ip, port] = proxyMatch[1].split(':');
const newConfig = caddy.generateConfig(subdomain, ip, port || '80', {
tailscaleOnly: tailscaleOnly !== false,
allowedIPs: allowedIPs || []
});
const caddyResult = await caddy.modify(c => c.replace(blockRegex, newConfig));
if (!caddyResult.success) {
throw new Error(`Failed to reload Caddy: ${caddyResult.error}`);
}
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;
}
return services;
});
}
ok(res, {
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false
});
}, 'tailscale-protect'));
// ── Tailscale API Integration (OAuth 2.0) ──
// Save OAuth client credentials + validate by exchanging for a token
router.post('/oauth-config', asyncHandler(async (req, res) => {
const { clientId, clientSecret, tailnet } = req.body;
if (!clientId || !clientSecret || !tailnet) {
throw new ValidationError('clientId, clientSecret, and tailnet are required');
}
// Validate by exchanging for a real token
const tokenRes = await fetch(TAILSCALE.OAUTH_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`
});
if (!tokenRes.ok) {
throw new ValidationError(`OAuth validation failed: HTTP ${tokenRes.status}`);
}
const tokenData = await tokenRes.json();
// Test with the device list to verify scopes
const testRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, {
headers: { Authorization: `Bearer ${tokenData.access_token}` }
});
if (!testRes.ok) {
throw new ValidationError(`API test failed: HTTP ${testRes.status}. Check tailnet name and OAuth scopes (needs devices:read, acl:read).`);
}
// Store credentials securely
await credentialManager.store('tailscale.oauth.client_id', clientId, { provider: 'tailscale' });
await credentialManager.store('tailscale.oauth.client_secret', clientSecret, { provider: 'tailscale', tailnet });
// Update config
tailscale.config.oauthConfigured = true;
tailscale.config.tailnet = tailnet;
if (!tailscale.config.allowedTailnet) {
const status = await tailscale.getStatus();
if (status?.MagicDNSSuffix) {
tailscale.config.allowedTailnet = status.MagicDNSSuffix;
}
}
await tailscale.save();
// Start background sync
tailscale.startSync();
// Trigger initial sync
try {
await tailscale.syncAPI();
} catch (e) {
log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message });
}
ok(res, { config: tailscale.config });
}, 'tailscale-oauth-config'));
// Remove OAuth credentials and disable API sync
router.delete('/oauth-config', asyncHandler(async (req, res) => {
await credentialManager.delete('tailscale.oauth.client_id');
await credentialManager.delete('tailscale.oauth.client_secret');
tailscale.config.oauthConfigured = false;
tailscale.config.tailnet = null;
tailscale.config.lastSync = null;
await tailscale.save();
tailscale.stopSync();
successMessage(res, 'Tailscale OAuth credentials removed');
}, 'tailscale-oauth-delete'));
// Get enriched device list from Tailscale API
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
ok(res, {
devices: tailscale.config.devices || [],
lastSync: tailscale.config.lastSync
});
}, 'tailscale-api-devices'));
// Manually trigger an API sync
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 tailscale.syncAPI();
ok(res, {
devices: devices || [],
lastSync: tailscale.config.lastSync
});
}, 'tailscale-sync'));
// Fetch ACL policy (read-only)
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');
}
const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
});
if (!aclRes.ok) {
throw new Error(`ACL fetch failed: HTTP ${aclRes.status}`);
}
const acl = await aclRes.json();
const summary = {
groups: Object.keys(acl.groups || {}),
tagOwners: Object.keys(acl.tagOwners || {}),
aclRuleCount: (acl.acls || []).length,
sshRuleCount: (acl.ssh || []).length
};
ok(res, { acl, summary });
}, 'tailscale-acl'));
return router;
};