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; };