oneuptime/CommonServer/Utils/Greenlock/Greenlock.ts

258 lines
7.5 KiB
TypeScript

import {
LetsEncryptAccountKey,
LetsEncryptNotificationEmail,
} from "../../EnvironmentConfig";
import AcmeCertificateService from "../../Services/AcmeCertificateService";
import AcmeChallengeService from "../../Services/AcmeChallengeService";
import QueryHelper from "../../Types/Database/QueryHelper";
import logger from "../Logger";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import LIMIT_MAX from "Common/Types/Database/LimitMax";
import OneUptimeDate from "Common/Types/Date";
import BadDataException from "Common/Types/Exception/BadDataException";
import Exception from "Common/Types/Exception/Exception";
import ServerException from "Common/Types/Exception/ServerException";
import Text from "Common/Types/Text";
import AcmeCertificate from "Model/Models/AcmeCertificate";
import AcmeChallenge from "Model/Models/AcmeChallenge";
import acme from "acme-client";
import { Challenge } from "acme-client/types/rfc8555";
export default class GreenlockUtil {
public static async renewAllCertsWhichAreExpiringSoon(data: {
validateCname: (domain: string) => Promise<boolean>;
notifyDomainRemoved: (domain: string) => Promise<void>;
}): Promise<void> {
logger.debug("Renewing all certificates");
// get all certificates which are expiring soon
const certificates: AcmeCertificate[] = await AcmeCertificateService.findBy(
{
query: {
expiresAt: QueryHelper.lessThanEqualTo(
OneUptimeDate.addRemoveDays(
OneUptimeDate.getCurrentDate(),
40, // 40 days before expiry
),
),
},
limit: LIMIT_MAX,
skip: 0,
select: {
domain: true,
},
sort: {
expiresAt: SortOrder.Ascending,
},
props: {
isRoot: true,
},
},
);
// order certificate for each domain
for (const certificate of certificates) {
if (!certificate.domain) {
continue;
}
try {
//validate cname
const isValidCname: boolean = await data.validateCname(
certificate.domain,
);
if (!isValidCname) {
await GreenlockUtil.orderCert({
domain: certificate.domain,
validateCname: data.validateCname,
});
} else {
await GreenlockUtil.removeDomain(certificate.domain);
await data.notifyDomainRemoved(certificate.domain);
}
} catch (e) {
logger.error(
`Error renewing certificate for domain: ${certificate.domain}`,
);
logger.error(e);
}
}
}
public static async removeDomain(domain: string): Promise<void> {
// remove certificate for this domain.
await AcmeCertificateService.deleteBy({
query: {
domain: domain,
},
limit: 1,
skip: 0,
props: {
isRoot: true,
},
});
}
public static async orderCert(data: {
domain: string;
validateCname: (domain: string) => Promise<boolean>;
}): Promise<void> {
try {
let { domain } = data;
domain = domain.trim().toLowerCase();
const acmeAccountKeyInBase64: string = LetsEncryptAccountKey;
if (!acmeAccountKeyInBase64) {
throw new ServerException(
"No lets encrypt account key found in environment variables. Please add one.",
);
}
let acmeAccountKey: string = Buffer.from(
acmeAccountKeyInBase64,
"base64",
).toString();
acmeAccountKey = Text.replaceAll(acmeAccountKey, "\\n", "\n");
//validate cname
const isValidCname: boolean = await data.validateCname(domain);
if (!isValidCname) {
await GreenlockUtil.removeDomain(domain);
logger.error(`Cname is not valid for domain: ${domain}`);
throw new BadDataException("Cname is not valid for domain " + domain);
}
const client: acme.Client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production,
accountKey: acmeAccountKey,
});
const [certificateKey, certificateRequest] = await acme.crypto.createCsr({
commonName: domain,
});
const certificate: string = await client.auto({
csr: certificateRequest,
email: LetsEncryptNotificationEmail.toString(),
termsOfServiceAgreed: true,
challengePriority: ["http-01"], // only http-01 challenge is supported by oneuptime
challengeCreateFn: async (
authz: acme.Authorization,
challenge: Challenge,
keyAuthorization: string,
) => {
// Satisfy challenge here
/* http-01 */
if (challenge.type === "http-01") {
const acmeChallenge: AcmeChallenge = new AcmeChallenge();
acmeChallenge.challenge = keyAuthorization;
acmeChallenge.token = challenge.token;
acmeChallenge.domain = authz.identifier.value;
await AcmeChallengeService.create({
data: acmeChallenge,
props: {
isRoot: true,
},
});
}
},
challengeRemoveFn: async (
authz: acme.Authorization,
challenge: Challenge,
) => {
// Clean up challenge here
if (challenge.type === "http-01") {
await AcmeChallengeService.deleteBy({
query: {
domain: authz.identifier.value,
},
limit: 1,
skip: 0,
props: {
isRoot: true,
},
});
}
},
});
// get expires at date from certificate
const cert: acme.CertificateInfo =
acme.crypto.readCertificateInfo(certificate);
const issuedAt: Date = cert.notBefore;
const expiresAt: Date = cert.notAfter;
// check if the certificate is already in the database.
const existingCertificate: AcmeCertificate | null =
await AcmeCertificateService.findOneBy({
query: {
domain: domain,
},
select: {
_id: true,
},
props: {
isRoot: true,
},
});
if (existingCertificate) {
// update the certificate
await AcmeCertificateService.updateBy({
query: {
domain: domain,
},
limit: 1,
skip: 0,
data: {
certificate: certificate.toString(),
certificateKey: certificateKey.toString(),
issuedAt: issuedAt,
expiresAt: expiresAt,
},
props: {
isRoot: true,
},
});
} else {
// create the certificate
const acmeCertificate: AcmeCertificate = new AcmeCertificate();
acmeCertificate.domain = domain;
acmeCertificate.certificate = certificate.toString();
acmeCertificate.certificateKey = certificateKey.toString();
acmeCertificate.issuedAt = issuedAt;
acmeCertificate.expiresAt = expiresAt;
await AcmeCertificateService.create({
data: acmeCertificate,
props: {
isRoot: true,
},
});
}
} catch (e) {
logger.error(`Error ordering certificate for domain: ${data.domain}`);
logger.error(e);
if (e instanceof Exception) {
throw e;
}
throw new ServerException(
`Unable to order certificate for ${data.domain}. Please contact support at support@oneuptime.com for more information.`,
);
}
}
}