const express = require('express'); const fs = require('fs'); const { TAILSCALE } = require('../constants'); const { exists } = require('../fs-helpers'); module.exports = function(ctx) { 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(); if (!status) { return res.json({ success: true, 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 }); } } res.json({ success: true, installed: true, connected: status.BackendState === 'Running', backendState: status.BackendState, self: { hostname: status.Self?.HostName, ip: localIP, tailnetName: status.MagicDNSSuffix, online: status.Self?.Online }, config: ctx.tailscale.config, devices, deviceCount: devices.length }); }, 'tailscale-status')); // Update Tailscale configuration router.post('/config', ctx.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; await ctx.tailscale.save(); res.json({ success: true, message: 'Tailscale configuration updated', config: ctx.tailscale.config }); }, 'tailscale-config')); // Check if a request is coming from Tailscale router.get('/check-connection', ctx.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())); res.json({ success: true, isTailscale, clientIP, forwardedFor: forwardedFor || null, realIP: realIP || null }); }, 'tailscale-check')); // Get Tailscale device list router.get('/devices', ctx.asyncHandler(async (req, res) => { const status = await ctx.tailscale.getStatus(); if (!status || !status.Peer) { return res.json({ success: true, 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 }); } res.json({ success: true, devices }); }, 'tailscale-devices')); // Toggle Tailscale-only mode for an existing service router.post('/protect-service', ctx.asyncHandler(async (req, res) => { const { subdomain, tailscaleOnly, allowedIPs } = req.body; if (!subdomain) { return ctx.errorResponse(res, 400, 'subdomain is required'); } let content = await ctx.caddy.read(); const domain = ctx.buildDomain(subdomain); const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's'); const match = content.match(blockRegex); if (!match) { const { NotFoundError } = require('../errors'); throw new NotFoundError(`Service ${domain} in Caddyfile`); } const proxyMatch = match[0].match(/reverse_proxy\s+([^\s\n]+)/); if (!proxyMatch) { return ctx.errorResponse(res, 400, 'Could not parse service configuration'); } const [ip, port] = proxyMatch[1].split(':'); const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', { tailscaleOnly: tailscaleOnly !== false, allowedIPs: allowedIPs || [] }); const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig)); if (!caddyResult.success) { return ctx.errorResponse(res, 500, `[DC-303] Failed to reload Caddy: ${caddyResult.error}`); } if (await exists(ctx.SERVICES_FILE)) { await ctx.servicesStateManager.update(services => { const serviceIndex = services.findIndex(s => s.id === subdomain); if (serviceIndex !== -1) { services[serviceIndex].tailscaleOnly = tailscaleOnly !== false; } return services; }); } res.json({ success: true, 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', ctx.asyncHandler(async (req, res) => { const { clientId, clientSecret, tailnet } = req.body; if (!clientId || !clientSecret || !tailnet) { return ctx.errorResponse(res, 400, '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) { return ctx.errorResponse(res, 400, `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) { return ctx.errorResponse(res, 400, `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 }); // Update config ctx.tailscale.config.oauthConfigured = true; ctx.tailscale.config.tailnet = tailnet; if (!ctx.tailscale.config.allowedTailnet) { const status = await ctx.tailscale.getStatus(); if (status?.MagicDNSSuffix) { ctx.tailscale.config.allowedTailnet = status.MagicDNSSuffix; } } await ctx.tailscale.save(); // Start background sync ctx.tailscale.startSync(); // Trigger initial sync try { await ctx.tailscale.syncAPI(); } catch (e) { ctx.log.warn('tailscale', 'Initial sync after OAuth config failed', { error: e.message }); } res.json({ success: true, config: ctx.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'); ctx.tailscale.config.oauthConfigured = false; ctx.tailscale.config.tailnet = null; ctx.tailscale.config.lastSync = null; await ctx.tailscale.save(); ctx.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) { return ctx.errorResponse(res, 400, '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 }); }, 'tailscale-api-devices')); // Manually trigger an API sync router.post('/sync', ctx.asyncHandler(async (req, res) => { if (!ctx.tailscale.config.oauthConfigured) { return ctx.errorResponse(res, 400, 'Tailscale API not configured. Set up OAuth first.'); } const devices = await ctx.tailscale.syncAPI(); res.json({ success: true, devices: devices || [], lastSync: ctx.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; if (!token || !tailnet) { return ctx.errorResponse(res, 400, '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) { return ctx.errorResponse(res, aclRes.status, `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 }; res.json({ success: true, acl, summary }); }, 'tailscale-acl')); return router; };