Sync DNS2 production changes - removed obsolete test suite and refactored structure

This commit is contained in:
Krystie
2026-03-23 10:47:15 +01:00
parent 1ac50918ab
commit d76644d948
288 changed files with 8965 additions and 15731 deletions

View File

@@ -62,7 +62,7 @@ module.exports = function(ctx, helpers) {
ctx.log.info('deploy', 'DashCA: Using existing index.html');
}
ctx.log.info('deploy', `DashCA: For full features, copy certificate files to ${ destPath}`);
ctx.log.info('deploy', 'DashCA: For full features, copy certificate files to ' + destPath);
ctx.log.info('deploy', 'DashCA: Static site deployment completed successfully');
} catch (error) {
ctx.log.error('deploy', 'DashCA deployment error', { error: error.message });
@@ -121,14 +121,14 @@ module.exports = function(ctx, helpers) {
PortBindings: {},
Binds: translatedVolumes,
RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG,
LogConfig: DOCKER.LOG_CONFIG
},
Env: Object.entries(processedTemplate.docker.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
'sami.managed': 'true', 'sami.app': appId,
'sami.subdomain': userConfig.subdomain,
'sami.deployed': new Date().toISOString(),
},
'sami.deployed': new Date().toISOString()
}
};
processedTemplate.docker.ports.forEach(portMapping => {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
ctx.log.info('docker', 'Pruned dangling images after deploy', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after deploy failed', { error: pruneErr.message });
@@ -324,7 +324,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [],
customVolumes: config.customVolumes || undefined,
useExisting: false,
useExisting: false
},
container: template.isStaticSite ? null : {
image: processedTemplate.docker.image,
@@ -340,14 +340,14 @@ module.exports = function(ctx, helpers) {
}
return env;
})(),
capabilities: processedTemplate.docker.capabilities || undefined,
capabilities: processedTemplate.docker.capabilities || undefined
},
caddy: {
tailscaleOnly: config.tailscaleOnly || false,
allowedIPs: config.allowedIPs || [],
subpathSupport: template.subpathSupport || 'strip',
routingMode: ctx.siteConfig.routingMode,
},
routingMode: ctx.siteConfig.routingMode
}
};
await ctx.addServiceToConfig({
@@ -358,7 +358,7 @@ module.exports = function(ctx, helpers) {
tailscaleOnly: config.tailscaleOnly || false,
routingMode: ctx.siteConfig.routingMode,
deployedAt: new Date().toISOString(),
deploymentManifest,
deploymentManifest
});
ctx.log.info('deploy', 'Service added to dashboard', { subdomain: config.subdomain });
@@ -366,7 +366,7 @@ module.exports = function(ctx, helpers) {
success: true, containerId, usedExisting,
url: serviceUrl,
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
setupInstructions: template.setupInstructions || [],
setupInstructions: template.setupInstructions || []
};
if (dnsWarning) response.warning = dnsWarning;

View File

@@ -38,16 +38,16 @@ module.exports = function(ctx) {
const templateImage = template.docker.image.split(':')[0];
for (const container of containers) {
const containerImage = container.Image.split(':')[0];
if (containerImage === templateImage || containerImage.endsWith(`/${ templateImage}`)) {
if (containerImage === templateImage || containerImage.endsWith('/' + templateImage)) {
const ports = container.Ports.filter(p => p.PublicPort).map(p => ({
hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type,
hostPort: p.PublicPort, containerPort: p.PrivatePort, protocol: p.Type
}));
return {
id: container.Id, shortId: container.Id.slice(0, 12),
name: container.Names[0]?.replace(/^\//, '') || 'unknown',
image: container.Image, status: container.Status, state: container.State,
ports, primaryPort: ports.length > 0 ? ports[0].hostPort : null,
labels: container.Labels || {},
labels: container.Labels || {}
};
}
}
@@ -72,7 +72,7 @@ module.exports = function(ctx) {
'{{PORT}}': config.port || template.defaultPort,
'{{MEDIA_PATH}}': mediaPaths[0] || '/media',
'{{TIMEZONE}}': ctx.siteConfig.timezone || 'UTC',
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex'),
'{{GENERATED_SECRET}}': crypto.randomBytes(32).toString('hex')
};
function replaceInObject(obj) {
@@ -117,7 +117,7 @@ module.exports = function(ctx) {
const basePath = `/${config.subdomain}`;
// Some apps need the full URL, not just the path
if (['GF_SERVER_ROOT_URL', 'GITEA__server__ROOT_URL'].includes(template.urlBaseEnv)) {
processed.docker.environment[template.urlBaseEnv] = `${ctx.buildServiceUrl(config.subdomain) }/`;
processed.docker.environment[template.urlBaseEnv] = ctx.buildServiceUrl(config.subdomain) + '/';
} else {
processed.docker.environment[template.urlBaseEnv] = basePath;
}
@@ -137,7 +137,7 @@ module.exports = function(ctx) {
config.mediaPath.split(',').map(p => p.trim()).filter(Boolean).forEach(p => allowedRoots.push(path.resolve(p)));
}
const isAllowed = allowedRoots.some(root =>
normalizedHost === root || normalizedHost.startsWith(root + path.sep),
normalizedHost === root || normalizedHost.startsWith(root + path.sep)
);
if (!isAllowed) {
ctx.log.warn('deploy', 'Custom volume host path rejected', { hostPath: override.hostPath, allowed: allowedRoots });
@@ -162,76 +162,76 @@ module.exports = function(ctx) {
c += ` root * ${sitePath}\n\n`;
if (tailscaleOnly) {
c += ' @blocked not remote_ip 100.64.0.0/10\n';
c += ' respond @blocked "Access denied. Tailscale connection required." 403\n\n';
c += ` @blocked not remote_ip 100.64.0.0/10\n`;
c += ` respond @blocked "Access denied. Tailscale connection required." 403\n\n`;
}
if (apiProxy) {
c += ' handle /api/* {\n';
c += ` handle /api/* {\n`;
c += ` reverse_proxy ${apiProxy}\n`;
c += ' }\n\n';
c += ` }\n\n`;
}
c += ' @crt path *.crt\n';
c += ' handle @crt {\n';
c += ' header Content-Type application/x-x509-ca-cert\n';
c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ' header Cache-Control "public, max-age=86400"\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' @der path *.der\n';
c += ' handle @der {\n';
c += ' header Content-Type application/x-x509-ca-cert\n';
c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ' header Cache-Control "public, max-age=86400"\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' @mobileconfig path *.mobileconfig\n';
c += ' handle @mobileconfig {\n';
c += ' header Content-Type application/x-apple-aspen-config\n';
c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ' header Cache-Control "public, max-age=86400"\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' @ps1 path *.ps1\n';
c += ' handle @ps1 {\n';
c += ' header Content-Type text/plain\n';
c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' @sh path *.sh\n';
c += ' handle @sh {\n';
c += ' header Content-Type text/x-shellscript\n';
c += ' header Content-Disposition "attachment; filename=\\"{file}\\""\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' # Static site with SPA fallback\n';
c += ' handle {\n';
c += ' @notFile not file {path}\n';
c += ' rewrite @notFile /index.html\n';
c += ' file_server\n';
c += ' }\n\n';
c += ' # No cache for HTML\n';
c += ' @htmlfiles {\n';
c += ' path *.html\n';
c += ' path /\n';
c += ' }\n';
c += ' header @htmlfiles Cache-Control "no-store"\n';
c += ` @crt path *.crt\n`;
c += ` handle @crt {\n`;
c += ` header Content-Type application/x-x509-ca-cert\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @der path *.der\n`;
c += ` handle @der {\n`;
c += ` header Content-Type application/x-x509-ca-cert\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @mobileconfig path *.mobileconfig\n`;
c += ` handle @mobileconfig {\n`;
c += ` header Content-Type application/x-apple-aspen-config\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` header Cache-Control "public, max-age=86400"\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @ps1 path *.ps1\n`;
c += ` handle @ps1 {\n`;
c += ` header Content-Type text/plain\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` @sh path *.sh\n`;
c += ` handle @sh {\n`;
c += ` header Content-Type text/x-shellscript\n`;
c += ` header Content-Disposition "attachment; filename=\\"{file}\\""\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` # Static site with SPA fallback\n`;
c += ` handle {\n`;
c += ` @notFile not file {path}\n`;
c += ` rewrite @notFile /index.html\n`;
c += ` file_server\n`;
c += ` }\n\n`;
c += ` # No cache for HTML\n`;
c += ` @htmlfiles {\n`;
c += ` path *.html\n`;
c += ` path /\n`;
c += ` }\n`;
c += ` header @htmlfiles Cache-Control "no-store"\n`;
return c;
}
// HTTPS block
let config = `${domain} {\n`;
config += ' tls internal\n\n';
config += ` tls internal\n\n`;
config += siteBlockContent();
config += '}';
config += `}`;
// HTTP companion block for devices that haven't trusted the CA yet
if (httpAccess) {
config += '\n\n# HTTP access for first-time certificate installation\n';
config += `\n\n# HTTP access for first-time certificate installation\n`;
config += `http://${domain} {\n`;
config += siteBlockContent();
config += '}';
config += `}`;
}
return config;
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
} else if (healthPath && port && httpCheckFailed < 5) {
try {
const response = await ctx.fetchT(`http://localhost:${port}${healthPath}`, {
signal: AbortSignal.timeout(3000), redirect: 'manual',
signal: AbortSignal.timeout(3000), redirect: 'manual'
});
if (response.ok || (response.status >= 300 && response.status < 400)) {
ctx.log.info('docker', 'Health check passed', { containerId, status: response.status });
@@ -290,7 +290,7 @@ module.exports = function(ctx) {
await ctx.caddy.reload(existing);
return;
}
const result = await ctx.caddy.modify(c => `${c }\n${config}\n`);
const result = await ctx.caddy.modify(c => c + `\n${config}\n`);
if (!result.success) throw new Error(`[DC-303] Failed to add Caddy config for ${domain}: ${result.error}`);
await ctx.caddy.verifySite(domain);
}
@@ -405,6 +405,6 @@ module.exports = function(ctx) {
removeSubpathConfig,
ensureMainDomainBlock,
RESERVED_SUBPATHS,
generateStaticSiteConfig,
generateStaticSiteConfig
};
};

View File

@@ -26,7 +26,7 @@ module.exports = function(ctx, helpers) {
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
ctx.log.info('docker', 'Pruned dangling images after removal', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after removal failed', { error: pruneErr.message });
@@ -42,7 +42,7 @@ module.exports = function(ctx, helpers) {
try {
const domain = ctx.buildDomain(subdomain);
const getResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/get', {
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
token: ctx.dns.getToken(), domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
});
let recordIp = ip || 'localhost';
if (getResult.status === 'ok' && getResult.response?.records) {
@@ -50,7 +50,7 @@ module.exports = function(ctx, helpers) {
if (aRecord && aRecord.rData?.ipAddress) recordIp = aRecord.rData.ipAddress;
}
const dnsResult = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp,
token: ctx.dns.getToken(), domain, type: 'A', ipAddress: recordIp
});
results.dns = dnsResult.status === 'ok' ? 'deleted' : (dnsResult.errorMessage || 'failed');
ctx.log.info('dns', 'DNS record removal', { result: results.dns });

View File

@@ -37,7 +37,7 @@ module.exports = function(ctx, helpers) {
return res.json({
success: true,
message: 'No services have deployment manifests to restore',
results: [],
results: []
});
}
@@ -51,7 +51,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'failed',
error: error.message,
error: error.message
});
}
}
@@ -63,7 +63,7 @@ module.exports = function(ctx, helpers) {
res.json({
success: true,
message: `Restore complete: ${succeeded} restored, ${skipped} skipped, ${failed} failed`,
results,
results
});
}, 'apps-restore-all'));
@@ -81,7 +81,7 @@ module.exports = function(ctx, helpers) {
hasManifest: !!service.deploymentManifest,
templateId: service.deploymentManifest?.templateId || service.appTemplate || null,
deployedAt: service.deployedAt || null,
containerRunning: false,
containerRunning: false
};
// Check if container is currently running
@@ -125,7 +125,7 @@ module.exports = function(ctx, helpers) {
name: service.name,
status: 'restored',
type: 'static',
message: `Static site "${service.name}" config preserved`,
message: `Static site "${service.name}" config preserved`
};
}
@@ -140,7 +140,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'skipped',
message: 'Container already running',
message: 'Container already running'
};
}
} catch (e) {
@@ -164,7 +164,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'skipped',
message: 'Container already running (found by name)',
message: 'Container already running (found by name)'
};
}
// Exists but not running — remove stale container
@@ -178,7 +178,7 @@ module.exports = function(ctx, helpers) {
id: service.id,
name: service.name,
status: 'failed',
error: 'No container configuration in manifest',
error: 'No container configuration in manifest'
};
}
@@ -189,7 +189,7 @@ module.exports = function(ctx, helpers) {
} catch (e) {
// Check if image exists locally
const images = await ctx.docker.client.listImages({
filters: { reference: [manifest.container.image] },
filters: { reference: [manifest.container.image] }
});
if (images.length === 0) {
throw new Error(`Failed to pull image ${manifest.container.image}: ${e.message}`);
@@ -206,7 +206,7 @@ module.exports = function(ctx, helpers) {
PortBindings: {},
Binds: manifest.container.volumes || [],
RestartPolicy: { Name: 'unless-stopped' },
LogConfig: DOCKER.LOG_CONFIG,
LogConfig: DOCKER.LOG_CONFIG
},
Env: Object.entries(manifest.container.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
@@ -214,8 +214,8 @@ module.exports = function(ctx, helpers) {
'sami.app': manifest.templateId,
'sami.subdomain': manifest.config.subdomain,
'sami.deployed': new Date().toISOString(),
'sami.restored': 'true',
},
'sami.restored': 'true'
}
};
// Set up port bindings
@@ -287,7 +287,7 @@ module.exports = function(ctx, helpers) {
status: 'restored',
type: 'container',
containerId: container.id,
message: `${service.name} restored successfully`,
message: `${service.name} restored successfully`
};
}

View File

