diff --git a/backend/internal/certificate.js b/backend/internal/certificate.js index fad97f6a9..831eadf31 100644 --- a/backend/internal/certificate.js +++ b/backend/internal/certificate.js @@ -24,6 +24,104 @@ const certbotCommand = "certbot"; const certbotLogsDir = "/data/logs"; const certbotWorkDir = "/tmp/letsencrypt-lib"; +// Certbot output lines that say nothing about why a request failed +const certbotNoise = [ + /^Saving debug log/, + /^Ask for help/, + /^See the logfile/, + /^(- )+-?$/, + /^Processing \//, + /^Renewing an existing/, + /^Requesting a certificate/, + /^Plugins selected/, + /^Account registered/, + /^Waiting \d+ seconds/, + /^All renewals failed/, + /^\S+\/fullchain\.pem \(failure\)$/, + /^\d+ renew failure\(s\)/, +]; + +// Plain-language hints for failures that are common and fixable outside NPM +const certbotHints = [ + [ + /expected \S+ to be a symlink/i, + () => + "This certificate's files in /etc/letsencrypt were copied without their symlinks (live/ must link to archive/). Restore /etc/letsencrypt from a copy made with tar or rsync -a, or delete this certificate and create it again.", + ], + [ + /Renewal configuration file \S+ is broken/i, + () => + "This certificate's files in /etc/letsencrypt are damaged or missing. Restore /etc/letsencrypt from a backup, or delete this certificate and create it again.", + ], + [ + /\b9109\b.*?location: ([0-9a-f.:]+)/i, + (m) => { + const ip = m[1].replace(/\.+$/, ""); + return `Cloudflare refused the API token from this server's public IP (${ip}). The token has Client IP Address Filtering: add ${ip} to the token in Cloudflare (My Profile > API Tokens), or remove the filter, then renew again.`; + }, + ], + [ + /\b(6003|6111|9103|9109|10000)\b|Invalid request headers|Invalid access token|Authentication error/i, + () => + "Cloudflare rejected the API token. Check the token value and that it has Zone > DNS > Edit for this domain's zone.", + ], + [ + /too many (certificates|failed authorizations|new orders)|rateLimited/i, + () => "Let's Encrypt rate limit reached. Wait before retrying (see https://letsencrypt.org/docs/rate-limits/).", + ], + [ + /NXDOMAIN|DNS problem/i, + () => "Let's Encrypt could not look up the domain. Check the DNS records of every domain on the certificate.", + ], + [ + /Timeout during connect|Connection refused|Invalid response from/i, + () => + "Let's Encrypt could not reach this server over HTTP (port 80) for the challenge. Check that the domain points here and port 80 is open, or use a DNS challenge.", + ], +]; + +/** + * Turns certbot's stderr into a message the UI can show: a hint for common causes plus certbot's own reason. + * Certbot never prints DNS provider credentials. + * + * @param {String} stderr + * @returns {String} + */ +const certbotFailureMessage = (stderr) => { + const reason = `${stderr || ""}` + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !certbotNoise.some((re) => re.test(line))) + .join(" ") + .slice(0, 800); + + for (const [pattern, hint] of certbotHints) { + const match = reason.match(pattern); + if (match) { + return `${hint(match)} (Certbot: ${reason})`; + } + } + return reason ? `Certbot failed: ${reason}` : "Certbot failed. See /data/logs/letsencrypt.log for details."; +}; + +/** + * Runs certbot; a failure becomes a public error with the real reason instead of "Internal Error". + * + * @param {Array} args + * @param {Object} [opts] + * @returns {Promise} + */ +const runCertbot = async (args, opts) => { + try { + return await utils.execFile(certbotCommand, args, opts); + } catch (err) { + if (err instanceof error.CommandError) { + throw new error.ValidationError(certbotFailureMessage(err.message), err); + } + throw err; + } +}; + const omissions = () => { return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials", "meta.certificate_key"]; }; @@ -843,7 +941,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.success(result); return result; }, @@ -910,7 +1008,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.info(result); return result; } finally { @@ -1004,7 +1102,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.info(result); return result; }, @@ -1088,7 +1186,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); logger.info(result); return result; } finally { @@ -1131,7 +1229,7 @@ const internalCertificate = { logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`); try { - const result = await utils.execFile(certbotCommand, args, adds.opts); + const result = await runCertbot(args, adds.opts); await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`); logger.info(result); return result;