Phase 2 (WIP): Extract context modules (caddy, dns)
- src/context/caddy.js: Caddyfile manipulation, reload, config generation - src/context/dns.js: DNS API wrapper with token management - All context modules use factory pattern with explicit dependencies
This commit is contained in:
@@ -1,96 +1,39 @@
|
||||
/**
|
||||
* Caddy context
|
||||
* Caddyfile manipulation, reload, and site verification
|
||||
* Caddyfile manipulation, reload, and configuration generation
|
||||
*/
|
||||
|
||||
const fsp = require('fs').promises;
|
||||
const { CADDY, RETRIES } = require('../../constants');
|
||||
const { RETRIES, CADDY } = require('../../constants');
|
||||
const { safeErrorMessage } = require('../utils/safe-error');
|
||||
|
||||
// Will be initialized by init()
|
||||
let config = null;
|
||||
let log = null;
|
||||
let fetchT = null;
|
||||
let buildDomain = null;
|
||||
let siteConfig = null;
|
||||
let httpsAgent = null;
|
||||
|
||||
/**
|
||||
* Initialize caddy context with dependencies
|
||||
* @param {object} deps - { config, log, fetchT, buildDomain, siteConfig, httpsAgent }
|
||||
*/
|
||||
function init(deps) {
|
||||
config = deps.config;
|
||||
log = deps.log;
|
||||
fetchT = deps.fetchT;
|
||||
buildDomain = deps.buildDomain;
|
||||
siteConfig = deps.siteConfig;
|
||||
httpsAgent = deps.httpsAgent;
|
||||
}
|
||||
|
||||
// Mutex for atomic Caddyfile modifications
|
||||
let _caddyfileLock = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Atomically read-modify-write the Caddyfile and reload Caddy
|
||||
* Uses a mutex to prevent concurrent modifications from clobbering each other
|
||||
* Rolls back on reload failure
|
||||
* @param {function} modifyFn - receives current content, returns modified content (or null to skip)
|
||||
* @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>}
|
||||
* Create Caddy context
|
||||
* @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }
|
||||
*/
|
||||
async function modifyCaddyfile(modifyFn) {
|
||||
let resolve;
|
||||
const prev = _caddyfileLock;
|
||||
_caddyfileLock = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
|
||||
await prev; // wait for any in-flight modification to finish
|
||||
|
||||
try {
|
||||
const original = await readCaddyfile();
|
||||
const modified = await modifyFn(original);
|
||||
|
||||
if (modified === null || modified === original) {
|
||||
return { success: false, error: 'No changes to apply' };
|
||||
}
|
||||
|
||||
await fsp.writeFile(config.caddyfilePath, modified, 'utf8');
|
||||
|
||||
try {
|
||||
await reloadCaddy(modified);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Rollback
|
||||
await fsp.writeFile(config.caddyfilePath, original, 'utf8');
|
||||
return { success: false, error: safeErrorMessage(err), rolledBack: true };
|
||||
}
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) {
|
||||
/**
|
||||
* Read the current Caddyfile content
|
||||
* @returns {Promise<string>} Caddyfile content
|
||||
*/
|
||||
async function readCaddyfile() {
|
||||
return fsp.readFile(config.caddyfilePath, 'utf8');
|
||||
}
|
||||
async function readCaddyfile() {
|
||||
return fsp.readFile(caddyfilePath, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Reload Caddy configuration via admin API
|
||||
* @param {string} content - New Caddyfile content
|
||||
* @returns {Promise<void>}
|
||||
* @throws {Error} If reload fails after max retries
|
||||
*/
|
||||
async function reloadCaddy(content) {
|
||||
async function reloadCaddy(content) {
|
||||
const maxRetries = RETRIES.CADDY_RELOAD;
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetchT(`${config.caddyAdminUrl}/load`, {
|
||||
const response = await fetchT(`${caddyAdminUrl}/load`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
|
||||
body: content,
|
||||
@@ -116,25 +59,62 @@ async function reloadCaddy(content) {
|
||||
}
|
||||
|
||||
throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Caddy reverse proxy configuration
|
||||
* @param {string} subdomain - Service subdomain
|
||||
* @param {string} ip - Backend IP address
|
||||
* @param {number} port - Backend port
|
||||
* @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport }
|
||||
* @returns {string} Caddy configuration snippet
|
||||
/**
|
||||
* Atomically read-modify-write the Caddyfile and reload Caddy
|
||||
* Uses a mutex to prevent concurrent modifications
|
||||
* Rolls back on reload failure
|
||||
* @param {function} modifyFn - Receives current content, returns modified content (or null to skip)
|
||||
* @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>}
|
||||
*/
|
||||
function generateConfig(subdomain, ip, port, options = {}) {
|
||||
async function modifyCaddyfile(modifyFn) {
|
||||
let resolve;
|
||||
const prev = _caddyfileLock;
|
||||
_caddyfileLock = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
|
||||
await prev; // wait for any in-flight modification to finish
|
||||
|
||||
try {
|
||||
const original = await readCaddyfile();
|
||||
const modified = await modifyFn(original);
|
||||
|
||||
if (modified === null || modified === original) {
|
||||
return { success: false, error: 'No changes to apply' };
|
||||
}
|
||||
|
||||
await fsp.writeFile(caddyfilePath, modified, 'utf8');
|
||||
|
||||
try {
|
||||
await reloadCaddy(modified);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Rollback
|
||||
await fsp.writeFile(caddyfilePath, original, 'utf8');
|
||||
return { success: false, error: safeErrorMessage(err), rolledBack: true };
|
||||
}
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Caddy configuration block for a service
|
||||
* @param {string} subdomain - Service subdomain
|
||||
* @param {string} ip - Target IP address
|
||||
* @param {number} port - Target port
|
||||
* @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport }
|
||||
* @returns {string} Caddy configuration block
|
||||
*/
|
||||
function generateConfig(subdomain, ip, port, options = {}) {
|
||||
const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options;
|
||||
|
||||
// Subdirectory mode: generate handle/handle_path block (injected into main domain block)
|
||||
// Subdirectory mode: generate handle/handle_path block
|
||||
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
|
||||
let config = '';
|
||||
|
||||
// Native-support apps: use handle (preserve path prefix)
|
||||
// Strip-mode apps: use handle_path (remove path prefix before proxying)
|
||||
if (subpathSupport === 'native') {
|
||||
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
|
||||
config += `\thandle /${subdomain}/* {\n`;
|
||||
@@ -169,27 +149,25 @@ function generateConfig(subdomain, ip, port, options = {}) {
|
||||
config += '}';
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Verify a site is accessible via HTTPS
|
||||
* @param {string} domain - Domain to check
|
||||
* @param {number} maxAttempts - Max verification attempts
|
||||
* @returns {Promise<boolean>} true if site is accessible
|
||||
* @param {number} maxAttempts - Maximum retry attempts
|
||||
* @returns {Promise<boolean>} True if accessible
|
||||
*/
|
||||
async function verifySite(domain, maxAttempts = 5) {
|
||||
async function verifySite(domain, maxAttempts = 5) {
|
||||
const delay = 2000;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
// Try HTTPS first (internal CA)
|
||||
const response = await fetchT(`https://${domain}/`, {
|
||||
method: 'HEAD',
|
||||
agent: httpsAgent, // Use CA-aware agent
|
||||
agent: httpsAgent,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Any response (even 4xx) means Caddy is serving the site
|
||||
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -208,13 +186,17 @@ async function verifySite(domain, maxAttempts = 5) {
|
||||
|
||||
log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
return {
|
||||
modify: modifyCaddyfile,
|
||||
read: readCaddyfile,
|
||||
reload: reloadCaddy,
|
||||
generateConfig,
|
||||
verifySite,
|
||||
};
|
||||
adminUrl: caddyAdminUrl,
|
||||
filePath: caddyfilePath,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createCaddyContext;
|
||||
|
||||
211
dashcaddy-api/src/context/dns.js
Normal file
211
dashcaddy-api/src/context/dns.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* DNS context
|
||||
* Technitium DNS API wrapper with token management
|
||||
*/
|
||||
|
||||
const { CADDY } = require('../../constants');
|
||||
|
||||
/**
|
||||
* Create DNS context
|
||||
* @param {object} deps - Dependencies { siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }
|
||||
*/
|
||||
function createDnsContext({ siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }) {
|
||||
// DNS token state
|
||||
let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
|
||||
let dnsTokenExpiry = null;
|
||||
|
||||
// Per-server token cache
|
||||
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens);
|
||||
|
||||
/**
|
||||
* Build full Technitium DNS API URL
|
||||
* @param {string} server - Server IP or hostname
|
||||
* @param {string} apiPath - API path (e.g., '/api/zones/records/add')
|
||||
* @param {object|URLSearchParams} params - Query parameters
|
||||
* @returns {string} Full API URL
|
||||
*/
|
||||
function buildUrl(server, apiPath, params) {
|
||||
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
|
||||
const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : '';
|
||||
const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString();
|
||||
return `${protocol}://${server}${port}${apiPath}?${qs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call DNS API endpoint
|
||||
* @param {string} server - Server IP or hostname
|
||||
* @param {string} apiPath - API path
|
||||
* @param {object} params - Query parameters
|
||||
* @returns {Promise<object>} Parsed JSON response
|
||||
*/
|
||||
async function call(server, apiPath, params) {
|
||||
const url = buildUrl(server, apiPath, params);
|
||||
const response = await fetchT(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh DNS token via login
|
||||
* @param {string} username - DNS username
|
||||
* @param {string} password - DNS password
|
||||
* @param {string} server - Server IP
|
||||
* @returns {Promise<{success: boolean, token?: string, error?: string}>}
|
||||
*/
|
||||
async function refreshToken(username, password, server) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
user: username,
|
||||
pass: password,
|
||||
includeInfo: 'false',
|
||||
});
|
||||
|
||||
const response = await fetchT(`http://${server}:5380/api/user/login?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok' && result.token) {
|
||||
dnsToken = result.token;
|
||||
dnsTokenExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 hours
|
||||
log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
|
||||
return { success: true, token: dnsToken };
|
||||
}
|
||||
|
||||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||
} catch (error) {
|
||||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map DNS server IP to dnsId (dns1, dns2, dns3)
|
||||
* @param {string} serverIp - Server IP address
|
||||
* @returns {string|null} DNS ID or null
|
||||
*/
|
||||
function dnsIpToDnsId(serverIp) {
|
||||
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
|
||||
if (info.ip === serverIp) return dnsId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a valid DNS token exists (auto-refresh if needed)
|
||||
* @returns {Promise<{success: boolean, token?: string, error?: string}>}
|
||||
*/
|
||||
async function ensureToken() {
|
||||
// Check if token is valid and not expired
|
||||
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
|
||||
return { success: true, token: dnsToken };
|
||||
}
|
||||
|
||||
// Try per-server admin credentials for the primary DNS server
|
||||
const primaryIp = siteConfig.dnsServerIp;
|
||||
if (primaryIp) {
|
||||
const dnsId = dnsIpToDnsId(primaryIp);
|
||||
if (dnsId) {
|
||||
for (const role of ['admin', 'readonly']) {
|
||||
try {
|
||||
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||||
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||||
if (username && password) {
|
||||
return await refreshToken(username, password, primaryIp);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global credentials
|
||||
try {
|
||||
const username = await credentialManager.retrieve('dns.username');
|
||||
const password = await credentialManager.retrieve('dns.password');
|
||||
const server = await credentialManager.retrieve('dns.server');
|
||||
if (username && password) {
|
||||
return await refreshToken(username, password, server || primaryIp);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('dns', 'Credential manager error', { error: err.message });
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a valid DNS token (auto-refresh if needed)
|
||||
* @param {string} providedToken - Optional token provided by caller
|
||||
* @returns {Promise<string>} Valid token
|
||||
* @throws {Error} If no valid token can be obtained
|
||||
*/
|
||||
async function requireToken(providedToken) {
|
||||
if (providedToken) return providedToken;
|
||||
const result = await ensureToken();
|
||||
if (result.success) return result.token;
|
||||
const err = new Error(`No valid DNS token available. ${result.error}`);
|
||||
err.statusCode = 401;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DNS A record
|
||||
* @param {string} subdomain - Service subdomain
|
||||
* @param {string} ip - IP address
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createRecord(subdomain, ip) {
|
||||
const tokenResult = await ensureToken();
|
||||
if (!tokenResult.success) {
|
||||
throw new Error(`DNS token not available: ${tokenResult.error}`);
|
||||
}
|
||||
|
||||
const domain = buildDomain(subdomain);
|
||||
const zone = siteConfig.tld.replace(/^\./, '');
|
||||
|
||||
const params = {
|
||||
token: dnsToken,
|
||||
domain,
|
||||
zone,
|
||||
type: 'A',
|
||||
ipAddress: ip,
|
||||
ttl: '300',
|
||||
overwrite: 'true',
|
||||
};
|
||||
|
||||
log.info('dns', 'Creating DNS record', { domain, ip });
|
||||
await call(siteConfig.dnsServerIp, '/api/zones/records/add', params);
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
buildUrl,
|
||||
requireToken,
|
||||
ensureToken,
|
||||
createRecord,
|
||||
refresh: refreshToken,
|
||||
getToken: () => dnsToken,
|
||||
setToken: (t) => {
|
||||
dnsToken = t;
|
||||
},
|
||||
getTokenExpiry: () => dnsTokenExpiry,
|
||||
setTokenExpiry: (e) => {
|
||||
dnsTokenExpiry = e;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createDnsContext;
|
||||
14
dashcaddy-api/src/context/index.js
Normal file
14
dashcaddy-api/src/context/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Context modules
|
||||
* Domain-specific context factories
|
||||
*/
|
||||
|
||||
const docker = require('./docker');
|
||||
const createCaddyContext = require('./caddy');
|
||||
const createDnsContext = require('./dns');
|
||||
|
||||
module.exports = {
|
||||
docker,
|
||||
createCaddyContext,
|
||||
createDnsContext,
|
||||
};
|
||||
Reference in New Issue
Block a user