@@ -11,7 +11,7 @@ module.exports = function(ctx, helpers) {
success: true,
templates: ctx.APP_TEMPLATES,
categories: ctx.TEMPLATE_CATEGORIES,
difficultyLevels: ctx.DIFFICULTY_LEVELS,
difficultyLevels: ctx.DIFFICULTY_LEVELS
});
}, 'apps-templates'));
@@ -71,7 +71,7 @@ module.exports = function(ctx, helpers) {
try {
const oldDomain = oldSubdomain.includes('.') ? oldSubdomain : ctx.buildDomain(oldSubdomain);
const result = await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', {
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost',
token: ctx.dns.getToken(), domain: oldDomain, type: 'A', ipAddress: ip || 'localhost'
});
results.oldDns = result.status === 'ok' ? 'deleted' : result.errorMessage;
ctx.log.info('dns', 'Old DNS record deleted', { domain: oldDomain });
@@ -139,7 +139,7 @@ module.exports = function(ctx, helpers) {
success: true,
message: `Subdomain updated: ${oldSubdomain} -> ${newSubdomain}`,
newUrl: `https://${ctx.buildDomain(newSubdomain)}`,
results,
results
});
}, 'update-subdomain'));

View File

@@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) {
const results = { radarr: null, sonarr: null };
// Step 1: Authenticate with Overseerr via Plex token
const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.',
hint: 'Complete Overseerr setup wizard and link your Plex account first, then try again.'
});
}
@@ -30,8 +30,8 @@ module.exports = function(ctx, helpers) {
headers: {
'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie,
...options.headers,
},
...options.headers
}
});
return response;
};
@@ -41,12 +41,12 @@ module.exports = function(ctx, helpers) {
const statusRes = await overseerrFetch('/api/v1/status');
if (!statusRes.ok) {
return ctx.errorResponse(res, 502, 'Cannot connect to Overseerr', {
hint: 'Make sure Overseerr is running on port 5055',
hint: 'Make sure Overseerr is running on port 5055'
});
}
} catch (e) {
return ctx.errorResponse(res, 502, `Cannot reach Overseerr: ${e.message}`, {
hint: 'Check if Overseerr container is running',
hint: 'Check if Overseerr container is running'
});
}
@@ -59,14 +59,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': radarr.apiKey },
headers: { 'X-Api-Key': radarr.apiKey }
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${radarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': radarr.apiKey },
headers: { 'X-Api-Key': radarr.apiKey }
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -87,12 +87,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: radarr.url,
tags: [],
tags: []
};
const radarrRes = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST',
body: JSON.stringify(radarrConfig),
body: JSON.stringify(radarrConfig)
});
if (radarrRes.ok) {
@@ -115,14 +115,14 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey },
headers: { 'X-Api-Key': sonarr.apiKey }
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': sonarr.apiKey },
headers: { 'X-Api-Key': sonarr.apiKey }
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -131,7 +131,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1;
try {
const langRes = await ctx.fetchT(`${sonarrBaseUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': sonarr.apiKey },
headers: { 'X-Api-Key': sonarr.apiKey }
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -158,12 +158,12 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: sonarr.url,
tags: [],
tags: []
};
const sonarrRes = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST',
body: JSON.stringify(sonarrConfig),
body: JSON.stringify(sonarrConfig)
});
if (sonarrRes.ok) {
@@ -182,7 +182,7 @@ module.exports = function(ctx, helpers) {
res.json({
success: anyConfigured,
message: anyConfigured ? 'Services configured in Overseerr' : 'Configuration failed',
results,
results
});
}, 'arr-configure-overseerr'));
@@ -210,7 +210,7 @@ module.exports = function(ctx, helpers) {
}
// Normalize URL - remove trailing slash
const baseUrl = url.replace(/\/+$/, '');
let baseUrl = url.replace(/\/+$/, '');
// Build the API endpoint
let apiEndpoint;
@@ -233,7 +233,7 @@ module.exports = function(ctx, helpers) {
const response = await ctx.fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
if (response.ok) {
@@ -244,7 +244,7 @@ module.exports = function(ctx, helpers) {
return res.json({
success: true,
version,
appName,
appName
});
} else if (response.status === 401) {
return ctx.errorResponse(res, 401, 'Invalid API key');
@@ -288,7 +288,7 @@ module.exports = function(ctx, helpers) {
containerName: container.Names[0]?.replace(/^\//, ''),
port: exposedPort,
url: `http://host.docker.internal:${exposedPort}`,
localUrl: `http://localhost:${exposedPort}`,
localUrl: `http://localhost:${exposedPort}`
};
// Extract API key for arr services
@@ -305,7 +305,7 @@ module.exports = function(ctx, helpers) {
radarrFound: !!detected.radarr?.apiKey,
sonarrFound: !!detected.sonarr?.apiKey,
lidarrFound: !!detected.lidarr?.apiKey,
prowlarrFound: !!detected.prowlarr?.apiKey,
prowlarrFound: !!detected.prowlarr?.apiKey
};
ctx.log.info('arr', 'Detected services', summary);
@@ -313,14 +313,14 @@ module.exports = function(ctx, helpers) {
if (!summary.overseerrFound) {
return ctx.errorResponse(res, 400, 'Overseerr is not running. Deploy it first.', {
detected,
summary,
summary
});
}
if (!summary.radarrFound && !summary.sonarrFound) {
return ctx.errorResponse(res, 400, 'No Radarr or Sonarr found with valid API keys. Deploy at least one first.', {
detected,
summary,
summary
});
}
@@ -331,7 +331,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 502, 'Could not authenticate with Overseerr. Make sure Plex and Overseerr are running.', {
setupUrl: detected.overseerr.localUrl,
detected,
summary,
summary
});
}
@@ -344,8 +344,8 @@ module.exports = function(ctx, helpers) {
headers: {
'Content-Type': 'application/json',
'Cookie': overseerrSession.cookie,
...options.headers,
},
...options.headers
}
});
};
@@ -356,14 +356,14 @@ module.exports = function(ctx, helpers) {
try {
// Fetch quality profiles from Radarr
const profilesRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.radarr.apiKey },
headers: { 'X-Api-Key': detected.radarr.apiKey }
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Radarr
const rootFoldersRes = await ctx.fetchT(`${detected.radarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.radarr.apiKey },
headers: { 'X-Api-Key': detected.radarr.apiKey }
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -384,12 +384,12 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: detected.radarr.localUrl,
tags: [],
tags: []
};
const resp = await overseerrFetch('/api/v1/settings/radarr', {
method: 'POST',
body: JSON.stringify(radarrConfig),
body: JSON.stringify(radarrConfig)
});
configResults.radarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -403,14 +403,14 @@ module.exports = function(ctx, helpers) {
try {
// Fetch quality profiles from Sonarr
const profilesRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey },
headers: { 'X-Api-Key': detected.sonarr.apiKey }
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
// Fetch root folders from Sonarr
const rootFoldersRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey },
headers: { 'X-Api-Key': detected.sonarr.apiKey }
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -419,7 +419,7 @@ module.exports = function(ctx, helpers) {
let languageProfileId = 1;
try {
const langRes = await ctx.fetchT(`${detected.sonarr.localUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': detected.sonarr.apiKey },
headers: { 'X-Api-Key': detected.sonarr.apiKey }
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -444,12 +444,12 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: detected.sonarr.localUrl,
tags: [],
tags: []
};
const resp = await overseerrFetch('/api/v1/settings/sonarr', {
method: 'POST',
body: JSON.stringify(sonarrConfig),
body: JSON.stringify(sonarrConfig)
});
configResults.sonarr = resp.ok ? 'configured' : `failed: ${await resp.text()}`;
@@ -466,7 +466,7 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess',
'Arr Stack Auto-Connected',
`Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`,
'success',
'success'
);
}
@@ -475,7 +475,7 @@ module.exports = function(ctx, helpers) {
message: anyConfigured ? 'Auto-setup completed successfully!' : 'Configuration failed',
detected,
configResults,
summary,
summary
});
}, 'arr-auto-setup'));

View File

@@ -39,7 +39,7 @@ module.exports = function(ctx, helpers) {
service,
source: url ? 'external' : 'local',
url: url || null,
storedAt: new Date().toISOString(),
storedAt: new Date().toISOString()
};
// Test connection if URL is known
@@ -77,7 +77,7 @@ module.exports = function(ctx, helpers) {
return ctx.errorResponse(res, 400, 'Invalid seedbox base URL');
}
await ctx.credentialManager.store('arr.seedbox.baseurl', seedboxBaseUrl, {
storedAt: new Date().toISOString(),
storedAt: new Date().toISOString()
});
}
@@ -87,7 +87,7 @@ module.exports = function(ctx, helpers) {
success: true,
message: `${service} API key stored`,
connectionTest,
url: resolvedUrl,
url: resolvedUrl
});
}, 'arr-credentials-store'));
@@ -106,7 +106,7 @@ module.exports = function(ctx, helpers) {
url: metadata?.url || null,
lastVerified: metadata?.lastVerified || null,
version: metadata?.version || null,
source: metadata?.source || null,
source: metadata?.source || null
};
}

View File

