diff --git a/functions/cluster_cert.py b/functions/cluster_cert.py index 29ccd8f..b92a9ff 100644 --- a/functions/cluster_cert.py +++ b/functions/cluster_cert.py @@ -78,17 +78,220 @@ CLUSTER_RENEW_THROW_ANCHOR = ( "\"Only Let'sEncrypt certificates can be renewed\");" ) -CLUSTER_RENEW_THROW_FRAGMENT = '''if ( +CLUSTER_RENEW_THROW_FRAGMENT = """if ( certificate.provider === "other" && certificate.meta?.cluster_managed === true && certificate.meta?.acme_renew === true ) { - throw new error.ValidationError( - "Cluster-managed ACME renewal path is not yet enabled", + return await internalCertificate.renewClusterManagedSsl( + access, + certificate, ); } - throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");''' + throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");""" + +CLUSTER_RENEW_HELPER_ANCHOR = ( + "\trenewLetsEncryptSsl: async (certificate) => {" +) + +CLUSTER_RENEW_HELPER_FRAGMENT = """\trenewClusterManagedSsl: async (access, certificate) => { + requireAcmeAuthority(); + + if ( + certificate.provider !== "other" || + certificate.meta?.cluster_managed !== true || + certificate.meta?.acme_renew !== true + ) { + throw new error.ValidationError( + "Certificate is not eligible for cluster-managed ACME renewal", + ); + } + + const livePath = internalCertificate.getLiveCertPath(certificate.id); + + if (internalCertificate.hasLetsEncryptSslCerts(certificate)) { + const renewMethod = certificate.meta?.dns_challenge + ? internalCertificate.renewLetsEncryptSslWithDnsChallenge + : internalCertificate.renewLetsEncryptSsl; + + await renewMethod(certificate); + } else { + const user = await userModel + .query() + .where("is_deleted", 0) + .andWhere("id", certificate.owner_user_id) + .first(); + + if (!user?.email) { + throw new error.ValidationError( + "A valid email address must be set on the certificate owner account to use Let's Encrypt", + ); + } + + const inUseResult = await internalHost.getHostsWithDomains( + certificate.domain_names, + ); + + await internalCertificate.disableInUseHosts(inUseResult); + + if (certificate.meta?.dns_challenge) { + try { + await internalNginx.reload(); + + await internalCertificate.requestLetsEncryptSslWithDnsChallenge( + certificate, + user.email, + ); + + await internalNginx.reload(); + await internalCertificate.enableInUseHosts(inUseResult); + } catch (err) { + await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); + throw err; + } + } else { + try { + await internalNginx.generateLetsEncryptRequestConfig( + certificate, + ); + + await internalNginx.reload(); + + await internalCertificate.requestLetsEncryptSsl( + certificate, + user.email, + ); + + await internalNginx.deleteLetsEncryptRequestConfig( + certificate, + ); + + await internalNginx.reload(); + await internalCertificate.enableInUseHosts(inUseResult); + } catch (err) { + await internalNginx.deleteLetsEncryptRequestConfig( + certificate, + ); + + await internalCertificate.enableInUseHosts(inUseResult); + await internalNginx.reload(); + throw err; + } + } + } + + const certPath = `${livePath}/cert.pem`; + const chainPath = `${livePath}/chain.pem`; + const keyPath = `${livePath}/privkey.pem`; + + const certInfo = + await internalCertificate.getCertificateInfoFromFile( + certPath, + true, + ); + + const certificatePem = fs.readFileSync(certPath, "utf8"); + const intermediateCertificate = fs.readFileSync( + chainPath, + "utf8", + ); + const certificateKey = fs.readFileSync(keyPath, "utf8"); + + const updatedMeta = _.assign({}, certificate.meta, { + certificate: certificatePem, + intermediate_certificate: intermediateCertificate, + certificate_key: certificateKey, + }); + + const previousMeta = _.cloneDeep(certificate.meta); + const previousExpiresOn = certificate.expires_on; + + const stagedCertificate = _.assign({}, certificate, { + meta: updatedMeta, + }); + + try { + await internalCertificate.writeCustomCert(stagedCertificate); + } catch (err) { + try { + await internalCertificate.writeCustomCert(certificate); + } catch (rollbackErr) { + logger.error( + `Failed restoring cluster certificate files after staging failure: ${rollbackErr.message}`, + ); + } + throw err; + } + + let updatedCertificate; + + try { + updatedCertificate = + await certificateModel.query().patchAndFetchById( + certificate.id, + { + expires_on: moment( + certInfo.dates.to, + "X", + ).format("YYYY-MM-DD HH:mm:ss"), + meta: updatedMeta, + }, + ); + + updatedCertificate.meta = updatedMeta; + } catch (err) { + try { + await internalCertificate.writeCustomCert(certificate); + } catch (rollbackErr) { + logger.error( + `Failed restoring cluster certificate files after database failure: ${rollbackErr.message}`, + ); + } + throw err; + } + + try { + await internalNginx.reload(); + } catch (err) { + try { + await certificateModel.query().patchAndFetchById( + certificate.id, + { + expires_on: previousExpiresOn, + meta: previousMeta, + }, + ); + + certificate.meta = previousMeta; + await internalCertificate.writeCustomCert(certificate); + } catch (rollbackErr) { + logger.error( + `Failed rolling back cluster certificate after nginx reload failure: ${rollbackErr.message}`, + ); + } + + throw err; + } + + const auditCertificate = _.cloneDeep(updatedCertificate); + + auditCertificate.meta = internalCertificate.cleanMeta( + auditCertificate.meta, + ); + + await internalAuditLog.add(access, { + action: "renewed", + object_type: "certificate", + object_id: updatedCertificate.id, + meta: auditCertificate, + }); + + return updatedCertificate; + }, + + renewLetsEncryptSsl: async (certificate) => {""" def apply_certificate_js(source: str) -> str: @@ -141,6 +344,17 @@ def apply_certificate_js(source: str) -> str: ) log("cluster_cert_scheduler_foreach=added") + if CLUSTER_RENEW_HELPER_FRAGMENT in source: + log("cluster_cert_renew_helper=already-present") + else: + source = replace_exactly_once( + source, + CLUSTER_RENEW_HELPER_ANCHOR, + CLUSTER_RENEW_HELPER_FRAGMENT, + description="cluster certificate renewal helper", + ) + log("cluster_cert_renew_helper=added") + if CLUSTER_RENEW_THROW_FRAGMENT in source: log("cluster_cert_renew_dispatch=already-present") else: @@ -185,14 +399,51 @@ def verify_certificate_js(source: str) -> None: description="cluster certificate scheduler foreach verification", ) + require_fragment( + source, + CLUSTER_RENEW_HELPER_FRAGMENT, + description="cluster certificate renewal helper verification", + ) + require_fragment( source, CLUSTER_RENEW_THROW_FRAGMENT, description="cluster certificate renewal dispatch verification", ) + require_fragment( + source, + 'fs.readFileSync(certPath, "utf8")', + description="cluster certificate leaf import verification", + ) + + require_fragment( + source, + 'fs.readFileSync(keyPath, "utf8")', + description="cluster certificate private key import verification", + ) + + require_fragment( + source, + "await internalCertificate.writeCustomCert(stagedCertificate);", + description="cluster certificate staged serving copy verification", + ) + + require_fragment( + source, + "previousExpiresOn = certificate.expires_on", + description="cluster certificate rollback expiry verification", + ) + + require_fragment( + source, + "meta: previousMeta", + description="cluster certificate database rollback verification", + ) + log("cluster_cert_upload_domains_verification=passed") log("cluster_cert_scheduler_verification=passed") + log("cluster_cert_renew_helper_verification=passed") log("cluster_cert_renew_dispatch_verification=passed") def apply_schema(source: str) -> str: