Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
309
dashcaddy-api/routes/tailscale.js
Normal file
309
dashcaddy-api/routes/tailscale.js
Normal file
@@ -0,0 +1,309 @@
|
||||
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('/tailscale/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('/tailscale/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('/tailscale/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('/tailscale/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('/tailscale/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;
|
||||
};
|
||||
Reference in New Issue
Block a user