@@ -13,7 +13,7 @@ module.exports = function(ctx, helpers) {
sonarr: null,
overseerr: null,
lidarr: null,
prowlarr: null,
prowlarr: null
};
// Service detection patterns
@@ -35,7 +35,7 @@ module.exports = function(ctx, helpers) {
image: container.Image,
port: exposedPort,
status: container.State,
url: helpers.getServiceUrl(containerName, exposedPort),
url: helpers.getServiceUrl(containerName, exposedPort)
};
// Get API key for arr services (not Plex or Overseerr)
@@ -58,8 +58,8 @@ module.exports = function(ctx, helpers) {
plexReady: !!(detected.plex?.token),
radarrReady: !!(detected.radarr?.apiKey),
sonarrReady: !!(detected.sonarr?.apiKey),
overseerrRunning: !!detected.overseerr,
},
overseerrRunning: !!detected.overseerr
}
});
}, 'arr-detect'));
@@ -86,7 +86,7 @@ module.exports = function(ctx, helpers) {
containerId: container.Id,
containerName: container.Names[0]?.replace(/^\//, ''),
port: portInfo?.PublicPort || config.port,
status: container.State,
status: container.State
};
}
}
@@ -122,7 +122,7 @@ module.exports = function(ctx, helpers) {
hasToken: false,
containerId: null,
containerName: null,
version: null,
version: null
};
// Check Docker first
@@ -143,7 +143,7 @@ module.exports = function(ctx, helpers) {
// Store for later use
await ctx.credentialManager.store('arr.plex.token', token, {
service: 'plex', source: 'local', url: entry.url,
lastVerified: new Date().toISOString(),
lastVerified: new Date().toISOString()
});
} else {
entry.status = 'needs_key';
@@ -160,7 +160,7 @@ module.exports = function(ctx, helpers) {
try {
const radarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/radarr`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (radarrCheck.ok) {
const radarrSettings = await radarrCheck.json();
@@ -170,7 +170,7 @@ module.exports = function(ctx, helpers) {
try {
const sonarrCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/sonarr`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (sonarrCheck.ok) {
const sonarrSettings = await sonarrCheck.json();
@@ -180,7 +180,7 @@ module.exports = function(ctx, helpers) {
try {
const plexCheck = await ctx.fetchT(`http://host.docker.internal:${dc.port}/api/v1/settings/plex`, {
headers: { 'Cookie': session.cookie },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (plexCheck.ok) {
const plexSettings = await plexCheck.json();
@@ -273,7 +273,7 @@ module.exports = function(ctx, helpers) {
fullyConnected: statuses.filter(s => s.status === 'connected').length,
needsApiKey: statuses.filter(s => s.status === 'needs_key').length,
errors: statuses.filter(s => s.status === 'error').length,
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2,
readyForAutoConnect: statuses.filter(s => s.status === 'connected').length >= 2
};
res.json({ success: true, services: result, seedboxBaseUrl: detectedSeedboxUrl, summary });

View File

@@ -12,7 +12,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'],
AttachStdout: true,
AttachStderr: true,
AttachStderr: true
});
const stream = await exec.start();
@@ -38,7 +38,7 @@ module.exports = function(ctx) {
try {
const containers = await ctx.docker.client.listContainers({ all: false });
const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex')),
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
);
if (!container) return null;
@@ -47,7 +47,7 @@ module.exports = function(ctx) {
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true,
AttachStderr: true,
AttachStderr: true
});
const stream = await exec.start();
@@ -97,7 +97,7 @@ module.exports = function(ctx) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }),
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
if (!authRes.ok) {
@@ -125,7 +125,7 @@ module.exports = function(ctx) {
// 1. Get Plex server identity (for return info)
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
if (!identityRes.ok) throw new Error('Cannot reach Plex server');
const identity = await identityRes.json();
@@ -136,16 +136,16 @@ module.exports = function(ctx) {
const plexConfig = {
ip: 'host.docker.internal',
port: APP_PORTS.plex,
useSsl: false,
useSsl: false
};
const configRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie,
'Cookie': sessionCookie
},
body: JSON.stringify(plexConfig),
body: JSON.stringify(plexConfig)
});
if (!configRes.ok) {
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST',
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
} catch (e) {
ctx.log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
@@ -168,7 +168,7 @@ module.exports = function(ctx) {
try {
const libRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (libRes.ok) {
const plexSettings = await libRes.json();
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
try {
const existingRes = await ctx.fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) {
@@ -217,8 +217,8 @@ module.exports = function(ctx) {
{ name: 'prowlarrUrl', value: prowlarrUrl },
{ name: 'baseUrl', value: config.url },
{ name: 'apiKey', value: config.apiKey },
{ name: 'syncCategories', value: syncCategories },
],
{ name: 'syncCategories', value: syncCategories }
]
};
try {
@@ -226,10 +226,10 @@ module.exports = function(ctx) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': prowlarrApiKey,
'X-Api-Key': prowlarrApiKey
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
} catch (e) {
@@ -262,7 +262,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000),
signal: AbortSignal.timeout(15000)
});
if (response.ok) {
@@ -297,6 +297,6 @@ module.exports = function(ctx) {
getOverseerrApiKey,
connectPlexToOverseerr,
configureProwlarrApps,
testServiceConnection,
testServiceConnection
};
};

View File

@@ -14,7 +14,7 @@ module.exports = function(ctx, helpers) {
if (!plexToken) {
return ctx.errorResponse(res, 400, 'No Plex token available. Claim your Plex server first.', {
hint: 'Deploy Plex with a claim token or manually configure it.',
hint: 'Deploy Plex with a claim token or manually configure it.'
});
}
@@ -32,7 +32,7 @@ module.exports = function(ctx, helpers) {
// Fetch libraries
const libRes = await ctx.fetchT(`${plexUrl}/library/sections`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
if (!libRes.ok) {
@@ -45,7 +45,7 @@ module.exports = function(ctx, helpers) {
title: dir.title,
type: dir.type,
count: parseInt(dir.count) || 0,
scannedAt: dir.scannedAt,
scannedAt: dir.scannedAt
}));
// Get server name
@@ -54,7 +54,7 @@ module.exports = function(ctx, helpers) {
try {
const identityRes = await ctx.fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (identityRes.ok) {
const identity = await identityRes.json();
@@ -66,7 +66,7 @@ module.exports = function(ctx, helpers) {
// Store token for future use
await ctx.credentialManager.store('arr.plex.token', plexToken, {
service: 'plex', source: 'local', url: plexUrl,
lastVerified: new Date().toISOString(),
lastVerified: new Date().toISOString()
});
res.json({ success: true, serverName, version, libraries });

View File

@@ -44,7 +44,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: `Test ${svc.charAt(0).toUpperCase() + svc.slice(1)} connection`,
status: test.success ? 'success' : 'failed',
details: test.success ? `v${test.version}` : test.error,
details: test.success ? `v${test.version}` : test.error
});
if (test.success) {
@@ -55,12 +55,12 @@ module.exports = function(ctx, helpers) {
const stored = await ctx.credentialManager.store(`arr.${svc}.apikey`, apiKey, {
service: svc, source: 'external', url,
lastVerified: new Date().toISOString(),
version: test.version,
version: test.version
});
steps.push({
step: `Save ${svc} credentials`,
status: stored ? 'success' : 'failed',
details: stored ? 'Encrypted and saved' : 'Storage failed',
details: stored ? 'Encrypted and saved' : 'Storage failed'
});
}
}
@@ -94,7 +94,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: 'Get Overseerr API key',
status: 'failed',
details: 'Could not authenticate with Overseerr (Plex not running or not linked)',
details: 'Could not authenticate with Overseerr (Plex not running or not linked)'
});
} else {
steps.push({ step: 'Get Overseerr API key', status: 'success', details: 'Extracted from container' });
@@ -110,7 +110,7 @@ module.exports = function(ctx, helpers) {
// Fetch quality profiles
const profilesRes = await ctx.fetchT(`${radarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
@@ -118,7 +118,7 @@ module.exports = function(ctx, helpers) {
// Fetch root folders
const rootFoldersRes = await ctx.fetchT(`${radarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.radarr.apiKey },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/movies';
@@ -141,20 +141,20 @@ module.exports = function(ctx, helpers) {
minimumAvailability: 'released',
isDefault: true,
externalUrl: connectedServices.radarr.url,
tags: [],
tags: []
};
const radarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/radarr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(radarrConfig),
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
steps.push({
step: 'Configure Radarr in Overseerr',
status: radarrRes.ok ? 'success' : 'failed',
details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text(),
details: radarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await radarrRes.text()
});
} catch (e) {
steps.push({ step: 'Configure Radarr in Overseerr', status: 'failed', details: e.message });
@@ -170,14 +170,14 @@ module.exports = function(ctx, helpers) {
const profilesRes = await ctx.fetchT(`${sonarrUrl}/api/v3/qualityprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
const profiles = profilesRes.ok ? await profilesRes.json() : [];
const defaultProfile = profiles[0] || { id: 1, name: 'Any' };
const rootFoldersRes = await ctx.fetchT(`${sonarrUrl}/api/v3/rootfolder`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
const rootFolders = rootFoldersRes.ok ? await rootFoldersRes.json() : [];
const defaultRootFolder = rootFolders[0]?.path || '/tv';
@@ -186,7 +186,7 @@ module.exports = function(ctx, helpers) {
try {
const langRes = await ctx.fetchT(`${sonarrUrl}/api/v3/languageprofile`, {
headers: { 'X-Api-Key': connectedServices.sonarr.apiKey },
signal: AbortSignal.timeout(5000),
signal: AbortSignal.timeout(5000)
});
if (langRes.ok) {
const langProfiles = await langRes.json();
@@ -212,20 +212,20 @@ module.exports = function(ctx, helpers) {
isDefault: true,
enableSeasonFolders: true,
externalUrl: connectedServices.sonarr.url,
tags: [],
tags: []
};
const sonarrRes = await ctx.fetchT(`${overseerrUrl}/api/v1/settings/sonarr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': overseerrCookie },
body: JSON.stringify(sonarrConfig),
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000)
});
steps.push({
step: 'Configure Sonarr in Overseerr',
status: sonarrRes.ok ? 'success' : 'failed',
details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text(),
details: sonarrRes.ok ? `Profile: ${defaultProfile.name}, Root: ${defaultRootFolder}` : await sonarrRes.text()
});
} catch (e) {
steps.push({ step: 'Configure Sonarr in Overseerr', status: 'failed', details: e.message });
@@ -239,7 +239,7 @@ module.exports = function(ctx, helpers) {
steps.push({
step: 'Connect Plex to Overseerr',
status: 'success',
details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`,
details: `${plexResult.serverName} - ${plexResult.libraries.length} libraries synced`
});
} catch (e) {
steps.push({ step: 'Connect Plex to Overseerr', status: 'failed', details: e.message });
@@ -259,13 +259,13 @@ module.exports = function(ctx, helpers) {
const prowlarrResults = await helpers.configureProwlarrApps(
connectedServices.prowlarr.url.replace(/\/+$/, ''),
connectedServices.prowlarr.apiKey,
appsToConnect,
appsToConnect
);
for (const [app, status] of Object.entries(prowlarrResults)) {
steps.push({
step: `Add ${app.charAt(0).toUpperCase() + app.slice(1)} to Prowlarr`,
status: status === 'configured' || status === 'already_configured' ? 'success' : 'failed',
details: status,
details: status
});
}
} catch (e) {
@@ -283,14 +283,14 @@ module.exports = function(ctx, helpers) {
'deploymentSuccess',
'Smart Arr Connect Complete',
`${succeeded}/${steps.length} steps completed successfully`,
'success',
'success'
);
}
res.json({
success: succeeded > 0,
steps,
summary: { totalSteps: steps.length, succeeded, failed },
summary: { totalSteps: steps.length, succeeded, failed }
});
}, 'smart-connect'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
y: 365 * 24 * 60 * 60 * 1000,
y: 365 * 24 * 60 * 60 * 1000
};
return value * (multipliers[unit] || multipliers.h);
@@ -54,7 +54,7 @@ module.exports = function(ctx) {
const keyData = await ctx.authManager.generateAPIKey(
name.trim(),
scopes || ['read', 'write'],
scopes || ['read', 'write']
);
res.json({
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
name: keyData.name,
scopes: keyData.scopes,
createdAt: keyData.createdAt,
warning: 'Save this key securely - it will not be shown again',
warning: 'Save this key securely - it will not be shown again'
});
}, 'auth-keys-generate'));
@@ -109,9 +109,9 @@ module.exports = function(ctx) {
const token = await ctx.authManager.generateJWT(
{
sub: userId || 'dashcaddy-admin',
scope: ['admin'], // Session-generated JWTs have admin scope
scope: ['admin'] // Session-generated JWTs have admin scope
},
expiresIn || '24h',
expiresIn || '24h'
);
// Calculate expiration timestamp
@@ -122,7 +122,7 @@ module.exports = function(ctx) {
success: true,
token,
expiresAt,
usage: 'Include in Authorization header as: Bearer <token>',
usage: 'Include in Authorization header as: Bearer <token>'
});
}, 'auth-jwt-generate'));

View File

@@ -29,7 +29,7 @@ module.exports = function(ctx) {
const { spawnSync } = require('child_process');
const proc = spawnSync('wget', [
'-q', '-S', `--post-data=${routerBody}`, '-O', '/dev/null',
`${baseUrl}/cgi-bin/login.ha`,
`${baseUrl}/cgi-bin/login.ha`
], { timeout: 5000, encoding: 'utf8' });
const result = (proc.stderr || '').split('\n').slice(0, 2).join('\n');
const locationMatch = result.match(/Location:\s*(.+)/);

View File

@@ -10,8 +10,8 @@ module.exports = function(ctx) {
config: {
enabled: ctx.totpConfig.enabled,
sessionDuration: ctx.totpConfig.sessionDuration,
isSetUp: ctx.totpConfig.isSetUp,
},
isSetUp: ctx.totpConfig.isSetUp
}
});
}, 'totp-config-get'));
@@ -35,7 +35,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2,
color: { dark: '#ffffff', light: '#00000000' },
color: { dark: '#ffffff', light: '#00000000' }
});
res.json({ success: true, qrCode: qrDataUrl, manualKey: secret, issuer: 'DashCaddy', imported: !!req.body?.secret });
@@ -166,7 +166,7 @@ module.exports = function(ctx) {
if (sessionDuration && !ctx.session.durations.hasOwnProperty(sessionDuration)) {
return ctx.errorResponse(res, 400, 'Invalid session duration', {
validOptions: Object.keys(ctx.session.durations),
validOptions: Object.keys(ctx.session.durations)
});
}
@@ -180,7 +180,7 @@ module.exports = function(ctx) {
await ctx.saveTotpConfig();
res.json({
success: true,
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp },
config: { enabled: ctx.totpConfig.enabled, sessionDuration: ctx.totpConfig.sessionDuration, isSetUp: ctx.totpConfig.isSetUp }
});
}, 'totp-config'));

View File

@@ -1,32 +1,36 @@
const express = require('express');
const asyncHandler = require('../src/utils/async-handler');
module.exports = function({ backupManager }) {
module.exports = function(ctx) {
const router = express.Router();
router.get('/backups/config', asyncHandler(async (req, res) => {
const config = backupManager.getConfig();
// Get backup configuration
router.get('/backups/config', ctx.asyncHandler(async (req, res) => {
const config = ctx.backupManager.getConfig();
res.json({ success: true, config });
}, 'backups-config-get'));
router.post('/backups/config', asyncHandler(async (req, res) => {
backupManager.updateConfig(req.body);
// Update backup configuration
router.post('/backups/config', ctx.asyncHandler(async (req, res) => {
ctx.backupManager.updateConfig(req.body);
res.json({ success: true, message: 'Backup configuration updated' });
}, 'backups-config-update'));
router.post('/backups/execute', asyncHandler(async (req, res) => {
const backup = await backupManager.executeBackup('manual', req.body);
// Execute manual backup
router.post('/backups/execute', ctx.asyncHandler(async (req, res) => {
const backup = await ctx.backupManager.executeBackup('manual', req.body);
res.json({ success: true, backup });
}, 'backups-execute'));
router.get('/backups/history', asyncHandler(async (req, res) => {
// Get backup history
router.get('/backups/history', ctx.asyncHandler(async (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const history = backupManager.getHistory(limit);
const history = ctx.backupManager.getHistory(limit);
res.json({ success: true, history });
}, 'backups-history'));
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
// Restore from backup
router.post('/backups/restore/:backupId', ctx.asyncHandler(async (req, res) => {
const result = await ctx.backupManager.restoreBackup(req.params.backupId, req.body);
res.json({ success: true, result });
}, 'backups-restore'));

View File

@@ -24,7 +24,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath,
path: r.hostPath,
containerPath: r.containerPath,
containerPath: r.containerPath
}));
const roots = [];
@@ -45,7 +45,7 @@ module.exports = function(ctx) {
const allRoots = BROWSE_ROOTS.map(r => ({
name: r.hostPath,
path: r.hostPath,
type: 'drive',
type: 'drive'
}));
const roots = [];
for (const r of allRoots) {
@@ -58,12 +58,12 @@ module.exports = function(ctx) {
}
const matchingRoot = BROWSE_ROOTS.find(r =>
requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, ''),
requestedPath.startsWith(r.hostPath) || requestedPath === r.hostPath.replace(/\/$/, '')
);
if (!matchingRoot) {
return ctx.errorResponse(res, 400, 'Path not in browseable roots', {
availableRoots: BROWSE_ROOTS.map(r => r.hostPath),
availableRoots: BROWSE_ROOTS.map(r => r.hostPath)
});
}
@@ -80,7 +80,7 @@ module.exports = function(ctx) {
requestedPath, containerFullPath, allowedRoots,
error: error.message,
ip: req.ip,
userAgent: req.get('user-agent'),
userAgent: req.get('user-agent')
});
return ctx.errorResponse(res, 403, 'Access denied - path traversal detected');
}
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
.map(entry => ({
name: entry.name,
path: path.join(requestedPath, entry.name).replace(/\\/g, '/'),
type: 'folder',
type: 'folder'
}))
.sort((a, b) => a.name.localeCompare(b.name));
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
path: requestedPath,
parent: path.dirname(requestedPath).replace(/\\/g, '/') || null,
items: result.data,
...(result.pagination && { pagination: result.pagination }),
...(result.pagination && { pagination: result.pagination })
});
}, 'browse-dir'));
@@ -128,12 +128,12 @@ module.exports = function(ctx) {
const mediaServerPatterns = [
'plex', 'jellyfin', 'emby', 'kodi', 'navidrome', 'airsonic',
'subsonic', 'funkwhale', 'beets', 'lidarr', 'sonarr', 'radarr',
'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli',
'bazarr', 'readarr', 'prowlarr', 'overseerr', 'ombi', 'tautulli'
];
const excludePatterns = [
'/config', '/cache', '/transcode', '/data/config', '/app',
'/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile',
'/tmp', '/var', '/etc', '/opt', '/root', '/home', '/.', '/caddyfile'
];
const containers = await ctx.docker.client.listContainers({ all: false });
@@ -155,7 +155,7 @@ module.exports = function(ctx) {
let hostPath, containerPath;
if (parts[0].length === 1 && /[A-Za-z]/.test(parts[0])) {
hostPath = `${parts[0] }:${ parts[1]}`;
hostPath = parts[0] + ':' + parts[1];
containerPath = parts[2] || '';
} else {
hostPath = parts[0];
@@ -164,7 +164,7 @@ module.exports = function(ctx) {
const isExcluded = excludePatterns.some(p =>
containerPath.toLowerCase().includes(p.toLowerCase()) ||
hostPath.toLowerCase().includes(p.toLowerCase()),
hostPath.toLowerCase().includes(p.toLowerCase())
);
if (isExcluded) continue;
if (seenPaths.has(hostPath)) continue;
@@ -175,7 +175,7 @@ module.exports = function(ctx) {
detectedMounts.push({
hostPath, containerPath, folderName,
sourceContainer: containerInfo.Names[0]?.replace('/', '') || containerInfo.Id.slice(0, 12),
sourceImage: containerInfo.Image.split('/').pop().split(':')[0],
sourceImage: containerInfo.Image.split('/').pop().split(':')[0]
});
}
}
@@ -185,7 +185,7 @@ module.exports = function(ctx) {
mounts: detectedMounts,
message: detectedMounts.length > 0
? `Found ${detectedMounts.length} media mount(s) from existing containers`
: 'No existing media mounts detected',
: 'No existing media mounts detected'
});
}, 'detect-media-mounts'));

View File

@@ -25,22 +25,22 @@ module.exports = function(ctx) {
}
const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8'));
const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
const expirationDate = new Date(certInfo.validUntil);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
res.json({
success: true,
certificate: {
name: certInfo.name,
fingerprint: certInfo.fingerprint,
validFrom: certInfo.validFrom,
validUntil: certInfo.validUntil,
daysUntilExpiration,
algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256',
serialNumber: certInfo.serialNumber,
downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`,
},
});
res.json({
success: true,
certificate: {
name: certInfo.name,
fingerprint: certInfo.fingerprint,
validFrom: certInfo.validFrom,
validUntil: certInfo.validUntil,
daysUntilExpiration,
algorithm: certInfo.algorithm || 'ECDSA P-256 with SHA-256',
serialNumber: certInfo.serialNumber,
downloadUrl: `https://ca${ctx.siteConfig.tld}/root.crt`
}
});
}, 'ca-info'));
// Serve root CA certificate directly (works even without DashCA deployed)
@@ -99,7 +99,7 @@ module.exports = function(ctx) {
// Look for template in multiple locations (packaged app vs dev)
const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName),
path.join('/app', 'scripts', templateName)
];
let templateContent;
@@ -208,12 +208,12 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const serverCertContent = await fsp.readFile(certFile, 'utf8');
const intermediateCertContent = await fsp.readFile(intermediateCert, 'utf8');
const rootCertContent = await fsp.readFile(rootCert, 'utf8');
await fsp.writeFile(fullChainFile, `${serverCertContent }\n${ intermediateCertContent }\n${ rootCertContent}`);
await fsp.writeFile(fullChainFile, serverCertContent + '\n' + intermediateCertContent + '\n' + rootCertContent);
execSync(`openssl pkcs12 -export -out "${pfxFile}" -inkey "${keyFile}" -in "${certFile}" -certfile "${intermediateCert}" -password "pass:${password}"`, { stdio: 'pipe' });
const keyContent = await fsp.readFile(keyFile, 'utf8');
await fsp.writeFile(pemFile, `${keyContent }\n${ serverCertContent }\n${ intermediateCertContent}`);
await fsp.writeFile(pemFile, keyContent + '\n' + serverCertContent + '\n' + intermediateCertContent);
}
if (format === 'pfx') {
@@ -260,26 +260,26 @@ ${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const certFile = path.join(certsDir, domain, 'server.crt');
if (!await exists(certFile)) return null;
try {
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
try {
const certInfo = execSync(`openssl x509 -in "${certFile}" -noout -subject -dates -fingerprint -sha256`).toString();
const subject = certInfo.match(/subject=(.*)/) ? certInfo.match(/subject=(.*)/)[1].trim() : domain;
const notBefore = certInfo.match(/notBefore=(.*)/) ? certInfo.match(/notBefore=(.*)/)[1].trim() : '';
const notAfter = certInfo.match(/notAfter=(.*)/) ? certInfo.match(/notAfter=(.*)/)[1].trim() : '';
const fingerprint = certInfo.match(/Fingerprint=(.*)/) ? certInfo.match(/Fingerprint=(.*)/)[1].trim() : '';
const expirationDate = new Date(notAfter);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
const expirationDate = new Date(notAfter);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
return {
domain, subject,
validFrom: notBefore, validUntil: notAfter,
daysUntilExpiration, fingerprint,
status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid',
};
} catch {
return null;
}
}))).filter(Boolean);
return {
domain, subject,
validFrom: notBefore, validUntil: notAfter,
daysUntilExpiration, fingerprint,
status: daysUntilExpiration < 0 ? 'expired' : daysUntilExpiration < 30 ? 'expiring-soon' : 'valid'
};
} catch {
return null;
}
}))).filter(Boolean);
res.json({ success: true, certificates });
}, 'ca-certs'));

