Files
Krystie b172a21b63 Migrate 25 route files to throw-based error handling
Converted routes:
- All auth routes (totp.js, keys.js, sso-gate.js)
- Recipe deployment routes (deploy.js, manage.js, index.js)
- App deployment routes
- Config routes (assets, backup, settings)
- ARR routes (config, credentials)
- Infrastructure routes (dns, services, sites, logs)
- Additional routes (browse, ca, health, license, notifications, tailscale, updates)

Changes:
- Replaced ctx.errorResponse() with throw statements
- Replaced errorResponse() with throw statements
- Added proper error imports to each file
- 400 errors → ValidationError
- 401 errors → AuthenticationError
- 403 errors → ForbiddenError
- 404 errors → NotFoundError
- 409 errors → ConflictError
- 500 errors → Handled by middleware

Result: 25 files migrated, ~150 error responses standardized
2026-03-29 18:53:03 -07:00

289 lines
12 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const { execSync } = require('child_process');
const { exists } = require('../fs-helpers');
const platformPaths = require('../platform-paths');
module.exports = function(ctx) {
const router = express.Router();
// Get CA certificate information
router.get('/info', ctx.asyncHandler(async (req, res) => {
const certInfoPath = '/app/ca/cert-info.json';
const fallbackCertInfoPath = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile;
if (await exists(certInfoPath)) {
certInfoFile = certInfoPath;
} else if (await exists(fallbackCertInfoPath)) {
certInfoFile = fallbackCertInfoPath;
} else {
const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information');
}
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));
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)
router.get('/root.crt', ctx.asyncHandler(async (req, res) => {
const pkiCertPath = '/app/pki/root.crt';
const hostCertPath = platformPaths.pkiRootCert;
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
let certPath;
if (await exists(pkiCertPath)) certPath = pkiCertPath;
else if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
else if (await exists(hostCertPath)) certPath = hostCertPath;
else {
const { NotFoundError } = require('../errors');
throw new NotFoundError('Root CA certificate');
}
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
res.setHeader('Content-Disposition', 'attachment; filename="dashcaddy-root-ca.crt"');
res.sendFile(path.resolve(certPath));
}, 'ca-root-crt'));
// Generate a platform-specific install script with real cert info injected
router.get('/install-script', ctx.asyncHandler(async (req, res) => {
const platform = (req.query.platform || 'windows').toLowerCase();
if (!['windows', 'linux', 'macos'].includes(platform)) {
throw new ValidationError('Invalid platform. Use: windows, linux, or macos');
}
// Load cert info to get the fingerprint
const certInfoPath = '/app/ca/cert-info.json';
const fallbackCertInfoPath2 = path.join(platformPaths.caCertDir, 'cert-info.json');
let certInfoFile;
if (await exists(certInfoPath)) certInfoFile = certInfoPath;
else if (await exists(fallbackCertInfoPath2)) certInfoFile = fallbackCertInfoPath2;
else {
const { NotFoundError } = require('../errors');
throw new NotFoundError('CA certificate information. Deploy DashCA first or ensure cert-info.json exists.');
}
const certInfo = JSON.parse(await fsp.readFile(certInfoFile, 'utf8'));
const fingerprint = certInfo.fingerprint; // e.g. "08:98:A5:63:..."
// Build the cert download URL — use DashCA if available, fall back to API endpoint
const tld = ctx.siteConfig.tld || '.home';
const dashcaUrl = `https://ca${tld}/root.crt`;
const apiUrl = `https://dashcaddy${tld}/api/ca/root.crt`;
// Prefer DashCA URL, but the script's TLS bypass means either will work
const certUrl = dashcaUrl;
// Load and populate the template
const templateName = platform === 'windows' ? 'install-ca.ps1.template' : 'install-ca.sh.template';
// Look for template in multiple locations (packaged app vs dev)
const templatePaths = [
path.join(__dirname, '..', 'scripts', templateName),
path.join('/app', 'scripts', templateName)
];
let templateContent;
for (const tp of templatePaths) {
if (await exists(tp)) {
templateContent = await fsp.readFile(tp, 'utf8');
break;
}
}
if (!templateContent) {
const { NotFoundError } = require('../errors');
throw new NotFoundError(`Install script template (${templateName})`);
}
// Inject real values
const script = templateContent
.replace('{{CERT_URL}}', certUrl)
.replace('{{CERT_FINGERPRINT}}', fingerprint);
const filename = platform === 'windows' ? 'install-dashcaddy-ca.ps1' : 'install-dashcaddy-ca.sh';
const contentType = platform === 'windows' ? 'text/plain; charset=utf-8' : 'text/x-shellscript; charset=utf-8';
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(script);
}, 'ca-install-script'));
// Generate and download SSL certificate for a service
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
const { domain } = req.params;
const { password = 'dashcaddy', format = 'pfx' } = req.query;
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).');
}
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
}
const pkiPath = '/app/pki';
const certsDir = '/app/generated-certs';
const domainDir = path.join(certsDir, domain);
const intermediateCert = path.join(pkiPath, 'intermediate.crt');
const intermediateKey = path.join(pkiPath, 'intermediate.key');
const rootCert = path.join(pkiPath, 'root.crt');
if (!await exists(intermediateCert) || !await exists(intermediateKey)) {
return ctx.errorResponse(res, 500, 'CA certificates not found. Ensure Caddy PKI is initialized.');
}
if (!await exists(certsDir)) await fsp.mkdir(certsDir, { recursive: true });
if (!await exists(domainDir)) await fsp.mkdir(domainDir, { recursive: true });
const keyFile = path.join(domainDir, 'server.key');
const csrFile = path.join(domainDir, 'server.csr');
const certFile = path.join(domainDir, 'server.crt');
const pfxFile = path.join(domainDir, 'server.pfx');
const pemFile = path.join(domainDir, 'server.pem');
const fullChainFile = path.join(domainDir, 'fullchain.pem');
let needsRegeneration = true;
if (await exists(certFile)) {
try {
const certDates = execSync(`openssl x509 -in "${certFile}" -noout -dates`).toString();
const notAfter = certDates.match(/notAfter=(.*)/)[1].trim();
const expirationDate = new Date(notAfter);
const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiration > 30) needsRegeneration = false;
} catch {
needsRegeneration = true;
}
}
if (needsRegeneration) {
execSync(`openssl genrsa -out "${keyFile}" 2048`, { stdio: 'pipe' });
const subject = `/CN=${domain}`;
execSync(`openssl req -new -key "${keyFile}" -out "${csrFile}" -subj "${subject}"`, { stdio: 'pipe' });
const configContent = `[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = ${domain}
[v3_req]
keyUsage = keyEncipherment, dataEncipherment, digitalSignature
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${domain}
${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
const configFile = path.join(domainDir, 'openssl.cnf');
await fsp.writeFile(configFile, configContent);
const serialFile = path.join(domainDir, 'ca.srl');
execSync(`openssl x509 -req -in "${csrFile}" -CA "${intermediateCert}" -CAkey "${intermediateKey}" -CAserial "${serialFile}" -CAcreateserial -out "${certFile}" -days 365 -sha256 -extfile "${configFile}" -extensions v3_req`, { stdio: 'pipe' });
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);
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);
}
if (format === 'pfx') {
res.setHeader('Content-Type', 'application/x-pkcs12');
res.setHeader('Content-Disposition', `attachment; filename="${domain}.pfx"`);
res.sendFile(pfxFile);
} else if (format === 'pem') {
res.setHeader('Content-Type', 'application/x-pem-file');
res.setHeader('Content-Disposition', `attachment; filename="${domain}.pem"`);
res.sendFile(pemFile);
} else if (format === 'crt') {
res.setHeader('Content-Type', 'application/x-x509-ca-cert');
res.setHeader('Content-Disposition', `attachment; filename="${domain}.crt"`);
res.sendFile(certFile);
} else if (format === 'key') {
res.setHeader('Content-Type', 'application/x-pem-file');
res.setHeader('Content-Disposition', `attachment; filename="${domain}.key"`);
res.sendFile(keyFile);
} else if (format === 'fullchain') {
res.setHeader('Content-Type', 'application/x-pem-file');
res.setHeader('Content-Disposition', `attachment; filename="${domain}-fullchain.pem"`);
res.sendFile(fullChainFile);
} else {
ctx.errorResponse(res, 400, 'Invalid format. Use: pfx, pem, crt, key, or fullchain');
}
}, 'ca-cert'));
// List generated certificates
router.get('/certs', ctx.asyncHandler(async (req, res) => {
const certsDir = '/app/generated-certs';
if (!await exists(certsDir)) {
return res.json({ success: true, certificates: [] });
}
const dirEntries = await fsp.readdir(certsDir);
const domains = [];
for (const f of dirEntries) {
const stat = await fsp.stat(path.join(certsDir, f));
if (stat.isDirectory()) domains.push(f);
}
const certificates = (await Promise.all(domains.map(async (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() : '';
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);
res.json({ success: true, certificates });
}, 'ca-certs'));
return router;
};