378 lines
17 KiB
JavaScript
378 lines
17 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const { exists } = require('../src/utilities/fs-helpers');
|
|
const { ValidationError } = require('../src/utilities/errors');
|
|
const { ok } = require('../src/utils/responses');
|
|
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 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
|
|
let certInfoFile;
|
|
if (await exists(certInfoPath)) {
|
|
certInfoFile = certInfoPath;
|
|
} else {
|
|
const { NotFoundError } = require('../src/utilities/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));
|
|
|
|
ok(res, {
|
|
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 hostCertPath = platformPaths.pkiRootCert;
|
|
const dashcaCertPath = path.join(platformPaths.caCertDir, 'root.crt');
|
|
|
|
let certPath;
|
|
if (await exists(dashcaCertPath)) certPath = dashcaCertPath;
|
|
else if (await exists(hostCertPath)) certPath = hostCertPath;
|
|
else {
|
|
const { NotFoundError } = require('../src/utilities/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 = path.join(platformPaths.caCertDir, 'cert-info.json');
|
|
|
|
let certInfoFile;
|
|
if (await exists(certInfoPath)) {
|
|
certInfoFile = certInfoPath;
|
|
} else {
|
|
const { NotFoundError } = require('../src/utilities/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(platformPaths.caddyBase, '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('../src/utilities/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'));
|
|
|
|
// DC-076: per-service cert/key download — TOTP + admin scope required.
|
|
// Pre-fix this endpoint (a) had a hardcoded `password = 'dashcaddy'` default
|
|
// for the PFX format — a default credential published in source; (b) was
|
|
// public-listed in middleware.js PUBLIC_ROUTES (TOTP bypassed when TOTP is
|
|
// disabled — single ops command or fresh-install setup state), and (c)
|
|
// accepted ANY TOTP-authenticated scope (read scope was enough to pull
|
|
// private keys). Fix: require explicit password (no default), require
|
|
// TOTP/session (dropped from PUBLIC_ROUTES — see middleware.js), and
|
|
// require `admin` scope at the route layer as defense-in-depth against
|
|
// future middleware-ordering mistakes.
|
|
const CA_CERT_DOMAINS_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
// Per-DC-076: PFX password now required, ≥ 8 chars, no `=` (pkcs12
|
|
// interprets `=` as a base64 padding marker that downstream tooling
|
|
// can mis-handle; reject it to keep the password copy-paste-safe).
|
|
const CA_PFX_PASSWORD_RE = /^[A-Za-z0-9!@#%^_+,.~:-]{8,64}$/;
|
|
const CA_CERT_RATE_LIMIT = { windowMs: 60_000, max: 10 };
|
|
const caCertRateBuckets = new Map(); // ip -> { count, resetAt }
|
|
function caCertRateLimit(ip) {
|
|
const now = Date.now();
|
|
const b = caCertRateBuckets.get(ip);
|
|
if (!b || b.resetAt <= now) {
|
|
caCertRateBuckets.set(ip, { count: 1, resetAt: now + CA_CERT_RATE_LIMIT.windowMs });
|
|
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - 1 };
|
|
}
|
|
if (b.count >= CA_CERT_RATE_LIMIT.max) {
|
|
return { allowed: false, remaining: 0, retryAfterMs: b.resetAt - now };
|
|
}
|
|
b.count += 1;
|
|
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - b.count };
|
|
}
|
|
function requireCaCertAdminScope(req, res) {
|
|
// TOTP is enforced by `totpAuthMiddleware` globally. Here we additionally
|
|
// require the `admin` scope — even a read-scope API key or read-scope
|
|
// JWT must NOT be able to pull a private key. Auth context is mounted on
|
|
// `req.auth` by the upstream middlewares.
|
|
const auth = req.auth || {};
|
|
const scope = Array.isArray(auth.scope) ? auth.scope : [];
|
|
if (!scope.includes('admin')) {
|
|
ctx.errorResponse(res, 403,
|
|
'Admin scope required to download per-service private keys. Re-authenticate with an admin-scoped credential.',
|
|
{ code: 'DC-076_INSUFFICIENT_SCOPE', requiredScope: 'admin', actualScope: scope });
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Generate and download SSL certificate for a service
|
|
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
|
|
if (!requireCaCertAdminScope(req, res)) return;
|
|
|
|
const { domain } = req.params;
|
|
|
|
// DC-076: password is REQUIRED for the pfx format (no `=`) and must
|
|
// be ≥ 8 chars. Previously `password = 'dashcaddy'` — a hardcoded
|
|
// default that silently signed every PFX with the same published
|
|
// password. Other formats (key, pem, crt, fullchain) do not need a
|
|
// password and ignore the param.
|
|
const wantsPfx = !req.query.format || req.query.format === 'pfx';
|
|
let password = req.query.password;
|
|
if (wantsPfx) {
|
|
if (typeof password !== 'string' || password === '') {
|
|
return ctx.errorResponse(res, 400,
|
|
'PFX format requires an explicit `password` query param (8-64 chars, no `=`). '
|
|
+ 'A published default is unsafe — pick your own.',
|
|
{ code: 'DC-076_PASSWORD_REQUIRED' });
|
|
}
|
|
if (!CA_PFX_PASSWORD_RE.test(password)) {
|
|
return ctx.errorResponse(res, 400,
|
|
'PFX password must be 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-].',
|
|
{ code: 'DC-076_PASSWORD_INVALID' });
|
|
}
|
|
} else {
|
|
// For non-PFX formats, still reject `=` in the password so a copy-paste
|
|
// mistake can't accidentally inject a base64 padding token into a path
|
|
// someone else might log.
|
|
if (password !== undefined && (typeof password !== 'string' || password.includes('='))) {
|
|
return ctx.errorResponse(res, 400, 'password (if supplied) must be a string without `=`.',
|
|
{ code: 'DC-076_PASSWORD_INVALID' });
|
|
}
|
|
}
|
|
|
|
// DC-076: per-IP rate limit — each cert request forks an `openssl` process
|
|
// and writes to disk. An authenticated admin polling the endpoint in a
|
|
// loop could exhaust CPU/IO. 10 req/min/IP is enough for normal use
|
|
// (regenerate one cert, check 4 formats, done) and tight enough to stop
|
|
// a runaway client.
|
|
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
|
|
const rl = caCertRateLimit(clientIp);
|
|
if (!rl.allowed) {
|
|
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
|
|
return ctx.errorResponse(res, 429,
|
|
`Rate limit exceeded for /api/v1/ca/cert/* (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs/1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
|
|
{ code: 'DC-076_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
|
|
}
|
|
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
|
|
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
|
|
|
|
if (!CA_CERT_DOMAINS_RE.test(domain)) {
|
|
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`,
|
|
{ code: 'DC-076_DOMAIN_INVALID' });
|
|
}
|
|
|
|
const pkiPath = platformPaths.pkiDir;
|
|
const certsDir = platformPaths.generatedCertsDir;
|
|
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 = execFileSync('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) {
|
|
execFileSync('openssl', ['genrsa', '-out', keyFile, '2048'], { stdio: 'pipe' });
|
|
|
|
// Sanitize domain for safe use in shell arguments — defensive, since validation already restricts input
|
|
const safeDomain = domain.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
const subject = `/CN=${safeDomain}`;
|
|
execFileSync('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 = ${safeDomain}
|
|
|
|
[v3_req]
|
|
keyUsage = keyEncipherment, dataEncipherment, digitalSignature
|
|
extendedKeyUsage = serverAuth
|
|
subjectAltName = @alt_names
|
|
|
|
[alt_names]
|
|
DNS.1 = ${safeDomain}
|
|
${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
|
|
|
const configFile = path.join(domainDir, 'openssl.cnf');
|
|
await fsp.writeFile(configFile, configContent);
|
|
|
|
const serialFile = path.join(domainDir, 'ca.srl');
|
|
execFileSync('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);
|
|
|
|
// P0-2 fix: was execSync(`... -password "pass:${password}"`) which interpolates the user-controlled
|
|
// password into a shell string. execFileSync passes it as an argv element instead, no shell parsing.
|
|
execFileSync('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 (DC-076: TOTP-gated; previously public-listed)
|
|
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
|
if (!requireCaCertAdminScope(req, res)) return;
|
|
const certsDir = platformPaths.generatedCertsDir;
|
|
|
|
if (!await exists(certsDir)) {
|
|
return ok(res, { 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 = execFileSync('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);
|
|
|
|
ok(res, { certificates });
|
|
}, 'ca-certs'));
|
|
|
|
return router;
|
|
};
|