View File

@@ -56,7 +56,7 @@ module.exports = function(ctx) {
res.json({
success: true,
path: `/assets/${safeFilename}`,
message: `Logo saved to ${filePath}`,
message: `Logo saved to ${filePath}`
});
}, 'assets-upload'));
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
customLogo: config.customLogo || config.customLogoDark || null,
position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy',
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo,
isDefault: !config.customLogoDark && !config.customLogoLight && !config.customLogo
});
}, 'logo-get'));
@@ -153,7 +153,7 @@ module.exports = function(ctx) {
path: pathDark || pathLight,
position: config.logoPosition || 'left',
dashboardTitle: config.dashboardTitle || 'DashCaddy',
message: 'Branding settings saved',
message: 'Branding settings saved'
});
}, 'logo-upload'));
@@ -186,7 +186,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Branding reset to defaults',
message: 'Branding reset to defaults'
});
}, 'logo-delete'));
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
res.json({
success: true,
customFavicon: config.customFavicon || null,
isDefault: !config.customFavicon,
isDefault: !config.customFavicon
});
}, 'favicon-get'));
@@ -237,8 +237,8 @@ module.exports = function(ctx) {
sharp(buffer)
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer(),
),
.toBuffer()
)
);
// Convert to ICO
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({
success: true,
path: '/assets/favicon.ico',
message: 'Favicon created successfully',
message: 'Favicon created successfully'
});
}, 'favicon'));
@@ -285,7 +285,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Favicon reset to default',
message: 'Favicon reset to default'
});
}, 'favicon-delete'));

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
dashcaddyVersion: '1.0.0',
files: {},
themes: {},
assets: {},
assets: {}
};
// Collect all configuration files (encryption key now included for self-contained restore)
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
{ key: 'encryptionKey', path: ENCRYPTION_KEY_FILE, required: false },
{ key: 'totpConfig', path: ctx.TOTP_CONFIG_FILE, required: false },
{ key: 'tailscaleConfig', path: ctx.TAILSCALE_CONFIG_FILE, required: false },
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false },
{ key: 'notifications', path: ctx.NOTIFICATIONS_FILE, required: false }
];
for (const file of filesToBackup) {
@@ -59,12 +59,12 @@ module.exports = function(ctx) {
try {
backup.files[file.key] = {
type: 'json',
data: JSON.parse(content),
data: JSON.parse(content)
};
} catch {
backup.files[file.key] = {
type: 'text',
data: content,
data: content
};
}
} else if (file.required) {
@@ -85,7 +85,7 @@ module.exports = function(ctx) {
const otpauth = authenticator.keyuri('user', 'DashCaddy', secret);
const qrDataUrl = await QRCode.toDataURL(otpauth, {
width: 256, margin: 2,
color: { dark: '#000000', light: '#ffffff' },
color: { dark: '#000000', light: '#ffffff' }
});
backup.totp = { qrCode: qrDataUrl, issuer: 'DashCaddy' };
}
@@ -140,7 +140,7 @@ module.exports = function(ctx) {
valid: true,
version: backup.version,
exportedAt: backup.exportedAt,
files: {},
files: {}
};
// Check each file in the backup
@@ -154,7 +154,7 @@ module.exports = function(ctx) {
encryptionKey: { path: ENCRYPTION_KEY_FILE, description: 'Encryption key (for credentials)' },
totpConfig: { path: ctx.TOTP_CONFIG_FILE, description: 'TOTP authentication config' },
tailscaleConfig: { path: ctx.TAILSCALE_CONFIG_FILE, description: 'Tailscale config' },
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' },
notifications: { path: ctx.NOTIFICATIONS_FILE, description: 'Notification settings' }
};
for (const [key, value] of Object.entries(backup.files)) {
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
inBackup: true,
currentExists,
action: currentExists ? 'overwrite' : 'create',
type: value.type,
type: value.type
};
}
}
@@ -204,7 +204,7 @@ module.exports = function(ctx) {
// Require TOTP verification for restores that include security-sensitive files
const sensitiveKeys = ['credentials', 'totpConfig', 'encryptionKey'];
const restoresSensitive = sensitiveKeys.some(key =>
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key),
backup.files[key] && backup.files[key].type !== 'missing' && !(options.skip || []).includes(key)
);
if (restoresSensitive && ctx.totpConfig.enabled && ctx.totpConfig.isSetUp) {
if (!totpCode || !/^\d{6}$/.test(totpCode)) {
@@ -223,7 +223,7 @@ module.exports = function(ctx) {
const results = {
restored: [],
skipped: [],
errors: [],
errors: []
};
const ENCRYPTION_KEY_FILE = process.env.ENCRYPTION_KEY_FILE || path.join(path.dirname(ctx.SERVICES_FILE), '.encryption-key');
@@ -236,7 +236,7 @@ module.exports = function(ctx) {
encryptionKey: ENCRYPTION_KEY_FILE,
totpConfig: ctx.TOTP_CONFIG_FILE,
tailscaleConfig: ctx.TAILSCALE_CONFIG_FILE,
notifications: ctx.NOTIFICATIONS_FILE,
notifications: ctx.NOTIFICATIONS_FILE
};
// Restore each file
@@ -286,7 +286,7 @@ module.exports = function(ctx) {
const loadResponse = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyContent,
body: caddyContent
});
if (loadResponse.ok) {
@@ -345,7 +345,7 @@ module.exports = function(ctx) {
if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true });
for (const [slug, data] of Object.entries(backup.themes)) {
if (/^[a-z0-9-]+$/.test(slug)) {
fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(data, null, 2), 'utf8');
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(data, null, 2), 'utf8');
}
}
results.restored.push(`themes:${Object.keys(backup.themes).length}`);
@@ -376,7 +376,7 @@ module.exports = function(ctx) {
message: success
? `Restored ${results.restored.length} file(s) successfully`
: `Restore completed with ${results.errors.length} error(s)`,
results,
results
});
ctx.log.info('backup', 'Backup restore completed', { restored: results.restored.length, errors: results.errors.length });

View File

