From 9d180a2dcbd3d8f6cabf93b39b9d77a69caf8c0a Mon Sep 17 00:00:00 2001 From: Simon Larsen Date: Tue, 30 Apr 2024 13:22:06 +0100 Subject: [PATCH] Remove unused Greenlock configuration files and import AcmeCertificate in Model and Service files --- .../Workers/Jobs/StatusPageCerts/.greenlockrc | 1 - .../Services/AcmeCertificateService.ts | 11 + CommonServer/Services/Index.ts | 3 + .../Services/StatusPageDomainService.ts | 17 +- CommonServer/Utils/Greenlock/Greenlock.ts | 215 ++++++++++------- CommonServer/Utils/Greenlock/HttpChallenge.ts | 112 --------- CommonServer/Utils/Greenlock/Manager.ts | 190 --------------- CommonServer/Utils/Greenlock/Store.ts | 223 ------------------ CommonServer/Utils/Greenlock/greenlockrc | 1 - Model/Models/AcmeCertificate.ts | 145 ++++++++++++ Model/Models/Index.ts | 3 + Nginx/Jobs/AcmeWriteCertificates.ts | 145 ++++++++++++ 12 files changed, 438 insertions(+), 628 deletions(-) delete mode 100644 App/FeatureSet/Workers/Jobs/StatusPageCerts/.greenlockrc create mode 100644 CommonServer/Services/AcmeCertificateService.ts delete mode 100644 CommonServer/Utils/Greenlock/HttpChallenge.ts delete mode 100644 CommonServer/Utils/Greenlock/Manager.ts delete mode 100644 CommonServer/Utils/Greenlock/Store.ts delete mode 100644 CommonServer/Utils/Greenlock/greenlockrc create mode 100644 Model/Models/AcmeCertificate.ts create mode 100644 Nginx/Jobs/AcmeWriteCertificates.ts diff --git a/App/FeatureSet/Workers/Jobs/StatusPageCerts/.greenlockrc b/App/FeatureSet/Workers/Jobs/StatusPageCerts/.greenlockrc deleted file mode 100644 index 9e26dfeeb6..0000000000 --- a/App/FeatureSet/Workers/Jobs/StatusPageCerts/.greenlockrc +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/CommonServer/Services/AcmeCertificateService.ts b/CommonServer/Services/AcmeCertificateService.ts new file mode 100644 index 0000000000..c529c9c8f9 --- /dev/null +++ b/CommonServer/Services/AcmeCertificateService.ts @@ -0,0 +1,11 @@ +import PostgresDatabase from '../Infrastructure/PostgresDatabase'; +import Model from 'Model/Models/AcmeCertificate'; +import DatabaseService from './DatabaseService'; + +export class Service extends DatabaseService { + public constructor(postgresDatabase?: PostgresDatabase) { + super(Model, postgresDatabase); + } +} + +export default new Service(); diff --git a/CommonServer/Services/Index.ts b/CommonServer/Services/Index.ts index a50c2d4b1c..8aaac0d9ea 100644 --- a/CommonServer/Services/Index.ts +++ b/CommonServer/Services/Index.ts @@ -139,7 +139,10 @@ import UsageBillingService from './TelemetryUsageBillingService'; import ProjectCallSMSConfigService from './ProjectCallSMSConfigService'; import MonitorMetricsByMinuteService from './MonitorMetricsByMinuteService'; +import AcmeCertificateService from './AcmeCertificateService'; + const services: Array = [ + AcmeCertificateService, PromoCodeService, ResellerService, diff --git a/CommonServer/Services/StatusPageDomainService.ts b/CommonServer/Services/StatusPageDomainService.ts index d0d9e23f61..f42238ea6a 100644 --- a/CommonServer/Services/StatusPageDomainService.ts +++ b/CommonServer/Services/StatusPageDomainService.ts @@ -90,14 +90,14 @@ export class Service extends DatabaseService { } public async orderCert(statusPageDomain: StatusPageDomain): Promise { - if (!statusPageDomain.greenlockConfig) { + if (!statusPageDomain.fullDomain) { const fetchedStatusPageDomain = await this.findOneBy({ query: { _id: statusPageDomain.id!.toString(), }, select: { _id: true, - greenlockConfig: true, + fullDomain: true, }, props: { isRoot: true, @@ -108,16 +108,11 @@ export class Service extends DatabaseService { throw new BadDataException('Domain not found'); } - if (!fetchedStatusPageDomain.greenlockConfig) { - throw new BadDataException('Greenlock config not found'); - } - - statusPageDomain.greenlockConfig = - fetchedStatusPageDomain.greenlockConfig; + } await GreenlockUtil.orderCert( - statusPageDomain.greenlockConfig as JSONObject + statusPageDomain.fullDomain as string ); // update the order. @@ -320,7 +315,7 @@ export class Service extends DatabaseService { }, }); - await GreenlockUtil.addDomain(statusPageDomain.fullDomain!); + await GreenlockUtil.orderCert(statusPageDomain.fullDomain!); await this.updateOneById({ id: statusPageDomain.id!, @@ -476,7 +471,7 @@ export class Service extends DatabaseService { } public async renewCertsWhichAreExpiringSoon(): Promise { - await GreenlockUtil.renewAllCerts(); + await GreenlockUtil.renewAllCertsWhichAreExpiringSoon(); } } export default new Service(); diff --git a/CommonServer/Utils/Greenlock/Greenlock.ts b/CommonServer/Utils/Greenlock/Greenlock.ts index 182b79d2d6..d1a88cffd8 100644 --- a/CommonServer/Utils/Greenlock/Greenlock.ts +++ b/CommonServer/Utils/Greenlock/Greenlock.ts @@ -1,109 +1,144 @@ -import BadDataException from 'Common/Types/Exception/BadDataException'; -import { JSONObject } from 'Common/Types/JSON'; +import acme from 'acme-client'; import { LetsEncryptNotificationEmail } from '../../EnvironmentConfig'; -import StatusPageDomainService from '../../Services/StatusPageDomainService'; +import GreenlockChallenge from 'Model/Models/GreenlockChallenge'; +import GreenlockChallengeService from '../../Services/GreenlockChallengeService'; +import AcmeCertificate from 'Model/Models/AcmeCertificate'; +import AcmeCertificateService from '../../Services/AcmeCertificateService'; import logger from '../Logger'; -import StatusPageDomain from 'Model/Models/StatusPageDomain'; -// @ts-ignore -import Greenlock from 'greenlock'; export default class GreenlockUtil { - private static greenLockInstance: any = null; - private static getGreenlockInstance(): any { - if (this.greenLockInstance) { - return this.greenLockInstance; - } + public static async renewAllCertsWhichAreExpiringSoon(): Promise { - this.greenLockInstance = Greenlock.create({ - configFile: '//usr/src/CommonServer/Utils/Greenlock/greenlockrc', - packageRoot: `/usr/src/CommonServer/greenlock`, - manager: '/usr/src/CommonServer/Utils/Greenlock/Manager.ts', - directoryUrl: 'https://acme-v02.api.letsencrypt.org/directory', - renewOffset: '45d', - renewStagger: '3d', - approveDomains: async (opts: any) => { - const domain: StatusPageDomain | null = - await StatusPageDomainService.findOneBy({ - query: { - fullDomain: opts.domain, - }, - select: { - _id: true, - fullDomain: true, - }, - props: { - isRoot: true, - }, - }); - - if (!domain) { - throw new BadDataException( - `Domain ${opts.domain} does not exist in StatusPageDomain` - ); - } - - return opts; // or Promise.resolve(opts); - }, - store: { - module: '/usr/src/CommonServer/Utils/Greenlock/Store.ts', - }, - // Staging for testing environments - // staging: IsDevelopment, - - // This should be the contact who receives critical bug and security notifications - // Optionally, you may receive other (very few) updates, such as important new features - maintainerEmail: LetsEncryptNotificationEmail.toString(), - - // for an RFC 8555 / RFC 7231 ACME client user agent - packageAgent: 'oneuptime/1.0.0', - - notify: function (event: string, details: any) { - if ('error' === event) { - logger.error('Greenlock Notify: ' + event); - logger.error(details); - } - logger.info('Greenlock Notify: ' + event); - logger.info(details); - }, - - agreeToTerms: true, - challenges: { - 'http-01': { - module: '/usr/src/CommonServer/Utils/Greenlock/HttpChallenge.ts', - }, - }, - }); - - return this.greenLockInstance; + logger.info('Renewing all certificates'); + // TODO: Implement renewAllCerts } public static async removeDomain(domain: string): Promise { - await this.getGreenlockInstance().remove({ - subject: domain, + // remove certificate for this domain. + await AcmeCertificateService.deleteBy({ + query: { + key: domain + }, + limit: 1, + skip: 0, + props: { + isRoot: true + } }); } - public static async addDomain(domain: string): Promise { - await this.getGreenlockInstance().add({ - subject: domain, - altnames: [domain], - }); - } + public static async orderCert(domain: string): Promise { - public static async getCert(domain: string): Promise { - const site: JSONObject = await this.getGreenlockInstance().get({ - servername: domain, + domain = domain.trim().toLowerCase(); + + const client = new acme.Client({ + directoryUrl: acme.directory.letsencrypt.production, + accountKey: await acme.crypto.createPrivateKey() }); - return site; - } + const [certificateKey, certificateRequest] = await acme.crypto.createCsr({ + commonName: domain + }); - public static async renewAllCerts(): Promise { - await this.getGreenlockInstance().renew(); - } + 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, challenge, keyAuthorization) => { + // Satisfy challenge here + /* http-01 */ + if (challenge.type === 'http-01') { - public static async orderCert(greenlockConfig: JSONObject): Promise { - await this.getGreenlockInstance().order(greenlockConfig); + const greenlockChallenge = new GreenlockChallenge(); + greenlockChallenge.challenge = keyAuthorization; + greenlockChallenge.token = challenge.token; + greenlockChallenge.key = authz.identifier.value; + + + await GreenlockChallengeService.create({ + data: greenlockChallenge, + props: { + isRoot: true + } + }); + + } + }, + challengeRemoveFn: async (authz, challenge) => { + // Clean up challenge here + + if (challenge.type === 'http-01') { + + await GreenlockChallengeService.deleteBy({ + query: { + key: authz.identifier.value + }, + limit: 1, + skip: 0, + props: { + isRoot: true + } + }); + } + } + }); + + // get expires at date from certificate + + const cert = await acme.forge.readCertificateInfo(certificate); + const issuedAt = cert.notBefore; + const expiresAt = cert.notAfter; + + + + // check if the certificate is already in the database. + + const existingCertificate: AcmeCertificate | null = await AcmeCertificateService.findOneBy({ + query: { + key: domain + }, + select:{ + _id: true + }, + props: { + isRoot: true + } + }); + + const blob: string = JSON.stringify({ + certificate: certificate.toString(), + certificateKey: certificateKey.toString() + }); + + if(existingCertificate){ + // update the certificate + await AcmeCertificateService.updateBy({ + query: { + key: domain + }, + limit: 1, + skip: 0, + data: { + blob: blob + }, + props: { + isRoot: true + } + }); + } else { + // create the certificate + const AcmeCertificate = new AcmeCertificate(); + AcmeCertificate.key = domain; + AcmeCertificate.blob = blob; + + await AcmeCertificateService.create({ + data: AcmeCertificate, + props: { + isRoot: true + } + }); + } } } diff --git a/CommonServer/Utils/Greenlock/HttpChallenge.ts b/CommonServer/Utils/Greenlock/HttpChallenge.ts deleted file mode 100644 index 9acf179ac4..0000000000 --- a/CommonServer/Utils/Greenlock/HttpChallenge.ts +++ /dev/null @@ -1,112 +0,0 @@ -import GreenlockChallenge from 'Model/Models/GreenlockChallenge'; -import GreenlockChallengeService from '../../Services/GreenlockChallengeService'; -import logger from '../../Utils/Logger'; - -// because greenlock package expects module.exports. -module.exports = { - create: (_opts: any) => { - return { - init: async (): Promise => { - logger.info('Greenlock HTTP Challenge Init'); - return Promise.resolve(null); - }, - - set: async (data: any): Promise => { - logger.info('Greenlock HTTP Challenge Set'); - logger.info(data); - - const ch: any = data.challenge; - const key: string = ch.identifier.value + '#' + ch.token; - const token: string = ch.token; - - let challenge: GreenlockChallenge | null = - await GreenlockChallengeService.findOneBy({ - query: { - key: key, - }, - select: { - _id: true, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - - if (!challenge) { - challenge = new GreenlockChallenge(); - challenge.key = key; - challenge.token = token; - challenge.challenge = ch.keyAuthorization; - - await GreenlockChallengeService.create({ - data: challenge, - props: { - isRoot: true, - }, - }); - } else { - challenge.challenge = ch.keyAuthorization; - challenge.token = token; - await GreenlockChallengeService.updateOneById({ - id: challenge.id!, - data: challenge, - props: { - isRoot: true, - }, - }); - } - - // - return null; - }, - - get: async (data: any): Promise => { - logger.info('Greenlock HTTP Challenge Get'); - logger.info(data); - - const ch: any = data.challenge; - const key: string = ch.identifier.value + '#' + ch.token; - - const challenge: GreenlockChallenge | null = - await GreenlockChallengeService.findOneBy({ - query: { - key: key, - }, - select: { - _id: true, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - - if (!challenge) { - return null; - } - - return { keyAuthorization: challenge.challenge }; - }, - - remove: async (data: any): Promise => { - logger.info('Greenlock HTTP Challenge Remove'); - logger.info(data); - - const ch: any = data.challenge; - const key: string = ch.identifier.value + '#' + ch.token; - await GreenlockChallengeService.deleteOneBy({ - query: { - key: key, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - - return null; - }, - }; - }, -}; diff --git a/CommonServer/Utils/Greenlock/Manager.ts b/CommonServer/Utils/Greenlock/Manager.ts deleted file mode 100644 index c3a67f0574..0000000000 --- a/CommonServer/Utils/Greenlock/Manager.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Docs: https://git.rootprojects.org/root/greenlock-manager.js - -import StatusPageDomainService from '../../Services/StatusPageDomainService'; -import StatusPageDomain from 'Model/Models/StatusPageDomain'; -import logger from '../Logger'; -import GreenlockCertificate from 'Model/Models/GreenlockCertificate'; -import GreenlockCertificateService from '../../Services/GreenlockCertificateService'; -import LIMIT_MAX from 'Common/Types/Database/LimitMax'; -import { JSONObject } from 'Common/Types/JSON'; -import JSONFunctions from 'Common/Types/JSONFunctions'; - -// because greenlock package expects module.exports. -module.exports = { - create: () => { - return { - // Get - get: async ({ - servername, - }: { - servername: string; - }): Promise => { - // Required: find the certificate with the subject of `servername` - // Optional (multi-domain certs support): find a certificate with `servername` as an altname - // Optional (wildcard support): find a certificate with `wildname` as an altname - - // { subject, altnames, renewAt, deletedAt, challenges, ... } - logger.info('Greenlock Manager Get'); - logger.info(servername); - const domain: StatusPageDomain | null = - await StatusPageDomainService.findOneBy({ - query: { - fullDomain: servername, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - select: { - _id: true, - greenlockConfig: true, - }, - }); - - if (!domain || !domain.greenlockConfig) { - logger.info( - 'Greenlock Manager GET ' + - servername + - ' - No domain found.' - ); - return undefined; - } - - logger.info('Greenlock Manager GET ' + servername + ' RESULT'); - logger.info(domain.greenlockConfig); - - return domain.greenlockConfig; - }, - - find: async function (args: any) { - logger.info('Manager Find: '); - logger.info(JSON.stringify(args, null, 2)); - - // { subject, servernames, altnames, renewBefore } - - // i.e. find certs more than 30 days old - args.issuedBefore = Date.now() - 30 * 24 * 60 * 60 * 1000; - // i.e. find certs more that will expire in less than 45 days - args.expiresBefore = Date.now() + 45 * 24 * 60 * 60 * 1000; - const issuedBefore: any = args.issuedBefore || Infinity; - const expiresBefore: any = args.expiresBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000; - const renewBefore: any = args.renewBefore || Infinity; //Date.now() + 21 * 24 * 60 * 60 * 1000; - // if there's anything to match, only return matches - // if there's nothing to match, return everything - const nameKeys: Array = ['subject', 'altnames']; - const matchAll: boolean = !nameKeys.some((k: string) => { - return k in args; - }); - - const querynames: string = (args.altnames || []).slice(0); - - const greenlockCertificates: Array = - await GreenlockCertificateService.findBy({ - query: { - isKeyPair: false, - }, - limit: LIMIT_MAX, - skip: 0, - select: { - blob: true, - key: true, - }, - props: { - isRoot: true, - }, - }); - - const sites: Array = greenlockCertificates - .filter((i: GreenlockCertificate) => { - return i.blob; - }) - .map((i: GreenlockCertificate) => { - return JSONFunctions.parseJSONObject(i.blob!); - }) - .filter((site: any) => { - logger.info('Filter Site: '); - logger.info(site); - if (site.deletedAt) { - logger.info('Filter Site: DeletedAt'); - return false; - } - if (site.expiresAt >= expiresBefore) { - logger.info('Filter Site: expiresAt'); - return false; - } - if (site.issuedAt >= issuedBefore) { - logger.info('Filter Site: issuedAt'); - return false; - } - if (site.renewAt >= renewBefore) { - logger.info('Filter Site: renewAt'); - return false; - } - - // after attribute filtering, before cert filtering - if (matchAll) { - logger.info('Filter Site: MatchAll'); - return true; - } - - // if subject is specified, don't return anything else - if (site.subject === args.subject) { - logger.info('Filter Site: Subject'); - return true; - } - - // altnames, servername, and wildname all get rolled into one - return site.altnames.some((altname: any) => { - return querynames.includes(altname); - }); - }); - - logger.info('Sites: '); - logger.info(sites); - return sites; - }, - - // Set - set: async (opts: any) => { - logger.info('Greenlock Manager Set'); - logger.info(opts); - - // { subject, altnames, renewAt, deletedAt } - // Required: updated `renewAt` and `deletedAt` for certificate matching `subject` - - if (!opts.subject) { - return; - } - - const domain: StatusPageDomain | null = - await StatusPageDomainService.findOneBy({ - query: { - fullDomain: opts['subject'] as string, - }, - props: { - isRoot: true, - }, - select: { - _id: true, - greenlockConfig: true, - }, - }); - - if (!domain) { - return; - } - - await StatusPageDomainService.updateOneById({ - id: domain.id!, - data: { - greenlockConfig: opts, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - }, - }; - }, -}; diff --git a/CommonServer/Utils/Greenlock/Store.ts b/CommonServer/Utils/Greenlock/Store.ts deleted file mode 100644 index 40644e24ef..0000000000 --- a/CommonServer/Utils/Greenlock/Store.ts +++ /dev/null @@ -1,223 +0,0 @@ -/// https://git.rootprojects.org/root/greenlock-store-memory.js/src/branch/master/index.js - -import GreenlockCertificate from 'Model/Models/GreenlockCertificate'; -import GreenlockCertificateService from '../../Services/GreenlockCertificateService'; -import logger from '../../Utils/Logger'; -import JSONFunctions from 'Common/Types/JSONFunctions'; - -type SaveCertificateFunction = ( - id: string, - blob: string, - isKeyPair: boolean -) => Promise; - -type GetCertificateFunction = ( - id: string, - isKeyPair: boolean -) => Promise; - -module.exports = { - create: (_opts: any) => { - const saveCertificate: SaveCertificateFunction = async ( - id: string, - blob: string, - isKeyPair: boolean - ): Promise => { - logger.info('Save Certificates: ' + id); - - let cert: GreenlockCertificate | null = - await GreenlockCertificateService.findOneBy({ - query: { - key: id, - isKeyPair: isKeyPair, - }, - select: { - _id: true, - isKeyPair: isKeyPair, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - - if (!cert) { - cert = new GreenlockCertificate(); - cert.key = id; - cert.blob = blob; - cert.isKeyPair = isKeyPair; - - await GreenlockCertificateService.create({ - data: cert, - props: { - isRoot: true, - }, - }); - } else { - cert.blob = blob; - cert.isKeyPair = isKeyPair; - await GreenlockCertificateService.updateOneById({ - id: cert.id!, - data: { - blob: blob, - isKeyPair: isKeyPair, - }, - props: { - isRoot: true, - }, - }); - } - - // - return null; - }; - - const getCertificate: GetCertificateFunction = async ( - id: string, - isKeyPair: boolean - ): Promise => { - logger.info('Get Certificate - ' + id); - - const cert: GreenlockCertificate | null = - await GreenlockCertificateService.findOneBy({ - query: { - key: id, - isKeyPair: isKeyPair, - }, - select: { - _id: true, - blob: true, - }, - props: { - isRoot: true, - ignoreHooks: true, - }, - }); - - if (!cert || !cert.blob) { - logger.info('Certificate not found'); - return null; - } - - logger.info('Certificate found'); - return cert.blob; - }; - - type SaveKeyPairFunction = (id: string, blob: string) => Promise; - - const saveKeypair: SaveKeyPairFunction = async ( - id: string, - blob: string - ): Promise => { - logger.info('Save Keypair: ' + id); - return await saveCertificate(id, blob, true); - }; - - type GetKeypairFunction = (id: string) => Promise; - - const getKeypair: GetKeypairFunction = async ( - id: string - ): Promise => { - logger.info('Get Keypair: ' + id); - return await getCertificate(id, true); - }; - - return { - accounts: { - // Whenever a new keypair is used to successfully create an account, we need to save its keypair - setKeypair: async (opts: any): Promise => { - logger.info('Accounts Set Keypair: '); - logger.info(JSON.stringify(opts, null, 2)); - const id: string = - opts.account.id || opts.email || 'default'; - const keypair: any = opts.keypair; - - return await saveKeypair(id, JSON.stringify(keypair)); // Must return or Promise `null` instead of `undefined` - }, - // We need a way to retrieve a prior account's keypair for renewals and additional ACME certificate "orders" - checkKeypair: async (opts: any): Promise => { - logger.info('Accounts Check Keypair: '); - logger.info(JSON.stringify(opts, null, 2)); - const id: string = - opts.account.id || opts.email || 'default'; - const keyblob: any = await getKeypair(id); - - if (!keyblob) { - return null; - } - - return JSONFunctions.parse(keyblob); - }, - }, - - certificates: { - setKeypair: async (opts: any): Promise => { - logger.info('Certificates Set Keypair: '); - logger.info(JSON.stringify(opts, null, 2)); - // The ID is a string that doesn't clash between accounts and certificates. - // That's all you need to know... unless you're doing something special (in which case you're on your own). - const id: string = - opts.certificate.kid || - opts.certificate.id || - opts.subject; - const keypair: any = opts.keypair; - - return await saveKeypair(id, JSON.stringify(keypair)); // Must return or Promise `null` instead of `undefined` - // Side Note: you can use the "keypairs" package to convert between - // public and private for jwk and pem, as well as convert JWK <-> PEM - }, - - // You won't be able to use a certificate without it's private key, gotta save it - checkKeypair: async (opts: any): Promise => { - logger.info('Certificates Check Keypair: '); - logger.info(JSON.stringify(opts, null, 2)); - const id: string = - opts.certificate.kid || - opts.certificate.id || - opts.subject; - const keyblob: any = await getKeypair(id); - - if (!keyblob) { - return null; - } - - return JSONFunctions.parse(keyblob); - }, - - // And you'll also need to save certificates. You may find the metadata useful to save - // (perhaps to delete expired keys), but the same information can also be redireved from - // the key using the "cert-info" package. - set: async (opts: any): Promise => { - logger.info('Certificates Set: '); - logger.info(JSON.stringify(opts, null, 2)); - const id: string = opts.certificate.id || opts.subject; - const pems: any = opts.pems; - - return await saveCertificate( - id, - JSON.stringify(pems), - false - ); // Must return or Promise `null` instead of `undefined` - }, - - // This is actually the first thing to be called after approveDomins(), - // but it's easiest to implement last since it's not useful until there - // are certs that can actually be loaded from storage. - check: async (opts: any): Promise => { - logger.info('Certificates Check: '); - logger.info(JSON.stringify(opts, null, 2)); - const id: string = opts.certificate?.id || opts.subject; - const certblob: any = await getCertificate(id, false); - - if (!certblob) { - return null; - } - - return JSONFunctions.parse(certblob); - }, - }, - - options: {}, - }; - }, -}; diff --git a/CommonServer/Utils/Greenlock/greenlockrc b/CommonServer/Utils/Greenlock/greenlockrc deleted file mode 100644 index ec97048a5f..0000000000 --- a/CommonServer/Utils/Greenlock/greenlockrc +++ /dev/null @@ -1 +0,0 @@ -{"manager":"/usr/src/CommonServer/Utils/Greenlock/Manager.ts","configDir":"./greenlock.d"} \ No newline at end of file diff --git a/Model/Models/AcmeCertificate.ts b/Model/Models/AcmeCertificate.ts new file mode 100644 index 0000000000..3ac61aa434 --- /dev/null +++ b/Model/Models/AcmeCertificate.ts @@ -0,0 +1,145 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import BaseModel from 'Common/Models/BaseModel'; +import TableColumnType from 'Common/Types/Database/TableColumnType'; +import TableColumn from 'Common/Types/Database/TableColumn'; +import ColumnType from 'Common/Types/Database/ColumnType'; +import ColumnLength from 'Common/Types/Database/ColumnLength'; +import TableAccessControl from 'Common/Types/Database/AccessControl/TableAccessControl'; +import ColumnAccessControl from 'Common/Types/Database/AccessControl/ColumnAccessControl'; +import TableMetadata from 'Common/Types/Database/TableMetadata'; +import IconProp from 'Common/Types/Icon/IconProp'; +import User from './User'; +import ObjectID from 'Common/Types/ObjectID'; + + +@TableAccessControl({ + create: [], + read: [], + delete: [], + update: [], +}) +@TableMetadata({ + tableName: 'AcmeCertificate', + singularName: 'Acme Certificate', + pluralName: 'Acme Certificate', + icon: IconProp.Lock, + tableDescription: 'Lets Encrypt Certificates', +}) +@Entity({ + name: 'AcmeCertificate', +}) +export default class AcmeCertificate extends BaseModel { + @Index() + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ type: TableColumnType.LongText }) + @Column({ + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + nullable: false, + unique: false, + }) + public domain?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ type: TableColumnType.VeryLongText }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public certificate?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ type: TableColumnType.VeryLongText }) + @Column({ + type: ColumnType.VeryLongText, + nullable: false, + unique: false, + }) + public certificateKey?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ type: TableColumnType.Date }) + @Column({ + type: ColumnType.Date, + nullable: false, + default: false, + unique: false, + }) + public issuedAt?: Date = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ type: TableColumnType.Date }) + @Column({ + type: ColumnType.Date, + nullable: false, + default: false, + unique: false, + }) + public expiresAt?: Date = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: 'deletedByUserId', + type: TableColumnType.Entity, + title: 'Deleted by User', + description: + 'Relation to User who deleted this object (if this object was deleted by a User)', + }) + @ManyToOne( + (_type: string) => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: 'CASCADE', + orphanedRowAction: 'nullify', + } + ) + @JoinColumn({ name: 'deletedByUserId' }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: 'Deleted by User ID', + description: + 'User ID who deleted this object (if this object was deleted by a User)', + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; +} diff --git a/Model/Models/Index.ts b/Model/Models/Index.ts index 750561a501..f1a41e0a6c 100644 --- a/Model/Models/Index.ts +++ b/Model/Models/Index.ts @@ -142,6 +142,7 @@ import OnCallDutyPolicyEscalationRuleSchedule from './OnCallDutyPolicyEscalation import UsageBilling from './TelemetryUsageBilling'; import ProjectCallSMSConfig from './ProjectCallSMSConfig'; +import AcmeCertificate from './AcmeCertificate'; export default [ User, @@ -272,4 +273,6 @@ export default [ UsageBilling, ProjectCallSMSConfig, + + AcmeCertificate ]; diff --git a/Nginx/Jobs/AcmeWriteCertificates.ts b/Nginx/Jobs/AcmeWriteCertificates.ts new file mode 100644 index 0000000000..37e22c0aa0 --- /dev/null +++ b/Nginx/Jobs/AcmeWriteCertificates.ts @@ -0,0 +1,145 @@ +import { + EVERY_FIVE_MINUTE, + EVERY_FIFTEEN_MINUTE, + EVERY_MINUTE, +} from 'Common/Utils/CronTime'; +import BasicCron from 'CommonServer/Utils/BasicCron'; +import { IsDevelopment } from 'CommonServer/EnvironmentConfig'; +// @ts-ignore +import logger from 'CommonServer/Utils/Logger'; +import LIMIT_MAX from 'Common/Types/Database/LimitMax'; +import GreenlockCertificate from 'Model/Models/GreenlockCertificate'; +import GreenlockCertificateService from 'CommonServer/Services/GreenlockCertificateService'; +import LocalFile from 'CommonServer/Utils/LocalFile'; +import StatusPageDomain from 'Model/Models/StatusPageDomain'; +import StatusPageDomainService from 'CommonServer/Services/StatusPageDomainService'; +import SelfSignedSSL from '../Utils/SelfSignedSSL'; + +export default class Jobs { + public static init(): void { + BasicCron({ + jobName: 'StatusPageCerts:WriteAcmeCertsToDisk', + options: { + schedule: IsDevelopment ? EVERY_MINUTE : EVERY_FIFTEEN_MINUTE, + runOnStartup: true, + }, + runFunction: async () => { + // Fetch all domains where certs are added to greenlock. + + const certs: Array = + await GreenlockCertificateService.findBy({ + query: {}, + select: { + key: true, + blob: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: { + isRoot: true, + }, + }); + + for (const cert of certs) { + + + try { + await LocalFile.makeDirectory( + '/etc/nginx/certs/StatusPageCerts' + ); + } catch (err) { + // directory already exists, ignore. + logger.error('Create directory err'); + logger.error(err); + } + + const certBlob = JSON.parse(cert.blob!); + + // Write to disk. + await LocalFile.write( + `/etc/nginx/certs/StatusPageCerts/${cert.key}.crt`, + certBlob['certificate'] + ); + + await LocalFile.write( + `/etc/nginx/certs/StatusPageCerts/${cert.key}.key`, + certBlob['certificateKey'] + ); + } + }, + }); + + BasicCron({ + jobName: 'StatusPageCerts:WriteSelfSignedCertsToDisk', + options: { + schedule: IsDevelopment ? EVERY_MINUTE : EVERY_FIVE_MINUTE, + runOnStartup: true, + }, + runFunction: async () => { + // Fetch all domains where certs are added to greenlock. + + const certs: Array = + await GreenlockCertificateService.findBy({ + query: {}, + select: { + key: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: { + isRoot: true, + }, + }); + + const statusPageDomains: Array = + await StatusPageDomainService.findBy({ + query: { + isSelfSignedSslGenerated: false, + }, + select: { + fullDomain: true, + _id: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: { + isRoot: true, + ignoreHooks: true, + }, + }); + + const greenlockCertDomains: Array = + certs.map((cert: GreenlockCertificate) => { + return cert.key; + }); + + // Generate self signed certs + for (const domain of statusPageDomains) { + if (greenlockCertDomains.includes(domain.fullDomain)) { + continue; + } + + if (!domain.fullDomain) { + continue; + } + + await SelfSignedSSL.generate( + '/etc/nginx/certs/StatusPageCerts', + domain.fullDomain + ); + + await StatusPageDomainService.updateOneById({ + id: domain.id!, + data: { + isSelfSignedSslGenerated: true, + }, + props: { + ignoreHooks: true, + isRoot: true, + }, + }); + } + }, + }); + } +}