Phase 1: Add ESLint/Prettier config + baseline auto-fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ module.exports = function(ctx, helpers) {
|
||||
const results = { radarr: null, sonarr: null };
|
||||
|
||||
// Step 1: Authenticate with Overseerr via Plex token
|
||||
let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
|
||||
const 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
|
||||
let baseUrl = url.replace(/\/+$/, '');
|
||||
const 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'));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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*(.+)/);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -46,90 +46,90 @@ module.exports = function(ctx) {
|
||||
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(/^\//, '');
|
||||
|
||||
ctx.log.info('docker', 'Updating container', { containerName, imageName });
|
||||
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);
|
||||
// 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 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],
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
const newContainerInfo = await newContainer.inspect();
|
||||
|
||||
// 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;
|
||||
// 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 });
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Container ${containerName} updated successfully`,
|
||||
newContainerId: newContainerInfo.Id,
|
||||
});
|
||||
}, 'container-update'));
|
||||
|
||||
// Check for available updates (compares local and remote image digests)
|
||||
@@ -148,7 +148,7 @@ module.exports = function(ctx) {
|
||||
const pullStream = await ctx.docker.pull(imageName);
|
||||
|
||||
const downloadedLayers = pullStream.filter(e =>
|
||||
e.status === 'Downloading' || e.status === 'Download complete'
|
||||
e.status === 'Downloading' || e.status === 'Download complete',
|
||||
);
|
||||
updateAvailable = downloadedLayers.length > 0;
|
||||
|
||||
@@ -167,7 +167,7 @@ module.exports = function(ctx) {
|
||||
success: true,
|
||||
imageName,
|
||||
updateAvailable,
|
||||
currentDigest: localDigest
|
||||
currentDigest: localDigest,
|
||||
});
|
||||
}, 'container-check-update'));
|
||||
|
||||
@@ -178,7 +178,7 @@ module.exports = function(ctx) {
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
tail: 100,
|
||||
timestamps: true
|
||||
timestamps: true,
|
||||
});
|
||||
res.json({ success: true, logs: logs.toString() });
|
||||
}, 'container-logs'));
|
||||
@@ -194,7 +194,7 @@ module.exports = function(ctx) {
|
||||
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'
|
||||
container.Labels && container.Labels['sami.managed'] === 'true',
|
||||
);
|
||||
|
||||
const discoveredContainers = samiContainers.map(container => ({
|
||||
@@ -205,7 +205,7 @@ module.exports = function(ctx) {
|
||||
status: container.Status,
|
||||
appTemplate: container.Labels['sami.app'],
|
||||
subdomain: container.Labels['sami.subdomain'],
|
||||
ports: container.Ports
|
||||
ports: container.Ports,
|
||||
}));
|
||||
|
||||
const paginationParams = parsePaginationParams(req.query);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -34,7 +34,7 @@ module.exports = function(ctx) {
|
||||
|
||||
try {
|
||||
let url = null;
|
||||
let checkType = 'http';
|
||||
const 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'));
|
||||
|
||||
@@ -16,7 +16,7 @@ module.exports = function(ctx) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: result.message,
|
||||
license: result.activation
|
||||
license: result.activation,
|
||||
});
|
||||
} else {
|
||||
ctx.errorResponse(res, 400, result.message);
|
||||
@@ -53,8 +53,8 @@ module.exports = function(ctx) {
|
||||
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'));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -96,17 +96,17 @@ module.exports = function(ctx) {
|
||||
image: containerInfo.Image,
|
||||
status: containerInfo.State,
|
||||
cpu: {
|
||||
percent: Math.round(cpuPercent * 100) / 100
|
||||
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
|
||||
}
|
||||
tx: netTx,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip containers we can't get stats for
|
||||
@@ -151,15 +151,15 @@ module.exports = function(ctx) {
|
||||
status: info.State.Status,
|
||||
started: info.State.StartedAt,
|
||||
cpu: {
|
||||
percent: Math.round(cpuPercent * 100) / 100
|
||||
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'));
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
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'
|
||||
}
|
||||
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,
|
||||
},
|
||||
events: notificationConfig.events,
|
||||
healthCheck: notificationConfig.healthCheck
|
||||
};
|
||||
res.json({ success: true, config: safeConfig });
|
||||
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 });
|
||||
}, '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');
|
||||
}
|
||||
}
|
||||
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)');
|
||||
}
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// 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.telegram?.botToken) {
|
||||
try {
|
||||
validateToken(providers.telegram.botToken);
|
||||
} catch (validationErr) {
|
||||
return ctx.errorResponse(res, 400, 'Invalid Telegram bot token format');
|
||||
}
|
||||
}
|
||||
|
||||
// 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?.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)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.notification.saveConfig();
|
||||
res.json({ success: true, message: 'Notification config updated' });
|
||||
// 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' });
|
||||
}, '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'));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
let content = await ctx.caddy.read();
|
||||
const content = await ctx.caddy.read();
|
||||
|
||||
// Find and remove the block for this domain
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -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);
|
||||
|
||||
let content = await ctx.caddy.read();
|
||||
const 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'));
|
||||
|
||||
|
||||
@@ -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 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 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 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');
|
||||
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];
|
||||
}
|
||||
}
|
||||
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' });
|
||||
}
|
||||
|
||||
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 } });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
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 } });
|
||||
}, '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');
|
||||
|
||||
let content = await ctx.caddy.read();
|
||||
const 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);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
let content = await ctx.caddy.read();
|
||||
const 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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user