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,88 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Caddy context
|
* Caddy context
|
||||||
* Caddyfile manipulation, reload, and site verification
|
* Caddyfile manipulation, reload, and configuration generation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fsp = require('fs').promises;
|
const fsp = require('fs').promises;
|
||||||
const { CADDY, RETRIES } = require('../../constants');
|
const { RETRIES, CADDY } = require('../../constants');
|
||||||
const { safeErrorMessage } = require('../utils/safe-error');
|
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
|
// Mutex for atomic Caddyfile modifications
|
||||||
let _caddyfileLock = Promise.resolve();
|
let _caddyfileLock = Promise.resolve();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically read-modify-write the Caddyfile and reload Caddy
|
* Create Caddy context
|
||||||
* Uses a mutex to prevent concurrent modifications from clobbering each other
|
* @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }
|
||||||
* 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}>}
|
|
||||||
*/
|
*/
|
||||||
async function modifyCaddyfile(modifyFn) {
|
function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) {
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the current Caddyfile content
|
* Read the current Caddyfile content
|
||||||
* @returns {Promise<string>} Caddyfile content
|
* @returns {Promise<string>} Caddyfile content
|
||||||
*/
|
*/
|
||||||
async function readCaddyfile() {
|
async function readCaddyfile() {
|
||||||
return fsp.readFile(config.caddyfilePath, 'utf8');
|
return fsp.readFile(caddyfilePath, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reload Caddy configuration via admin API
|
* Reload Caddy configuration via admin API
|
||||||
* @param {string} content - New Caddyfile content
|
* @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;
|
const maxRetries = RETRIES.CADDY_RELOAD;
|
||||||
@@ -90,7 +33,7 @@ async function reloadCaddy(content) {
|
|||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
try {
|
try {
|
||||||
const response = await fetchT(`${config.caddyAdminUrl}/load`, {
|
const response = await fetchT(`${caddyAdminUrl}/load`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
|
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
|
||||||
body: content,
|
body: content,
|
||||||
@@ -119,22 +62,59 @@ async function reloadCaddy(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate Caddy reverse proxy configuration
|
* 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}>}
|
||||||
|
*/
|
||||||
|
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} subdomain - Service subdomain
|
||||||
* @param {string} ip - Backend IP address
|
* @param {string} ip - Target IP address
|
||||||
* @param {number} port - Backend port
|
* @param {number} port - Target port
|
||||||
* @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport }
|
* @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport }
|
||||||
* @returns {string} Caddy configuration snippet
|
* @returns {string} Caddy configuration block
|
||||||
*/
|
*/
|
||||||
function generateConfig(subdomain, ip, port, options = {}) {
|
function generateConfig(subdomain, ip, port, options = {}) {
|
||||||
const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = 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) {
|
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
|
||||||
let config = '';
|
let config = '';
|
||||||
|
|
||||||
// Native-support apps: use handle (preserve path prefix)
|
|
||||||
// Strip-mode apps: use handle_path (remove path prefix before proxying)
|
|
||||||
if (subpathSupport === 'native') {
|
if (subpathSupport === 'native') {
|
||||||
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
|
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
|
||||||
config += `\thandle /${subdomain}/* {\n`;
|
config += `\thandle /${subdomain}/* {\n`;
|
||||||
@@ -174,22 +154,20 @@ function generateConfig(subdomain, ip, port, options = {}) {
|
|||||||
/**
|
/**
|
||||||
* Verify a site is accessible via HTTPS
|
* Verify a site is accessible via HTTPS
|
||||||
* @param {string} domain - Domain to check
|
* @param {string} domain - Domain to check
|
||||||
* @param {number} maxAttempts - Max verification attempts
|
* @param {number} maxAttempts - Maximum retry attempts
|
||||||
* @returns {Promise<boolean>} true if site is accessible
|
* @returns {Promise<boolean>} True if accessible
|
||||||
*/
|
*/
|
||||||
async function verifySite(domain, maxAttempts = 5) {
|
async function verifySite(domain, maxAttempts = 5) {
|
||||||
const delay = 2000;
|
const delay = 2000;
|
||||||
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
try {
|
try {
|
||||||
// Try HTTPS first (internal CA)
|
|
||||||
const response = await fetchT(`https://${domain}/`, {
|
const response = await fetchT(`https://${domain}/`, {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
agent: httpsAgent, // Use CA-aware agent
|
agent: httpsAgent,
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Any response (even 4xx) means Caddy is serving the site
|
|
||||||
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -210,11 +188,15 @@ async function verifySite(domain, maxAttempts = 5) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
return {
|
||||||
init,
|
|
||||||
modify: modifyCaddyfile,
|
modify: modifyCaddyfile,
|
||||||
read: readCaddyfile,
|
read: readCaddyfile,
|
||||||
reload: reloadCaddy,
|
reload: reloadCaddy,
|
||||||
generateConfig,
|
generateConfig,
|
||||||
verifySite,
|
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