refactor(context): Extract context modules from god object
- Create src/context/docker.js - Docker operations - Create src/context/caddy.js - Caddyfile manipulation - Create src/context/dns.js - DNS token management and API calls - Create src/context/session.js - Session wrapper - Create src/context/index.js - Context assembly (DI container) Breaks up the 50+ property ctx god object into domain-specific modules
This commit is contained in:
184
dashcaddy-api/src/context/caddy.js
Normal file
184
dashcaddy-api/src/context/caddy.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Caddy context - Caddyfile manipulation and reload
|
||||||
|
*/
|
||||||
|
const fsp = require('fs').promises;
|
||||||
|
const { RETRIES } = require('../../constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically read-modify-write the Caddyfile and reload Caddy.
|
||||||
|
* Uses a mutex to prevent concurrent modifications.
|
||||||
|
* Rolls back on reload failure.
|
||||||
|
*/
|
||||||
|
let _caddyfileLock = Promise.resolve();
|
||||||
|
|
||||||
|
async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) {
|
||||||
|
let resolve;
|
||||||
|
const prev = _caddyfileLock;
|
||||||
|
_caddyfileLock = new Promise(r => { resolve = r; });
|
||||||
|
await prev;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const original = await fsp.readFile(CADDYFILE_PATH, 'utf8');
|
||||||
|
const modified = await modifyFn(original);
|
||||||
|
|
||||||
|
if (modified === null || modified === original) {
|
||||||
|
return { success: false, error: 'No changes to apply' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reloadCaddy(modified);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Rollback
|
||||||
|
await fsp.writeFile(CADDYFILE_PATH, original, 'utf8');
|
||||||
|
return { success: false, error: err.message, rolledBack: true };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current Caddyfile content
|
||||||
|
*/
|
||||||
|
async function readCaddyfile(CADDYFILE_PATH) {
|
||||||
|
return fsp.readFile(CADDYFILE_PATH, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload Caddy via admin API
|
||||||
|
*/
|
||||||
|
async function reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log) {
|
||||||
|
const maxRetries = RETRIES.CADDY_RELOAD;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetchT(`${CADDY_ADMIN_URL}/load`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/caddyfile' },
|
||||||
|
body: content
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
log.info('caddy', 'Caddy configuration reloaded successfully');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastError = await response.text();
|
||||||
|
log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError });
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error.message;
|
||||||
|
log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < maxRetries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a site is accessible via HTTPS
|
||||||
|
*/
|
||||||
|
async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts = 5) {
|
||||||
|
const delay = 2000;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetchT(`https://${domain}/`, {
|
||||||
|
method: 'HEAD',
|
||||||
|
agent: httpsAgent,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('caddy', 'Site verification attempt', {
|
||||||
|
domain,
|
||||||
|
attempt: i + 1,
|
||||||
|
maxAttempts,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < maxAttempts - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn('caddy', 'Could not verify site accessibility', { domain });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Caddy config block for a service
|
||||||
|
*/
|
||||||
|
function generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options = {}) {
|
||||||
|
const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options;
|
||||||
|
|
||||||
|
// Subdirectory mode
|
||||||
|
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
|
||||||
|
let config = '';
|
||||||
|
|
||||||
|
if (subpathSupport === 'native') {
|
||||||
|
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
|
||||||
|
config += `\thandle /${subdomain}/* {\n`;
|
||||||
|
} else {
|
||||||
|
config += `\thandle_path /${subdomain}/* {\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tailscaleOnly) {
|
||||||
|
config += `\t\t@blocked not remote_ip 100.64.0.0/10`;
|
||||||
|
if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`;
|
||||||
|
config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
config += `\t\treverse_proxy ${ip}:${port}\n`;
|
||||||
|
config += `\t}`;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdomain mode
|
||||||
|
let config = `${buildDomain(subdomain)} {\n`;
|
||||||
|
|
||||||
|
if (tailscaleOnly) {
|
||||||
|
config += ` @blocked not remote_ip 100.64.0.0/10`;
|
||||||
|
if (allowedIPs.length > 0) {
|
||||||
|
config += ` ${allowedIPs.join(' ')}`;
|
||||||
|
}
|
||||||
|
config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
config += ` reverse_proxy ${ip}:${port}\n`;
|
||||||
|
config += ` tls internal\n`;
|
||||||
|
config += `}`;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain) {
|
||||||
|
const reload = (content) => reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log);
|
||||||
|
const read = () => readCaddyfile(CADDYFILE_PATH);
|
||||||
|
const modify = (modifyFn) => modifyCaddyfile(CADDYFILE_PATH, reload, modifyFn);
|
||||||
|
const verify = (domain, maxAttempts) => verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts);
|
||||||
|
const generate = (subdomain, ip, port, options) => generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options);
|
||||||
|
|
||||||
|
return {
|
||||||
|
modify,
|
||||||
|
read,
|
||||||
|
reload,
|
||||||
|
generateConfig: generate,
|
||||||
|
verifySite: verify,
|
||||||
|
adminUrl: CADDY_ADMIN_URL,
|
||||||
|
filePath: CADDYFILE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createCaddyContext };
|
||||||
308
dashcaddy-api/src/context/dns.js
Normal file
308
dashcaddy-api/src/context/dns.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* DNS context - Technitium DNS operations and token management
|
||||||
|
*/
|
||||||
|
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
|
||||||
|
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
|
||||||
|
|
||||||
|
// DNS token management
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
function buildDnsUrl(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 a Technitium DNS API endpoint
|
||||||
|
*/
|
||||||
|
async function callDns(server, apiPath, params, fetchT, httpsAgent) {
|
||||||
|
const url = buildDnsUrl(server, apiPath, params);
|
||||||
|
const response = await fetchT(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
agent: httpsAgent
|
||||||
|
}, TIMEOUTS.HTTP_LONG);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh DNS token via login
|
||||||
|
*/
|
||||||
|
async function refreshDnsToken(username, password, server, fetchT, log) {
|
||||||
|
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() + SESSION_TTL.DNS_TOKEN).toISOString();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure we have a valid DNS token (auto-refresh if needed)
|
||||||
|
*/
|
||||||
|
async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
|
||||||
|
// Check if token is valid and not expired
|
||||||
|
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
|
||||||
|
return { success: true, token: dnsToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryIp = siteConfig.dnsServerIp;
|
||||||
|
if (primaryIp) {
|
||||||
|
const dnsId = dnsIpToDnsId(primaryIp, siteConfig);
|
||||||
|
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 refreshDnsToken(username, password, primaryIp, fetchT, log);
|
||||||
|
}
|
||||||
|
} 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 refreshDnsToken(username, password, server || primaryIp, fetchT, log);
|
||||||
|
}
|
||||||
|
} 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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map DNS server IP to its ID
|
||||||
|
*/
|
||||||
|
function dnsIpToDnsId(serverIp, siteConfig) {
|
||||||
|
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
|
||||||
|
if (info.ip === serverIp) return dnsId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a valid token for a specific DNS server
|
||||||
|
*/
|
||||||
|
async function getTokenForServer(targetServer, siteConfig, credentialManager, fetchT, log, role = 'readonly') {
|
||||||
|
const cacheKey = `${targetServer}:${role}`;
|
||||||
|
const cached = dnsServerTokens.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) {
|
||||||
|
return { success: true, token: cached.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPort = siteConfig.dnsServerPort || '5380';
|
||||||
|
|
||||||
|
async function authenticateToServer(username, password) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
user: username,
|
||||||
|
pass: password,
|
||||||
|
includeInfo: 'false'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetchT(
|
||||||
|
`http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok' && result.token) {
|
||||||
|
dnsServerTokens.set(cacheKey, {
|
||||||
|
token: result.token,
|
||||||
|
expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString()
|
||||||
|
});
|
||||||
|
log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
|
||||||
|
return { success: true, token: result.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: result.errorMessage || 'Login failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dnsId = dnsIpToDnsId(targetServer, siteConfig);
|
||||||
|
|
||||||
|
if (dnsId) {
|
||||||
|
try {
|
||||||
|
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||||||
|
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||||||
|
if (username && password) {
|
||||||
|
return await authenticateToServer(username, password);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRole = role === 'readonly' ? 'admin' : 'readonly';
|
||||||
|
try {
|
||||||
|
const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`);
|
||||||
|
const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`);
|
||||||
|
if (username && password) {
|
||||||
|
return await authenticateToServer(username, password);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const username = await credentialManager.retrieve('dns.username');
|
||||||
|
const password = await credentialManager.retrieve('dns.password');
|
||||||
|
if (username && password) {
|
||||||
|
return await authenticateToServer(username, password);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error('dns', 'Credential manager error', { server: targetServer, error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: 'No DNS credentials configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a DNS token (throw if unavailable)
|
||||||
|
*/
|
||||||
|
async function requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log) {
|
||||||
|
if (providedToken) return providedToken;
|
||||||
|
const result = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
|
||||||
|
if (result.success) return result.token;
|
||||||
|
const err = new Error('No valid DNS token available. ' + result.error);
|
||||||
|
err.statusCode = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create DNS record
|
||||||
|
*/
|
||||||
|
async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log) {
|
||||||
|
const tokenResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
|
||||||
|
if (!tokenResult.success) {
|
||||||
|
throw new Error(`DNS token not available: ${tokenResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = buildDomain(subdomain);
|
||||||
|
const zone = siteConfig.tld.replace(/^\./, '');
|
||||||
|
|
||||||
|
const dnsParams = {
|
||||||
|
token: dnsToken,
|
||||||
|
domain,
|
||||||
|
zone,
|
||||||
|
type: 'A',
|
||||||
|
ipAddress: ip,
|
||||||
|
ttl: '300',
|
||||||
|
overwrite: 'true'
|
||||||
|
};
|
||||||
|
|
||||||
|
const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams, fetchT, httpsAgent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('dns', 'Creating DNS record', { domain, ip });
|
||||||
|
const result = await callDnsApi();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
log.info('dns', 'DNS record created', { domain, ip });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
|
||||||
|
log.info('dns', 'Token appears expired, attempting auto-refresh');
|
||||||
|
const refreshResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
|
||||||
|
if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`);
|
||||||
|
|
||||||
|
const retryResult = await callDnsApi();
|
||||||
|
if (retryResult.status === 'ok') {
|
||||||
|
log.info('dns', 'DNS record created after token refresh', { domain, ip });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
throw new Error(retryResult.errorMessage || 'Unknown error after token refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(result.errorMessage || 'Unknown error');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateTokenForServer(serverIp) {
|
||||||
|
dnsServerTokens.delete(`${serverIp}:readonly`);
|
||||||
|
dnsServerTokens.delete(`${serverIp}:admin`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
|
||||||
|
const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
|
||||||
|
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
|
||||||
|
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
|
||||||
|
const refresh = (username, password, server) => refreshDnsToken(username, password, server, fetchT, log);
|
||||||
|
const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log);
|
||||||
|
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
call,
|
||||||
|
buildUrl: buildDnsUrl,
|
||||||
|
requireToken: require,
|
||||||
|
ensureToken,
|
||||||
|
createRecord: create,
|
||||||
|
getToken: () => dnsToken,
|
||||||
|
setToken: (t) => { dnsToken = t; },
|
||||||
|
getTokenExpiry: () => dnsTokenExpiry,
|
||||||
|
setTokenExpiry: (e) => { dnsTokenExpiry = e; },
|
||||||
|
getTokenForServer: getForServer,
|
||||||
|
invalidateTokenForServer,
|
||||||
|
refresh,
|
||||||
|
credentialsFile: DNS_CREDENTIALS_FILE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDnsContext };
|
||||||
67
dashcaddy-api/src/context/docker.js
Normal file
67
dashcaddy-api/src/context/docker.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Docker context - Docker client and operations
|
||||||
|
*/
|
||||||
|
const Docker = require('dockerode');
|
||||||
|
const { DOCKER } = require('../../constants');
|
||||||
|
|
||||||
|
const docker = new Docker();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a Docker image with timeout protection
|
||||||
|
*/
|
||||||
|
function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
docker.pull(imageName, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
docker.modem.followProgress(stream, (err, output) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a running Docker container by name substring
|
||||||
|
*/
|
||||||
|
async function findContainerByName(name, opts = { all: false }) {
|
||||||
|
const containers = await docker.listContainers(opts);
|
||||||
|
const match = containers.find(c =>
|
||||||
|
c.Names.some(n => n.toLowerCase().includes(name.toLowerCase()))
|
||||||
|
);
|
||||||
|
return match || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all host ports currently in use by Docker containers
|
||||||
|
*/
|
||||||
|
async function getUsedPorts() {
|
||||||
|
const containers = await docker.listContainers({ all: false });
|
||||||
|
const ports = new Set();
|
||||||
|
for (const c of containers) {
|
||||||
|
for (const p of (c.Ports || [])) {
|
||||||
|
if (p.PublicPort) ports.add(p.PublicPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDockerContext(dockerSecurity) {
|
||||||
|
return {
|
||||||
|
client: docker,
|
||||||
|
pull: dockerPull,
|
||||||
|
findContainer: findContainerByName,
|
||||||
|
getUsedPorts,
|
||||||
|
security: dockerSecurity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDockerContext };
|
||||||
175
dashcaddy-api/src/context/index.js
Normal file
175
dashcaddy-api/src/context/index.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Context assembly - Dependency injection container
|
||||||
|
* Assembles all context objects needed by routes
|
||||||
|
*/
|
||||||
|
const { createDockerContext } = require('./docker');
|
||||||
|
const { createCaddyContext } = require('./caddy');
|
||||||
|
const { createDnsContext } = require('./dns');
|
||||||
|
const { createSessionContext } = require('./session');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the full application context
|
||||||
|
* This replaces the old "god object" ctx with explicit construction
|
||||||
|
*/
|
||||||
|
function assembleContext({
|
||||||
|
// Config
|
||||||
|
siteConfig,
|
||||||
|
buildDomain,
|
||||||
|
buildServiceUrl,
|
||||||
|
SERVICES_FILE,
|
||||||
|
CONFIG_FILE,
|
||||||
|
TOTP_CONFIG_FILE,
|
||||||
|
TAILSCALE_CONFIG_FILE,
|
||||||
|
NOTIFICATIONS_FILE,
|
||||||
|
ERROR_LOG_FILE,
|
||||||
|
DNS_CREDENTIALS_FILE,
|
||||||
|
CADDYFILE_PATH,
|
||||||
|
CADDY_ADMIN_URL,
|
||||||
|
|
||||||
|
// State managers
|
||||||
|
servicesStateManager,
|
||||||
|
configStateManager,
|
||||||
|
|
||||||
|
// Managers
|
||||||
|
credentialManager,
|
||||||
|
authManager,
|
||||||
|
licenseManager,
|
||||||
|
healthChecker,
|
||||||
|
updateManager,
|
||||||
|
backupManager,
|
||||||
|
resourceMonitor,
|
||||||
|
auditLogger,
|
||||||
|
portLockManager,
|
||||||
|
selfUpdater,
|
||||||
|
dockerMaintenance,
|
||||||
|
logDigest,
|
||||||
|
dockerSecurity,
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
APP_TEMPLATES,
|
||||||
|
TEMPLATE_CATEGORIES,
|
||||||
|
DIFFICULTY_LEVELS,
|
||||||
|
RECIPE_TEMPLATES,
|
||||||
|
RECIPE_CATEGORIES,
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
asyncHandler,
|
||||||
|
errorResponse,
|
||||||
|
ok,
|
||||||
|
fetchT,
|
||||||
|
httpsAgent,
|
||||||
|
log,
|
||||||
|
logError,
|
||||||
|
safeErrorMessage,
|
||||||
|
getServiceById,
|
||||||
|
readConfig,
|
||||||
|
saveConfig,
|
||||||
|
addServiceToConfig,
|
||||||
|
validateURL,
|
||||||
|
strictLimiter,
|
||||||
|
totpConfig,
|
||||||
|
saveTotpConfig,
|
||||||
|
loadSiteConfig,
|
||||||
|
loadNotificationConfig,
|
||||||
|
resyncHealthChecker,
|
||||||
|
|
||||||
|
// Middleware result
|
||||||
|
middlewareResult,
|
||||||
|
|
||||||
|
// App
|
||||||
|
app,
|
||||||
|
}) {
|
||||||
|
// Create domain-specific contexts
|
||||||
|
const docker = createDockerContext(dockerSecurity);
|
||||||
|
const caddy = createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain);
|
||||||
|
const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
|
||||||
|
const session = createSessionContext(middlewareResult);
|
||||||
|
|
||||||
|
// Notification context (inline for now - could be extracted)
|
||||||
|
const notification = {
|
||||||
|
// These will be populated by server.js for now
|
||||||
|
// TODO: Extract notification module
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tailscale context (inline for now - could be extracted)
|
||||||
|
const tailscale = {
|
||||||
|
// These will be populated by server.js for now
|
||||||
|
// TODO: Extract tailscale module
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assemble flat context (temporary - routes still expect this)
|
||||||
|
const ctx = {
|
||||||
|
// Namespaced contexts
|
||||||
|
docker,
|
||||||
|
caddy,
|
||||||
|
dns,
|
||||||
|
session,
|
||||||
|
notification,
|
||||||
|
tailscale,
|
||||||
|
|
||||||
|
// App and config
|
||||||
|
app,
|
||||||
|
siteConfig,
|
||||||
|
|
||||||
|
// State managers
|
||||||
|
servicesStateManager,
|
||||||
|
configStateManager,
|
||||||
|
|
||||||
|
// Managers
|
||||||
|
credentialManager,
|
||||||
|
authManager,
|
||||||
|
licenseManager,
|
||||||
|
healthChecker,
|
||||||
|
updateManager,
|
||||||
|
backupManager,
|
||||||
|
resourceMonitor,
|
||||||
|
auditLogger,
|
||||||
|
portLockManager,
|
||||||
|
selfUpdater,
|
||||||
|
dockerMaintenance,
|
||||||
|
logDigest,
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
APP_TEMPLATES,
|
||||||
|
TEMPLATE_CATEGORIES,
|
||||||
|
DIFFICULTY_LEVELS,
|
||||||
|
RECIPE_TEMPLATES,
|
||||||
|
RECIPE_CATEGORIES,
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
asyncHandler,
|
||||||
|
errorResponse,
|
||||||
|
ok,
|
||||||
|
fetchT,
|
||||||
|
log,
|
||||||
|
logError,
|
||||||
|
safeErrorMessage,
|
||||||
|
buildDomain,
|
||||||
|
buildServiceUrl,
|
||||||
|
getServiceById,
|
||||||
|
readConfig,
|
||||||
|
saveConfig,
|
||||||
|
addServiceToConfig,
|
||||||
|
validateURL,
|
||||||
|
strictLimiter,
|
||||||
|
|
||||||
|
// Config helpers
|
||||||
|
totpConfig,
|
||||||
|
saveTotpConfig,
|
||||||
|
loadSiteConfig,
|
||||||
|
loadNotificationConfig,
|
||||||
|
resyncHealthChecker,
|
||||||
|
|
||||||
|
// File paths
|
||||||
|
SERVICES_FILE,
|
||||||
|
CONFIG_FILE,
|
||||||
|
TOTP_CONFIG_FILE,
|
||||||
|
TAILSCALE_CONFIG_FILE,
|
||||||
|
NOTIFICATIONS_FILE,
|
||||||
|
ERROR_LOG_FILE,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { assembleContext };
|
||||||
21
dashcaddy-api/src/context/session.js
Normal file
21
dashcaddy-api/src/context/session.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Session context - IP-based session management
|
||||||
|
* (Implementation provided by middleware, just re-exported here)
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createSessionContext(middlewareResult) {
|
||||||
|
const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipSessions,
|
||||||
|
durations: SESSION_DURATIONS,
|
||||||
|
getClientIP,
|
||||||
|
create: createIPSession,
|
||||||
|
setCookie: setSessionCookie,
|
||||||
|
clear: clearIPSession,
|
||||||
|
clearCookie: clearSessionCookie,
|
||||||
|
isValid: isSessionValid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSessionContext };
|
||||||
Reference in New Issue
Block a user