@@ -1,21 +1,14 @@
/**
* Container management routes
* Refactored to use explicit dependencies instead of ctx god object
*/
const express = require('express');
const { DOCKER } = require('../constants');
const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors');
const asyncHandler = require('../src/utils/async-handler');
const log = require('../src/utils/logger');
module.exports = function({ docker }) {
module.exports = function(ctx) {
const router = express.Router();
// Helper: verify container exists before operating on it
async function getVerifiedContainer(id) {
const container = docker.client.getContainer(id);
const container = ctx.docker.client.getContainer(id);
try {
await container.inspect();
} catch (err) {
@@ -28,143 +21,138 @@ module.exports = function({ docker }) {
}
// Start container
router.post('/:id/start', asyncHandler(async (req, res) => {
router.post('/:id/start', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.start();
res.json({ success: true, message: 'Container started' });
}, 'container-start'));
// Stop container
router.post('/:id/stop', asyncHandler(async (req, res) => {
router.post('/:id/stop', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.stop();
res.json({ success: true, message: 'Container stopped' });
}, 'container-stop'));
// Restart container
router.post('/:id/restart', asyncHandler(async (req, res) => {
router.post('/:id/restart', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.restart();
res.json({ success: true, message: 'Container restarted' });
}, 'container-restart'));
// Update container to latest image version
router.post('/:id/update', asyncHandler(async (req, res) => {
router.post('/:id/update', ctx.asyncHandler(async (req, res) => {
const containerId = req.params.id;
const container = await getVerifiedContainer(containerId);
// Get container info
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const containerName = containerInfo.Name.replace(/^\//, '');
// Get container info
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const containerName = containerInfo.Name.replace(/^\//, '');
log.info('docker', 'Updating container', { containerName, imageName });
ctx.log.info('docker', 'Updating container', { containerName, imageName });
// Pull the latest image
log.info('docker', `Pulling latest image: ${imageName}`);
await docker.pull(imageName);
// Pull the latest image
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
await ctx.docker.pull(imageName);
// Get current container config for recreation
const hostConfig = containerInfo.HostConfig;
const config = {
Image: imageName,
name: containerName,
Env: containerInfo.Config.Env,
ExposedPorts: containerInfo.Config.ExposedPorts,
Labels: containerInfo.Config.Labels,
HostConfig: {
Binds: hostConfig.Binds,
PortBindings: hostConfig.PortBindings,
RestartPolicy: hostConfig.RestartPolicy,
NetworkMode: hostConfig.NetworkMode,
ExtraHosts: hostConfig.ExtraHosts,
Privileged: hostConfig.Privileged,
CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG,
},
NetworkingConfig: {},
};
// Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName],
// Get current container config for recreation
const hostConfig = containerInfo.HostConfig;
const config = {
Image: imageName,
name: containerName,
Env: containerInfo.Config.Env,
ExposedPorts: containerInfo.Config.ExposedPorts,
Labels: containerInfo.Config.Labels,
HostConfig: {
Binds: hostConfig.Binds,
PortBindings: hostConfig.PortBindings,
RestartPolicy: hostConfig.RestartPolicy,
NetworkMode: hostConfig.NetworkMode,
ExtraHosts: hostConfig.ExtraHosts,
Privileged: hostConfig.Privileged,
CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG // Ensure log rotation on updated containers
},
NetworkingConfig: {}
};
}
// Stop and remove old container
log.info('docker', 'Stopping container', { containerName });
await container.stop().catch(() => {}); // Ignore if already stopped
log.info('docker', 'Removing container', { containerName });
await container.remove();
// Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName]
};
}
// Wait for port release
await new Promise((r) => setTimeout(r, 3000));
// Stop and remove old container
ctx.log.info('docker', 'Stopping container', { containerName });
await container.stop().catch(() => {}); // Ignore if already stopped
ctx.log.info('docker', 'Removing container', { containerName });
await container.remove();
// Create and start new container
log.info('docker', 'Creating new container', { containerName });
let newContainer;
try {
newContainer = await docker.client.createContainer(config);
log.info('docker', 'Starting container', { containerName });
await newContainer.start();
} catch (startError) {
log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
if (newContainer) {
try {
await newContainer.remove({ force: true });
} catch (e) {
/* already gone */
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
await new Promise(r => setTimeout(r, 3000));
// Create and start new container
ctx.log.info('docker', 'Creating new container', { containerName });
let newContainer;
try {
newContainer = await ctx.docker.client.createContainer(config);
ctx.log.info('docker', 'Starting container', { containerName });
await newContainer.start();
} catch (startError) {
// Clean up the failed container so it doesn't block future attempts
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
if (newContainer) {
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
}
throw startError;
}
throw startError;
}
const newContainerInfo = await newContainer.inspect();
const newContainerInfo = await newContainer.inspect();
// Prune dangling images
try {
const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
log.info('docker', 'Pruned dangling images after update', {
spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024)}MB`,
});
// Prune dangling images after update
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) + 'MB' });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
}
} catch (pruneErr) {
log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
}
res.json({
success: true,
message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id,
});
res.json({
success: true,
message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id
});
}, 'container-update'));
// Check for available updates
router.get('/:id/check-update', asyncHandler(async (req, res) => {
// Check for available updates (compares local and remote image digests)
router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => {
const containerId = req.params.id;
const container = await getVerifiedContainer(containerId);
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const localImage = docker.client.getImage(containerInfo.Image);
const localImage = ctx.docker.client.getImage(containerInfo.Image);
const localImageInfo = await localImage.inspect();
const localDigest = localImageInfo.RepoDigests?.[0] || null;
let updateAvailable = false;
try {
const pullStream = await docker.pull(imageName);
const pullStream = await ctx.docker.pull(imageName);
const downloadedLayers = pullStream.filter(
(e) => e.status === 'Downloading' || e.status === 'Download complete',
const downloadedLayers = pullStream.filter(e =>
e.status === 'Downloading' || e.status === 'Download complete'
);
updateAvailable = downloadedLayers.length > 0;
const newImage = docker.client.getImage(imageName);
const newImage = ctx.docker.client.getImage(imageName);
const newImageInfo = await newImage.inspect();
const newDigest = newImageInfo.RepoDigests?.[0] || null;
@@ -172,44 +160,44 @@ module.exports = function({ docker }) {
updateAvailable = true;
}
} catch (pullError) {
log.debug('docker', 'Could not check for updates', { error: pullError.message });
ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message });
}
res.json({
success: true,
imageName,
updateAvailable,
currentDigest: localDigest,
currentDigest: localDigest
});
}, 'container-check-update'));
// Get container logs
router.get('/:id/logs', asyncHandler(async (req, res) => {
router.get('/:id/logs', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
const logs = await container.logs({
stdout: true,
stderr: true,
tail: 100,
timestamps: true,
timestamps: true
});
res.json({ success: true, logs: logs.toString() });
}, 'container-logs'));
// Delete container
router.delete('/:id', asyncHandler(async (req, res) => {
router.delete('/:id', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.remove({ force: true });
res.json({ success: true, message: 'Container removed' });
}, 'container-delete'));
// Discover running containers
router.get('/discover', asyncHandler(async (req, res) => {
const containers = await docker.client.listContainers({ all: true });
const samiContainers = containers.filter(
(container) => container.Labels && container.Labels['sami.managed'] === 'true',
router.get('/discover', ctx.asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: true });
const samiContainers = containers.filter(container =>
container.Labels && container.Labels['sami.managed'] === 'true'
);
const discoveredContainers = samiContainers.map((container) => ({
const discoveredContainers = samiContainers.map(container => ({
id: container.Id,
name: container.Names[0].replace('/', ''),
image: container.Image,
@@ -217,16 +205,12 @@ module.exports = function({ docker }) {
status: container.Status,
appTemplate: container.Labels['sami.app'],
subdomain: container.Labels['sami.subdomain'],
ports: container.Ports,
ports: container.Ports
}));
const paginationParams = parsePaginationParams(req.query);
const result = paginate(discoveredContainers, paginationParams);
res.json({
success: true,
containers: result.data,
...(result.pagination && { pagination: result.pagination }),
});
res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'containers-discover'));
return router;

View File

@@ -1,217 +0,0 @@
const express = require('express');
const { DOCKER } = require('../constants');
const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors');
module.exports = function(ctx) {
const router = express.Router();
// Helper: verify container exists before operating on it
async function getVerifiedContainer(id) {
const container = ctx.docker.client.getContainer(id);
try {
await container.inspect();
} catch (err) {
if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) {
throw new NotFoundError(`Container ${id}`);
}
throw err;
}
return container;
}
// Start container
router.post('/:id/start', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.start();
res.json({ success: true, message: 'Container started' });
}, 'container-start'));
// Stop container
router.post('/:id/stop', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.stop();
res.json({ success: true, message: 'Container stopped' });
}, 'container-stop'));
// Restart container
router.post('/:id/restart', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.restart();
res.json({ success: true, message: 'Container restarted' });
}, 'container-restart'));
// Update container to latest image version
router.post('/:id/update', ctx.asyncHandler(async (req, res) => {
const containerId = req.params.id;
const container = await getVerifiedContainer(containerId);
// Get container info
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const containerName = containerInfo.Name.replace(/^\//, '');
ctx.log.info('docker', 'Updating container', { containerName, imageName });
// Pull the latest image
ctx.log.info('docker', `Pulling latest image: ${imageName}`);
await ctx.docker.pull(imageName);
// Get current container config for recreation
const hostConfig = containerInfo.HostConfig;
const config = {
Image: imageName,
name: containerName,
Env: containerInfo.Config.Env,
ExposedPorts: containerInfo.Config.ExposedPorts,
Labels: containerInfo.Config.Labels,
HostConfig: {
Binds: hostConfig.Binds,
PortBindings: hostConfig.PortBindings,
RestartPolicy: hostConfig.RestartPolicy,
NetworkMode: hostConfig.NetworkMode,
ExtraHosts: hostConfig.ExtraHosts,
Privileged: hostConfig.Privileged,
CapAdd: hostConfig.CapAdd,
CapDrop: hostConfig.CapDrop,
Devices: hostConfig.Devices,
LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers
},
NetworkingConfig: {},
};
// Get network settings if using a custom network
if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) {
const networkName = hostConfig.NetworkMode;
config.NetworkingConfig.EndpointsConfig = {
[networkName]: containerInfo.NetworkSettings.Networks[networkName],
};
}
// Stop and remove old container
ctx.log.info('docker', 'Stopping container', { containerName });
await container.stop().catch(() => {}); // Ignore if already stopped
ctx.log.info('docker', 'Removing container', { containerName });
await container.remove();
// Wait for port release (Windows/Docker Desktop can be slow to free ports)
await new Promise(r => setTimeout(r, 3000));
// Create and start new container
ctx.log.info('docker', 'Creating new container', { containerName });
let newContainer;
try {
newContainer = await ctx.docker.client.createContainer(config);
ctx.log.info('docker', 'Starting container', { containerName });
await newContainer.start();
} catch (startError) {
// Clean up the failed container so it doesn't block future attempts
ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message });
if (newContainer) {
try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ }
}
throw startError;
}
const newContainerInfo = await newContainer.inspect();
// Prune dangling images after update
try {
const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } });
if (pruneResult.SpaceReclaimed > 0) {
ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` });
}
} catch (pruneErr) {
ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message });
}
res.json({
success: true,
message: `Container ${containerName} updated successfully`,
newContainerId: newContainerInfo.Id,
});
}, 'container-update'));
// Check for available updates (compares local and remote image digests)
router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => {
const containerId = req.params.id;
const container = await getVerifiedContainer(containerId);
const containerInfo = await container.inspect();
const imageName = containerInfo.Config.Image;
const localImage = ctx.docker.client.getImage(containerInfo.Image);
const localImageInfo = await localImage.inspect();
const localDigest = localImageInfo.RepoDigests?.[0] || null;
let updateAvailable = false;
try {
const pullStream = await ctx.docker.pull(imageName);
const downloadedLayers = pullStream.filter(e =>
e.status === 'Downloading' || e.status === 'Download complete',
);
updateAvailable = downloadedLayers.length > 0;
const newImage = ctx.docker.client.getImage(imageName);
const newImageInfo = await newImage.inspect();
const newDigest = newImageInfo.RepoDigests?.[0] || null;
if (localDigest && newDigest && localDigest !== newDigest) {
updateAvailable = true;
}
} catch (pullError) {
ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message });
}
res.json({
success: true,
imageName,
updateAvailable,
currentDigest: localDigest,
});
}, 'container-check-update'));
// Get container logs
router.get('/:id/logs', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
const logs = await container.logs({
stdout: true,
stderr: true,
tail: 100,
timestamps: true,
});
res.json({ success: true, logs: logs.toString() });
}, 'container-logs'));
// Delete container
router.delete('/:id', ctx.asyncHandler(async (req, res) => {
const container = await getVerifiedContainer(req.params.id);
await container.remove({ force: true });
res.json({ success: true, message: 'Container removed' });
}, 'container-delete'));
// Discover running containers
router.get('/discover', ctx.asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: true });
const samiContainers = containers.filter(container =>
container.Labels && container.Labels['sami.managed'] === 'true',
);
const discoveredContainers = samiContainers.map(container => ({
id: container.Id,
name: container.Names[0].replace('/', ''),
image: container.Image,
state: container.State,
status: container.Status,
appTemplate: container.Labels['sami.app'],
subdomain: container.Labels['sami.subdomain'],
ports: container.Ports,
}));
const paginationParams = parsePaginationParams(req.query);
const result = paginate(discoveredContainers, paginationParams);
res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'containers-discover'));
return router;
};

View File

@@ -1,21 +1,21 @@
const express = require('express');
const asyncHandler = require('../src/utils/async-handler');
const { errorResponse } = require('../src/utils/responses');
module.exports = function({ credentialManager }) {
module.exports = function(ctx) {
const router = express.Router();
router.get('/credentials/list', asyncHandler(async (req, res) => {
const keys = await credentialManager.list();
// List all stored credentials (keys only, no values)
router.get('/credentials/list', ctx.asyncHandler(async (req, res) => {
const keys = await ctx.credentialManager.list();
res.json({ success: true, credentials: keys, count: keys.length });
}, 'credentials-list'));
router.post('/credentials/rotate-key', asyncHandler(async (req, res) => {
const success = await credentialManager.rotateEncryptionKey();
// Rotate encryption key — re-encrypts all stored credentials
router.post('/credentials/rotate-key', ctx.asyncHandler(async (req, res) => {
const success = await ctx.credentialManager.rotateEncryptionKey();
if (success) {
res.json({ success: true, message: 'Encryption key rotated, all credentials re-encrypted' });
} else {
errorResponse(res, 500, 'Key rotation failed');
ctx.errorResponse(res, 500, 'Key rotation failed');
}
}, 'credentials-rotate'));

View File

@@ -113,7 +113,7 @@ module.exports = function(ctx) {
const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, '');
const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', {
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true',
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true'
});
if (result.status === 'ok') {
@@ -151,7 +151,7 @@ module.exports = function(ctx) {
try {
const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', {
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true',
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true'
});
if (result.status === 'ok' && result.response && result.response.records) {
@@ -218,7 +218,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(technitiumUrl, {
method: 'GET',
headers: { 'Accept': 'text/plain' },
timeout: 10000,
timeout: 10000
});
if (!response.ok) {
@@ -232,7 +232,7 @@ module.exports = function(ctx) {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server',
message: 'No logs available for this server'
});
}
return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText));
@@ -255,7 +255,7 @@ module.exports = function(ctx) {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server',
message: 'No logs available for this server'
});
}
// Invalidate cached token on auth errors so next request re-authenticates
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
class: match[6].trim(),
rcode: match[7].trim(),
answer: match[8].trim() || null,
raw: line,
raw: line
};
}
return { raw: line, parsed: false };
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
server: server,
logFile: logFileName,
count: parsedLogs.length,
logs: parsedLogs,
logs: parsedLogs
});
} catch (error) {
@@ -319,7 +319,7 @@ module.exports = function(ctx) {
hasCredentials,
hasToken,
tokenExpiry: ctx.dns.getTokenExpiry(),
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null,
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null
});
}, 'dns-token-status'));
@@ -394,7 +394,7 @@ module.exports = function(ctx) {
return res.json({
success: anySuccess,
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results,
results
});
}
@@ -430,7 +430,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'DNS credentials saved and verified (encrypted)',
tokenExpiry: ctx.dns.getTokenExpiry(),
tokenExpiry: ctx.dns.getTokenExpiry()
});
}, 'dns-credentials'));
@@ -495,7 +495,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Token refreshed successfully',
tokenExpiry: ctx.dns.getTokenExpiry(),
tokenExpiry: ctx.dns.getTokenExpiry()
});
} else {
ctx.errorResponse(res, 401, result.error);
@@ -529,8 +529,8 @@ module.exports = function(ctx) {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': APP.USER_AGENTS.API,
},
'User-Agent': APP.USER_AGENTS.API
}
});
const text = await response.text();
@@ -550,7 +550,7 @@ module.exports = function(ctx) {
updateTitle: result.response.updateTitle || null,
updateMessage: result.response.updateMessage || null,
downloadLink: result.response.downloadLink || null,
instructionsLink: result.response.instructionsLink || null,
instructionsLink: result.response.instructionsLink || null
});
} else {
ctx.errorResponse(res, 500, result.errorMessage || 'Check failed');
@@ -586,7 +586,7 @@ module.exports = function(ctx) {
// Check if update is available
const checkResponse = await ctx.fetchT(
`http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`,
{ method: 'GET', headers: { 'Accept': 'application/json' } },
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
const checkText = await checkResponse.text();
@@ -604,7 +604,7 @@ module.exports = function(ctx) {
success: true,
message: 'Already up to date',
currentVersion: checkResult.response.currentVersion,
updated: false,
updated: false
});
}
@@ -620,7 +620,7 @@ module.exports = function(ctx) {
downloadLink: checkResult.response.downloadLink || null,
instructionsLink: checkResult.response.instructionsLink || null,
updated: false,
manualUpdateRequired: true,
manualUpdateRequired: true
});
} catch (error) {
ctx.log.error('dns', 'DNS update error', { error: error.message });

View File

@@ -14,22 +14,22 @@ module.exports = function(ctx) {
}
const logContent = await fsp.readFile(ctx.ERROR_LOG_FILE, 'utf8');
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
const logEntries = logContent.split('='.repeat(80)).filter(entry => entry.trim());
const logs = logEntries.map(entry => {
const lines = entry.trim().split('\n');
const firstLine = lines[0] || '';
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
const logs = logEntries.map(entry => {
const lines = entry.trim().split('\n');
const firstLine = lines[0] || '';
const match = firstLine.match(/\[(.*?)\] (.*?): (.*)/);
if (match) {
return {
timestamp: match[1],
context: match[2],
error: match[3],
};
}
return null;
}).filter(Boolean);
if (match) {
return {
timestamp: match[1],
context: match[2],
error: match[3]
};
}
return null;
}).filter(Boolean);
res.json({ success: true, logs: logs.slice(-50).reverse() });
}, 'error-logs-get'));

View File

@@ -34,7 +34,7 @@ module.exports = function(ctx) {
try {
let url = null;
const checkType = 'http';
let checkType = 'http';
// Determine URL to check
url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl);
@@ -52,7 +52,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
redirect: 'follow'
});
clearTimeout(timeout);
@@ -60,7 +60,7 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status,
url,
checkedAt: new Date().toISOString(),
checkedAt: new Date().toISOString()
};
} catch (fetchError) {
clearTimeout(timeout);
@@ -73,7 +73,7 @@ module.exports = function(ctx) {
const getResponse = await ctx.fetchT(url, {
method: 'GET',
signal: getController.signal,
redirect: 'follow',
redirect: 'follow'
});
clearTimeout(getTimeout);
@@ -81,14 +81,14 @@ module.exports = function(ctx) {
status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy',
statusCode: getResponse.status,
url,
checkedAt: new Date().toISOString(),
checkedAt: new Date().toISOString()
};
} catch (e) {
health[serviceId] = {
status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url,
checkedAt: new Date().toISOString(),
checkedAt: new Date().toISOString()
};
}
}
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
health[serviceId] = {
status: 'error',
reason: e.message,
checkedAt: new Date().toISOString(),
checkedAt: new Date().toISOString()
};
}
}));
@@ -113,7 +113,7 @@ module.exports = function(ctx) {
success: true,
health: paginatedHealth,
checkedAt: lastHealthCheck,
...(result.pagination && { pagination: result.pagination }),
...(result.pagination && { pagination: result.pagination })
});
}, 'health-services'));
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
success: true,
health: serviceHealthCache,
lastCheck: lastHealthCheck,
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null,
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null
});
}, 'health-cached'));
@@ -157,7 +157,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow',
redirect: 'follow'
});
clearTimeout(timeout);
@@ -168,8 +168,8 @@ module.exports = function(ctx) {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
statusCode: response.status,
url,
checkedAt: new Date().toISOString(),
},
checkedAt: new Date().toISOString()
}
});
} catch (e) {
clearTimeout(timeout);
@@ -180,8 +180,8 @@ module.exports = function(ctx) {
status: 'unhealthy',
reason: e.name === 'AbortError' ? 'Timeout' : e.message,
url,
checkedAt: new Date().toISOString(),
},
checkedAt: new Date().toISOString()
}
});
}
}, 'health-service'));
@@ -201,7 +201,7 @@ module.exports = function(ctx) {
return res.json({
status: 'error',
message: 'Root CA certificate not found',
daysUntilExpiration: null,
daysUntilExpiration: null
});
}
@@ -232,14 +232,14 @@ module.exports = function(ctx) {
status: status,
message: message,
daysUntilExpiration: daysUntilExpiration,
expiresAt: notAfter,
expiresAt: notAfter
});
} catch (error) {
await ctx.logError('GET /api/health/ca', error);
res.json({
status: 'error',
message: error.message,
daysUntilExpiration: null,
daysUntilExpiration: null
});
}
}, 'health-ca'));

View File

@@ -1,44 +1,50 @@
const express = require('express');
const asyncHandler = require('../src/utils/async-handler');
const { errorResponse } = require('../src/utils/responses');
module.exports = function({ licenseManager }) {
module.exports = function(ctx) {
const router = express.Router();
router.post('/activate', asyncHandler(async (req, res) => {
// Activate a license code
router.post('/activate', ctx.asyncHandler(async (req, res) => {
const { code } = req.body;
if (!code) {
return errorResponse(res, 400, 'License code is required');
return ctx.errorResponse(res, 400, 'License code is required');
}
const result = await licenseManager.activate(code);
const result = await ctx.licenseManager.activate(code);
if (result.success) {
res.json({ success: true, message: result.message, license: result.activation });
res.json({
success: true,
message: result.message,
license: result.activation
});
} else {
errorResponse(res, 400, result.message);
ctx.errorResponse(res, 400, result.message);
}
}, 'license-activate'));
router.get('/status', asyncHandler(async (req, res) => {
const status = licenseManager.getStatus();
// Get current license status
router.get('/status', ctx.asyncHandler(async (req, res) => {
const status = ctx.licenseManager.getStatus();
res.json({ success: true, license: status });
}, 'license-status'));
router.post('/deactivate', asyncHandler(async (req, res) => {
const result = await licenseManager.deactivate();
// Deactivate current license
router.post('/deactivate', ctx.asyncHandler(async (req, res) => {
const result = await ctx.licenseManager.deactivate();
if (result.success) {
res.json({ success: true, message: result.message });
} else {
errorResponse(res, 400, result.message);
ctx.errorResponse(res, 400, result.message);
}
}, 'license-deactivate'));
router.get('/feature/:feature', asyncHandler(async (req, res) => {
// Check if a specific feature is available (lightweight check for frontend)
router.get('/feature/:feature', ctx.asyncHandler(async (req, res) => {
const { feature } = req.params;
const available = licenseManager.hasFeature(feature);
const status = licenseManager.getStatus();
const available = ctx.licenseManager.hasFeature(feature);
const status = ctx.licenseManager.getStatus();
res.json({
success: true,
@@ -47,8 +53,8 @@ module.exports = function({ licenseManager }) {
tier: status.tier,
...(available ? {} : {
upgradeUrl: '/settings#license',
message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`,
}),
message: `${status.premiumFeatures[feature]?.name || feature} requires DashCaddy Premium`
})
});
}, 'license-feature-check'));

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
name: c.Names[0]?.replace(/^\//, '') || 'unknown',
image: c.Image,
status: c.State,
created: c.Created,
created: c.Created
}));
const paginationParams = parsePaginationParams(req.query);
@@ -46,7 +46,7 @@ module.exports = function(ctx) {
const logs = await container.logs({
stdout: true, stderr: true,
tail, since, timestamps,
tail, since, timestamps
});
// Parse Docker log stream (demultiplex stdout/stderr)
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
if (line) {
lines.push({
stream: streamType === 2 ? 'stderr' : 'stdout',
text: line,
text: line
});
}
offset += 8 + size;
@@ -75,7 +75,7 @@ module.exports = function(ctx) {
success: true,
containerId, containerName,
logs: lines,
count: lines.length,
count: lines.length
});
}, 'logs-container'));
@@ -100,7 +100,7 @@ module.exports = function(ctx) {
const logStream = await container.logs({
stdout: true, stderr: true,
follow: true, tail: 50, timestamps: true,
follow: true, tail: 50, timestamps: true
});
let buffer = Buffer.alloc(0);
@@ -119,7 +119,7 @@ module.exports = function(ctx) {
const data = JSON.stringify({
stream: streamType === 2 ? 'stderr' : 'stdout',
text: line,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString()
});
res.write(`data: ${data}\n\n`);
}
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const logs = tailLines.map(line => ({
stream: 'stdout',
text: line,
timestamp: extractTimestamp(line),
timestamp: extractTimestamp(line)
}));
res.json({
@@ -256,7 +256,7 @@ module.exports = function(ctx) {
logPath: normalizedPath,
logs,
count: logs.length,
totalLines: lines.length,
totalLines: lines.length
});
}, 'logs-file'));

View File

@@ -1,23 +1,19 @@
/**
* Monitoring and stats routes
* Refactored to use explicit dependencies
*/
const express = require('express');
const asyncHandler = require('../src/utils/async-handler');
module.exports = function({ docker, resourceMonitor }) {
module.exports = function(ctx) {
const router = express.Router();
// ===== RESOURCE MONITORING ENDPOINTS =====
router.get('/monitoring/stats', asyncHandler(async (req, res) => {
const stats = resourceMonitor.getAllStats();
// Get all container stats (from resource monitor module)
router.get('/monitoring/stats', ctx.asyncHandler(async (req, res) => {
const stats = ctx.resourceMonitor.getAllStats();
res.json({ success: true, stats });
}, 'monitoring-stats'));
router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => {
const stats = resourceMonitor.getCurrentStats(req.params.containerId);
// Get stats for specific container
router.get('/monitoring/stats/:containerId', ctx.asyncHandler(async (req, res) => {
const stats = ctx.resourceMonitor.getCurrentStats(req.params.containerId);
if (!stats) {
const { NotFoundError } = require('../errors');
throw new NotFoundError('Container');
@@ -25,15 +21,17 @@ module.exports = function({ docker, resourceMonitor }) {
res.json({ success: true, stats });
}, 'monitoring-stats-container'));
router.get('/monitoring/history/:containerId', asyncHandler(async (req, res) => {
// Get historical stats
router.get('/monitoring/history/:containerId', ctx.asyncHandler(async (req, res) => {
const hours = parseInt(req.query.hours) || 24;
const history = resourceMonitor.getHistoricalStats(req.params.containerId, hours);
const history = ctx.resourceMonitor.getHistoricalStats(req.params.containerId, hours);
res.json({ success: true, history, hours });
}, 'monitoring-history'));
router.get('/monitoring/aggregated/:containerId', asyncHandler(async (req, res) => {
// Get aggregated stats
router.get('/monitoring/aggregated/:containerId', ctx.asyncHandler(async (req, res) => {
const hours = parseInt(req.query.hours) || 24;
const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours);
const aggregated = ctx.resourceMonitor.getAggregatedStats(req.params.containerId, hours);
if (!aggregated) {
const { NotFoundError } = require('../errors');
throw new NotFoundError('Monitoring data');
@@ -41,42 +39,49 @@ module.exports = function({ docker, resourceMonitor }) {
res.json({ success: true, aggregated, hours });
}, 'monitoring-aggregated'));
router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => {
resourceMonitor.setAlertConfig(req.params.containerId, req.body);
// Configure alerts
router.post('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => {
ctx.resourceMonitor.setAlertConfig(req.params.containerId, req.body);
res.json({ success: true, message: 'Alert configuration saved' });
}, 'monitoring-alerts-set'));
router.get('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => {
const config = resourceMonitor.getAlertConfig(req.params.containerId);
// Get alert configuration
router.get('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => {
const config = ctx.resourceMonitor.getAlertConfig(req.params.containerId);
res.json({ success: true, config: config || {} });
}, 'monitoring-alerts-get'));
router.delete('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => {
resourceMonitor.removeAlertConfig(req.params.containerId);
// Delete alert configuration
router.delete('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => {
ctx.resourceMonitor.removeAlertConfig(req.params.containerId);
res.json({ success: true, message: 'Alert configuration removed' });
}, 'monitoring-alerts-delete'));
// ===== CONTAINER STATS ENDPOINTS (legacy /stats/) =====
router.get('/stats/containers', asyncHandler(async (req, res) => {
const containers = await docker.client.listContainers({ all: false });
// Get all container stats (live Docker stats)
router.get('/stats/containers', ctx.asyncHandler(async (req, res) => {
const containers = await ctx.docker.client.listContainers({ all: false });
const stats = [];
for (const containerInfo of containers) {
try {
const container = docker.client.getContainer(containerInfo.Id);
const container = ctx.docker.client.getContainer(containerInfo.Id);
const containerStats = await container.stats({ stream: false });
// Calculate CPU percentage
const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage -
(containerStats.precpu_stats.cpu_usage?.total_usage || 0);
const systemDelta = containerStats.cpu_stats.system_cpu_usage -
(containerStats.precpu_stats.system_cpu_usage || 0);
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0;
// Calculate memory usage
const memUsage = containerStats.memory_stats.usage || 0;
const memLimit = containerStats.memory_stats.limit || 1;
const memPercent = (memUsage / memLimit) * 100;
// Network stats
let netRx = 0, netTx = 0;
if (containerStats.networks) {
for (const net of Object.values(containerStats.networks)) {
@@ -90,15 +95,21 @@ module.exports = function({ docker, resourceMonitor }) {
name: containerInfo.Names[0]?.replace(/^\//, '') || 'unknown',
image: containerInfo.Image,
status: containerInfo.State,
cpu: { percent: Math.round(cpuPercent * 100) / 100 },
cpu: {
percent: Math.round(cpuPercent * 100) / 100
},
memory: {
used: memUsage,
limit: memLimit,
percent: Math.round(memPercent * 100) / 100,
percent: Math.round(memPercent * 100) / 100
},
network: { rx: netRx, tx: netTx },
network: {
rx: netRx,
tx: netTx
}
});
} catch (e) {
// Skip containers we can't get stats for
console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message);
}
}
@@ -106,20 +117,24 @@ module.exports = function({ docker, resourceMonitor }) {
res.json({ success: true, stats, timestamp: new Date().toISOString() });
}, 'stats-containers'));
router.get('/stats/container/:id', asyncHandler(async (req, res) => {
const container = docker.client.getContainer(req.params.id);
// Get single container stats
router.get('/stats/container/:id', ctx.asyncHandler(async (req, res) => {
const container = ctx.docker.client.getContainer(req.params.id);
const containerStats = await container.stats({ stream: false });
const info = await container.inspect();
// Calculate CPU percentage
const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage -
(containerStats.precpu_stats.cpu_usage?.total_usage || 0);
const systemDelta = containerStats.cpu_stats.system_cpu_usage -
(containerStats.precpu_stats.system_cpu_usage || 0);
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0;
// Memory
const memUsage = containerStats.memory_stats.usage || 0;
const memLimit = containerStats.memory_stats.limit || 1;
// Network
let netRx = 0, netTx = 0;
if (containerStats.networks) {
for (const net of Object.values(containerStats.networks)) {
@@ -135,14 +150,16 @@ module.exports = function({ docker, resourceMonitor }) {
image: info.Config.Image,
status: info.State.Status,
started: info.State.StartedAt,
cpu: { percent: Math.round(cpuPercent * 100) / 100 },
cpu: {
percent: Math.round(cpuPercent * 100) / 100
},
memory: {
used: memUsage,
limit: memLimit,
percent: Math.round((memUsage / memLimit) * 100 * 100) / 100,
percent: Math.round((memUsage / memLimit) * 100 * 100) / 100
},
network: { rx: netRx, tx: netTx },
},
network: { rx: netRx, tx: netTx }
}
});
}, 'stats-container'));

View File

@@ -7,116 +7,116 @@ module.exports = function(ctx) {
// GET /config — Get notification configuration (sensitive data redacted)
router.get('/config', ctx.asyncHandler(async (req, res) => {
const notificationConfig = ctx.notification.getConfig();
// Return config without sensitive data
const safeConfig = {
enabled: notificationConfig.enabled,
providers: {
discord: {
enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl,
const notificationConfig = ctx.notification.getConfig();
// Return config without sensitive data
const safeConfig = {
enabled: notificationConfig.enabled,
providers: {
discord: {
enabled: notificationConfig.providers.discord?.enabled || false,
configured: !!notificationConfig.providers.discord?.webhookUrl
},
telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId)
},
ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh'
}
},
telegram: {
enabled: notificationConfig.providers.telegram?.enabled || false,
configured: !!(notificationConfig.providers.telegram?.botToken && notificationConfig.providers.telegram?.chatId),
},
ntfy: {
enabled: notificationConfig.providers.ntfy?.enabled || false,
configured: !!notificationConfig.providers.ntfy?.topic,
serverUrl: notificationConfig.providers.ntfy?.serverUrl || 'https://ntfy.sh',
},
},
events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck,
};
res.json({ success: true, config: safeConfig });
events: notificationConfig.events,
healthCheck: notificationConfig.healthCheck
};
res.json({ success: true, config: safeConfig });
}, 'notifications-config-get'));
// POST /config — Update notification configuration
router.post('/config', ctx.asyncHandler(async (req, res) => {
const { enabled, providers, events, healthCheck } = req.body;
const notificationConfig = ctx.notification.getConfig();
const { enabled, providers, events, healthCheck } = req.body;
const notificationConfig = ctx.notification.getConfig();
// Validate provider webhook URLs and tokens
if (providers) {
if (providers.discord?.webhookUrl) {
try {
validateURL(providers.discord.webhookUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
// Validate provider webhook URLs and tokens
if (providers) {
if (providers.discord?.webhookUrl) {
try {
validateURL(providers.discord.webhookUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Discord webhook URL');
}
}
if (providers.telegram?.botToken) {
try {
validateToken(providers.telegram.botToken);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
}
}
if (providers.ntfy?.serverUrl) {
try {
validateURL(providers.ntfy.serverUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
}
}
if (providers.ntfy?.topic) {
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
if (!topicRegex.test(providers.ntfy.topic)) {
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
}
}
if (providers.telegram?.botToken) {
try {
validateToken(providers.telegram.botToken);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
// Update enabled state
if (typeof enabled === 'boolean') {
notificationConfig.enabled = enabled;
}
// Update providers (only update provided fields)
if (providers) {
if (providers.discord) {
notificationConfig.providers.discord = {
...notificationConfig.providers.discord,
...providers.discord
};
}
if (providers.telegram) {
notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram,
...providers.telegram
};
}
if (providers.ntfy) {
notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy,
...providers.ntfy
};
}
}
if (providers.ntfy?.serverUrl) {
try {
validateURL(providers.ntfy.serverUrl);
} catch (validationErr) {
return ctx.errorResponse(res, 400, 'Invalid ntfy server URL');
// Update events
if (events) {
notificationConfig.events = { ...notificationConfig.events, ...events };
}
// Update health check settings
if (healthCheck) {
const wasEnabled = notificationConfig.healthCheck?.enabled;
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
// Restart daemon if settings changed
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
if (notificationConfig.healthCheck.enabled) {
ctx.notification.startHealthDaemon();
} else {
ctx.notification.stopHealthDaemon();
}
}
}
if (providers.ntfy?.topic) {
const topicRegex = /^[a-zA-Z0-9_-]{1,64}$/;
if (!topicRegex.test(providers.ntfy.topic)) {
return ctx.errorResponse(res, 400, 'Invalid ntfy topic (alphanumeric, hyphens, underscores only, max 64 chars)');
}
}
}
// Update enabled state
if (typeof enabled === 'boolean') {
notificationConfig.enabled = enabled;
}
// Update providers (only update provided fields)
if (providers) {
if (providers.discord) {
notificationConfig.providers.discord = {
...notificationConfig.providers.discord,
...providers.discord,
};
}
if (providers.telegram) {
notificationConfig.providers.telegram = {
...notificationConfig.providers.telegram,
...providers.telegram,
};
}
if (providers.ntfy) {
notificationConfig.providers.ntfy = {
...notificationConfig.providers.ntfy,
...providers.ntfy,
};
}
}
// Update events
if (events) {
notificationConfig.events = { ...notificationConfig.events, ...events };
}
// Update health check settings
if (healthCheck) {
const wasEnabled = notificationConfig.healthCheck?.enabled;
notificationConfig.healthCheck = { ...notificationConfig.healthCheck, ...healthCheck };
// Restart daemon if settings changed
if (healthCheck.enabled !== wasEnabled || healthCheck.intervalMinutes) {
if (notificationConfig.healthCheck.enabled) {
ctx.notification.startHealthDaemon();
} else {
ctx.notification.stopHealthDaemon();
}
}
}
await ctx.notification.saveConfig();
res.json({ success: true, message: 'Notification config updated' });
await ctx.notification.saveConfig();
res.json({ success: true, message: 'Notification config updated' });
}, 'notifications-config-update'));
// POST /test — Test notification delivery
@@ -159,7 +159,7 @@ module.exports = function(ctx) {
res.json({
success: true,
history: notificationHistory.slice(0, limit),
total: notificationHistory.length,
total: notificationHistory.length
});
}
}, 'notifications-history'));
@@ -177,7 +177,7 @@ module.exports = function(ctx) {
res.json({
success: true,
lastCheck: notificationConfig.healthCheck.lastCheck,
containersMonitored: Object.keys(ctx.notification.getHealthState()).length,
containersMonitored: Object.keys(ctx.notification.getHealthState()).length
});
}, 'notifications-health-check'));

View File

@@ -42,7 +42,7 @@ module.exports = function(ctx) {
await ctx.docker.client.createNetwork({
Name: networkName,
Driver: recipe.network.driver || 'bridge',
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId },
Labels: { 'sami.managed': 'true', 'sami.recipe': recipeId }
});
ctx.log.info('recipe', 'Created Docker network', { networkName });
} catch (e) {
@@ -62,18 +62,18 @@ module.exports = function(ctx) {
try {
ctx.log.info('recipe', `Deploying component: ${component.id}`, {
role: component.role,
internal: component.internal || false,
internal: component.internal || false
});
const result = await deployComponent(component, recipe, config, generatedPasswords, networkName);
deployedComponents.push(result);
ctx.log.info('recipe', `Component deployed: ${component.id}`, {
containerId: result.containerId?.substring(0, 12),
containerId: result.containerId?.substring(0, 12)
});
} catch (componentError) {
ctx.log.error('recipe', `Component failed: ${component.id}`, {
error: componentError.message,
error: componentError.message
});
errors.push({ componentId: component.id, role: component.role, error: componentError.message });
// Continue deploying other components — partial success is better than total failure
@@ -96,7 +96,7 @@ module.exports = function(ctx) {
recipeId: recipeId,
recipeRole: deployed.role,
tailscaleOnly: config.sharedConfig?.tailscaleOnly || false,
deployedAt: new Date().toISOString(),
deployedAt: new Date().toISOString()
});
}
}
@@ -119,18 +119,18 @@ module.exports = function(ctx) {
role: c.role,
containerId: c.containerId?.substring(0, 12),
url: c.url,
internal: c.internal,
internal: c.internal
})),
errors: errors.length > 0 ? errors : undefined,
message: errors.length > 0
? `${recipe.name} partially deployed (${deployedComponents.length}/${componentsToDeploy.length} components)`
: `${recipe.name} deployed successfully!`,
setupInstructions: recipe.setupInstructions,
setupInstructions: recipe.setupInstructions
};
ctx.notification.send('deploymentSuccess', 'Recipe Deployed',
`**${recipe.name}** recipe deployed (${deployedComponents.length} components).`,
'success',
'success'
);
res.json(response);
@@ -146,7 +146,7 @@ module.exports = function(ctx) {
}
} catch (cleanupError) {
ctx.log.warn('recipe', 'Cleanup failed for component', {
componentId: deployed.id, error: cleanupError.message,
componentId: deployed.id, error: cleanupError.message
});
}
}
@@ -162,7 +162,7 @@ module.exports = function(ctx) {
}
ctx.notification.send('deploymentFailed', 'Recipe Failed',
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error',
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error'
);
ctx.errorResponse(res, 500, error.message);
@@ -254,7 +254,7 @@ module.exports = function(ctx) {
HostConfig: {
PortBindings: {},
Binds: dockerConfig.volumes || [],
RestartPolicy: { Name: 'unless-stopped' },
RestartPolicy: { Name: 'unless-stopped' }
},
Env: Object.entries(dockerConfig.environment || {}).map(([k, v]) => `${k}=${v}`),
Labels: {
@@ -264,8 +264,8 @@ module.exports = function(ctx) {
'sami.recipe.component': component.id,
'sami.recipe.role': component.role,
'sami.subdomain': subdomain,
'sami.deployed': new Date().toISOString(),
},
'sami.deployed': new Date().toISOString()
}
};
// Configure ports
@@ -288,7 +288,7 @@ module.exports = function(ctx) {
} catch (e) {
ctx.log.warn('recipe', `Pull failed, checking local: ${dockerConfig.image}`);
const images = await ctx.docker.client.listImages({
filters: { reference: [dockerConfig.image] },
filters: { reference: [dockerConfig.image] }
});
if (images.length === 0) throw new Error(`Image not found: ${dockerConfig.image}`);
}
@@ -324,7 +324,7 @@ module.exports = function(ctx) {
const primaryPort = port || dockerConfig.ports[0].split(/[:/]/)[0];
const caddyConfig = ctx.caddy.generateConfig(
subdomain, hostIp, primaryPort,
{ tailscaleOnly: sharedConfig.tailscaleOnly || false },
{ tailscaleOnly: sharedConfig.tailscaleOnly || false }
);
try {
const helpers = require('../apps/helpers')(ctx);
@@ -344,7 +344,7 @@ module.exports = function(ctx) {
internal: component.internal || false,
templateRef: component.templateRef,
logo,
url,
url
};
}

View File

@@ -29,9 +29,9 @@ module.exports = function(ctx) {
required: c.required,
internal: c.internal || false,
templateRef: c.templateRef || null,
note: c.note || null,
note: c.note || null
})),
setupInstructions: recipe.setupInstructions,
setupInstructions: recipe.setupInstructions
}));
res.json({ success: true, templates, categories: RECIPE_CATEGORIES });

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
if (!recipeGroups[service.recipeId]) {
recipeGroups[service.recipeId] = {
recipeId: service.recipeId,
components: [],
components: []
};
}
recipeGroups[service.recipeId].components.push({
@@ -25,7 +25,7 @@ module.exports = function(ctx) {
logo: service.logo,
containerId: service.containerId,
recipeRole: service.recipeRole,
deployedAt: service.deployedAt,
deployedAt: service.deployedAt
});
}
@@ -48,7 +48,7 @@ module.exports = function(ctx) {
// Check if this container is already listed (by containerId)
const existing = recipeGroups[recipeId].components.find(
c => c.containerId === containerInfo.Id,
c => c.containerId === containerInfo.Id
);
if (existing) continue;
@@ -59,7 +59,7 @@ module.exports = function(ctx) {
recipeRole: labels['sami.recipe.role'] || 'Unknown',
internal: true,
state: containerInfo.State,
status: containerInfo.Status,
status: containerInfo.Status
});
}
} catch (e) {
@@ -242,7 +242,7 @@ module.exports = function(ctx) {
ctx.notification.send('recipeRemoved', 'Recipe Removed',
`Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`,
'info',
'info'
);
ctx.log.info('recipe', 'Recipe removed', { recipeId, results });
@@ -271,7 +271,7 @@ module.exports = function(ctx) {
Id: c.Id,
component: c.Labels['sami.recipe.component'] || c.Names[0]?.replace('/', ''),
role: c.Labels['sami.recipe.role'] || 'Unknown',
state: c.State,
state: c.State
}));
}
@@ -293,7 +293,7 @@ module.exports = function(ctx) {
*/
async function removeCaddyBlock(subdomain) {
const domain = ctx.buildDomain(subdomain);
const content = await ctx.caddy.read();
let content = await ctx.caddy.read();
// Find and remove the block for this domain
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

View File

@@ -99,7 +99,7 @@ module.exports = function(ctx) {
isUp: false,
statusCode: 502,
responseTime,
error: error.message,
error: error.message
};
}
@@ -108,7 +108,7 @@ module.exports = function(ctx) {
isUp: isServiceUp(statusCode),
statusCode,
responseTime,
url,
url
};
}
@@ -169,7 +169,7 @@ module.exports = function(ctx) {
success: true,
hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username,
username: username || null,
username: username || null
});
} catch (error) {
res.json({ success: true, hasApiKey: false, hasBasicAuth: false });
@@ -249,7 +249,7 @@ module.exports = function(ctx) {
services.forEach(service => addId(service.id));
const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) =>
probeServiceStatus(id, serviceMap.get(id)),
probeServiceStatus(id, serviceMap.get(id))
);
const statuses = {};
@@ -261,7 +261,7 @@ module.exports = function(ctx) {
res.json({
success: true,
checkedAt: new Date().toISOString(),
statuses,
statuses
});
}, 'services-status'));
@@ -343,7 +343,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Successfully imported ${services.length} services`,
count: services.length,
count: services.length
});
}, 'services-import'));
@@ -396,12 +396,12 @@ module.exports = function(ctx) {
const oldDomain = ctx.buildDomain(oldSubdomain);
const newDomain = ctx.buildDomain(newSubdomain);
const content = await ctx.caddy.read();
let content = await ctx.caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
's',
's'
);
const oldBlockMatch = content.match(siteBlockRegex);
@@ -414,7 +414,7 @@ module.exports = function(ctx) {
const finalPort = port || existingPort;
const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, {
tailscaleOnly: tailscaleOnly || false,
tailscaleOnly: tailscaleOnly || false
});
const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig));
@@ -445,7 +445,7 @@ module.exports = function(ctx) {
id: newSubdomain,
port: port || services[serviceIndex].port,
ip: ip || services[serviceIndex].ip,
tailscaleOnly: tailscaleOnly || false,
tailscaleOnly: tailscaleOnly || false
};
results.services = 'updated';
} else {
@@ -459,7 +459,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
results,
results
});
}, 'services-update'));

View File

@@ -25,7 +25,7 @@ module.exports = function(ctx) {
const response = await ctx.fetchT(`${ctx.caddy.adminUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: caddyfileContent,
body: caddyfileContent
});
if (!response.ok) {
@@ -39,80 +39,80 @@ module.exports = function(ctx) {
// Get Certificate Authorities from Caddyfile
router.get('/caddy/cas', ctx.asyncHandler(async (req, res) => {
const content = await ctx.caddy.read();
const cas = [];
const content = await ctx.caddy.read();
const cas = [];
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
let pkiMatch;
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
const pkiBlock = pkiMatch[1];
let caMatch;
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
const caName = caMatch[1];
const caBlock = caMatch[2];
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
const pkiRegex = /pki\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
let pkiMatch;
while ((pkiMatch = pkiRegex.exec(content)) !== null) {
const pkiBlock = pkiMatch[1];
let caMatch;
const caBlockRegex = /ca\s+(\S+)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}/gs;
while ((caMatch = caBlockRegex.exec(pkiBlock)) !== null) {
const caName = caMatch[1];
const caBlock = caMatch[2];
const ca = { id: caName, name: caName, root: {}, intermediate: {} };
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
if (nameMatch) ca.name = nameMatch[1];
const nameMatch = /name\s+"([^"]+)"/.exec(caBlock);
if (nameMatch) ca.name = nameMatch[1];
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
const rootCnMatch = /root_cn\s+"([^"]+)"/.exec(caBlock);
const intCnMatch = /intermediate_cn\s+"([^"]+)"/.exec(caBlock);
if (rootCnMatch) ca.root_cn = rootCnMatch[1];
if (intCnMatch) ca.intermediate_cn = intCnMatch[1];
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
if (rootMatch) {
const rootBlock = rootMatch[1];
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
if (certMatch) ca.root.cert = certMatch[1];
if (keyMatch) ca.root.key = keyMatch[1];
const rootMatch = /root\s*\{([^}]*)\}/s.exec(caBlock);
if (rootMatch) {
const rootBlock = rootMatch[1];
const certMatch = /cert\s+(\S+)/.exec(rootBlock);
const keyMatch = /key\s+(\S+)/.exec(rootBlock);
if (certMatch) ca.root.cert = certMatch[1];
if (keyMatch) ca.root.key = keyMatch[1];
}
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
if (intMatch) {
const intBlock = intMatch[1];
const certMatch = /cert\s+(\S+)/.exec(intBlock);
const keyMatch = /key\s+(\S+)/.exec(intBlock);
if (certMatch) ca.intermediate.cert = certMatch[1];
if (keyMatch) ca.intermediate.key = keyMatch[1];
}
cas.push(ca);
}
}
const intMatch = /intermediate\s*\{([^}]*)\}/s.exec(caBlock);
if (intMatch) {
const intBlock = intMatch[1];
const certMatch = /cert\s+(\S+)/.exec(intBlock);
const keyMatch = /key\s+(\S+)/.exec(intBlock);
if (certMatch) ca.intermediate.cert = certMatch[1];
if (keyMatch) ca.intermediate.key = keyMatch[1];
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
let tlsMatch;
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
}
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
const tlsInternalCAs = new Set();
for (const block of siteBlocks) {
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
tlsInternalCAs.add('local');
}
cas.push(ca);
}
}
const tlsGlobalRegex = /\{\s*acme_ca\s+(\S+)/g;
let tlsMatch;
while ((tlsMatch = tlsGlobalRegex.exec(content)) !== null) {
cas.push({ name: 'acme', url: tlsMatch[1], type: 'acme' });
}
const siteBlocks = content.match(/[\w.-]+\s*\{[^}]*tls\s+[^}]*\}/gs) || [];
const tlsInternalCAs = new Set();
for (const block of siteBlocks) {
const tlsInternalMatch = /tls\s+internal\s*\{[^}]*ca\s+(\S+)/s.exec(block);
if (tlsInternalMatch) tlsInternalCAs.add(tlsInternalMatch[1]);
if (/tls\s+internal(?:\s|$)/.test(block) && !/tls\s+internal\s*\{/.test(block)) {
tlsInternalCAs.add('local');
for (const caName of tlsInternalCAs) {
if (!cas.find(c => c.name === caName)) {
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
}
}
}
for (const caName of tlsInternalCAs) {
if (!cas.find(c => c.name === caName)) {
cas.push({ name: caName, type: 'internal', note: 'Referenced in tls directive' });
if (cas.length === 0 && /tls\s+internal/.test(content)) {
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
}
}
if (cas.length === 0 && /tls\s+internal/.test(content)) {
cas.push({ name: 'local', type: 'internal', note: 'Default Caddy internal CA' });
}
const caList = cas.map(ca => ({
id: ca.id || ca.name,
name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name,
}));
res.json({ status: 'success', data: { cas: caList } });
const caList = cas.map(ca => ({
id: ca.id || ca.name,
name: ca.name,
displayName: ca.name !== (ca.id || ca.name) ? `${ca.name} (${ca.id || ca.name})` : ca.name
}));
res.json({ status: 'success', data: { cas: caList } });
}, 'caddy-get-cas'));
// Remove a site from Caddyfile
@@ -123,7 +123,7 @@ module.exports = function(ctx) {
const result = await ctx.caddy.modify((content) => {
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g',
`\\n?${escapedDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}\\s*`, 'g'
);
const modified = content.replace(siteBlockRegex, '\n');
if (modified.length === content.length) return null;
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) return ctx.errorResponse(res, 400, 'Invalid upstream format. Use host:port');
const content = await ctx.caddy.read();
let content = await ctx.caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g');
if (siteBlockRegex.test(content)) {
@@ -200,7 +200,7 @@ module.exports = function(ctx) {
}
const sslConfig = sslType === 'letsencrypt' ? '' : 'tls internal';
const hostHeader = preserveHost ? '\n header_up Host {upstream_hostport}' : '';
const hostHeader = preserveHost ? `\n header_up Host {upstream_hostport}` : '';
const urlObj = new URL(externalUrl);
@@ -238,7 +238,7 @@ module.exports = function(ctx) {
await ctx.addServiceToConfig({
id: subdomain, name: serviceName, logo,
isExternal: true, externalUrl,
deployedAt: new Date().toISOString(),
deployedAt: new Date().toISOString()
});
ctx.log.info('deploy', 'Service added to dashboard', { subdomain });
} catch (serviceError) {
@@ -248,7 +248,7 @@ module.exports = function(ctx) {
const response = {
success: true,
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`,
message: `External service proxy for ${domain} -> ${externalUrl} created${shouldReload ? ' and Caddy reloaded' : ''}`
};
if (dnsWarning) response.warning = dnsWarning;
res.json(response);

View File

@@ -16,7 +16,7 @@ module.exports = function(ctx) {
success: true,
installed: false,
connected: false,
message: 'Tailscale not available or not running',
message: 'Tailscale not available or not running'
});
}
@@ -30,7 +30,7 @@ module.exports = function(ctx) {
os: peer.OS,
online: peer.Online,
lastSeen: peer.LastSeen,
user: peer.UserID,
user: peer.UserID
});
}
}
@@ -44,11 +44,11 @@ module.exports = function(ctx) {
hostname: status.Self?.HostName,
ip: localIP,
tailnetName: status.MagicDNSSuffix,
online: status.Self?.Online,
online: status.Self?.Online
},
config: ctx.tailscale.config,
devices,
deviceCount: devices.length,
deviceCount: devices.length
});
}, 'tailscale-status'));
@@ -65,7 +65,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: 'Tailscale configuration updated',
config: ctx.tailscale.config,
config: ctx.tailscale.config
});
}, 'tailscale-config'));
@@ -83,7 +83,7 @@ module.exports = function(ctx) {
isTailscale,
clientIP,
forwardedFor: forwardedFor || null,
realIP: realIP || null,
realIP: realIP || null
});
}, 'tailscale-check'));
@@ -102,7 +102,7 @@ module.exports = function(ctx) {
hostname: peer.HostName,
ip: peer.TailscaleIPs?.[0],
os: peer.OS,
user: peer.UserID,
user: peer.UserID
});
}
}
@@ -114,7 +114,7 @@ module.exports = function(ctx) {
ip: status.Self.TailscaleIPs?.[0],
os: status.Self.OS,
user: status.Self.UserID,
isSelf: true,
isSelf: true
});
}
@@ -129,7 +129,7 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 400, 'subdomain is required');
}
const content = await ctx.caddy.read();
let content = await ctx.caddy.read();
const domain = ctx.buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');
@@ -149,7 +149,7 @@ module.exports = function(ctx) {
const newConfig = ctx.caddy.generateConfig(subdomain, ip, port || '80', {
tailscaleOnly: tailscaleOnly !== false,
allowedIPs: allowedIPs || [],
allowedIPs: allowedIPs || []
});
const caddyResult = await ctx.caddy.modify(c => c.replace(blockRegex, newConfig));
@@ -170,7 +170,7 @@ module.exports = function(ctx) {
res.json({
success: true,
message: `Service ${domain} is now ${tailscaleOnly !== false ? 'protected by' : 'no longer restricted to'} Tailscale`,
tailscaleOnly: tailscaleOnly !== false,
tailscaleOnly: tailscaleOnly !== false
});
}, 'tailscale-protect'));
@@ -188,7 +188,7 @@ module.exports = function(ctx) {
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`,
body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials`
});
if (!tokenRes.ok) {
@@ -199,7 +199,7 @@ module.exports = function(ctx) {
// 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}` },
headers: { Authorization: `Bearer ${tokenData.access_token}` }
});
if (!testRes.ok) {
@@ -259,7 +259,7 @@ module.exports = function(ctx) {
res.json({
success: true,
devices: ctx.tailscale.config.devices || [],
lastSync: ctx.tailscale.config.lastSync,
lastSync: ctx.tailscale.config.lastSync
});
}, 'tailscale-api-devices'));
@@ -274,7 +274,7 @@ module.exports = function(ctx) {
res.json({
success: true,
devices: devices || [],
lastSync: ctx.tailscale.config.lastSync,
lastSync: ctx.tailscale.config.lastSync
});
}, 'tailscale-sync'));
@@ -287,7 +287,7 @@ module.exports = function(ctx) {
}
const aclRes = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/acl`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
});
if (!aclRes.ok) {
return ctx.errorResponse(res, aclRes.status, `ACL fetch failed: HTTP ${aclRes.status}`);
@@ -299,7 +299,7 @@ module.exports = function(ctx) {
groups: Object.keys(acl.groups || {}),
tagOwners: Object.keys(acl.tagOwners || {}),
aclRuleCount: (acl.acls || []).length,
sshRuleCount: (acl.ssh || []).length,
sshRuleCount: (acl.ssh || []).length
};
res.json({ success: true, acl, summary });

View File

@@ -46,15 +46,15 @@ module.exports = function(ctx) {
const themeData = { name, ...colors };
if (lightBg) themeData.lightBg = true;
fs.writeFileSync(path.join(THEMES_DIR, `${slug }.json`), JSON.stringify(themeData, null, 2), 'utf8');
fs.writeFileSync(path.join(THEMES_DIR, slug + '.json'), JSON.stringify(themeData, null, 2), 'utf8');
res.json({ success: true, message: `${name } theme saved` });
res.json({ success: true, message: name + ' theme saved' });
});
// Delete a theme
router.delete('/themes/:slug', (req, res) => {
const { slug } = req.params;
const filePath = path.join(THEMES_DIR, `${slug }.json`);
const filePath = path.join(THEMES_DIR, slug + '.json');
if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, error: 'Theme not found' });
@@ -64,7 +64,7 @@ module.exports = function(ctx) {
const name = data.name || slug;
fs.unlinkSync(filePath);
res.json({ success: true, message: `${name } theme deleted` });
res.json({ success: true, message: name + ' theme deleted' });
});
return router;