@softvisio/core
Version:
Softisio core
673 lines (517 loc) • 18.7 kB
JavaScript
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import Acme from "#lib/api/acme";
import fetch from "#lib/fetch";
import { exists } from "#lib/fs";
import Interval from "#lib/interval";
import sql from "#lib/sql";
import Mutex from "#lib/threads/mutex";
import { TmpFile } from "#lib/tmp";
import { sleep } from "#lib/utils";
const HTTP_LOCATION = "/.well-known/acme-challenge/";
const SQL = {
"getAcmeAccount": sql`SELECT url, key FROM acme_account WHERE email = ? AND test = ?`,
"upsertAccount": sql`
INSERT INTO acme_account
( email, test, url, key )
VALUES
( ?, ?, ?, ? )
ON CONFLICT ( email, test ) DO UPDATE SET
url = EXCLUDED.url,
key = EXCLUDED.key
`,
"getCertificate": sql`SELECT certificate, key FROM acme_certificate WHERE domains = ? AND test = ? AND expires > CURRENT_TIMESTAMP`,
"upsertCertificate": sql`
INSERT INTO acme_certificate
( domains, test, expires, certificate, key )
VALUES
( ?, ?, ?, ?, ? )
ON CONFLICT ( domains, test ) DO UPDATE SET
expires = EXCLUDED.expires,
certificate = EXCLUDED.certificate,
key = EXCLUDED.key
`,
"getChallenge": sql`SELECT content FROM acme_challenge WHERE id = ? AND expires > CURRENT_TIMESTAMP`.prepare(),
"insertChallenge": sql`INSERT INTO acme_challenge ( id, content, expires ) VALUES ( ?, ?, ? )`,
"deleteChallenge": sql`DELETE FROM acme_challenge WHERE id = ?`,
};
export default class {
#app;
#config;
#dataDir;
#acme;
#cloudFlareApi;
#mutexes = new Mutex.Set();
#certificatesRenewInterval;
#accountPath;
#challenges = {};
constructor ( app, config ) {
this.#app = app;
this.#config = config;
this.#certificatesRenewInterval = new Interval( this.#config.certificatesRenewInterval );
this.#dataDir = this.#app.env.dataDir + "/acme/" + ( this.#config.test
? "test"
: "production" );
this.#accountPath = this.#dataDir + `/accounts/${ this.#config.email }.json`;
}
// properties
get app () {
return this.#app;
}
get dbh () {
return this.app.dbh;
}
get httpLocation () {
return HTTP_LOCATION;
}
get useLocalStorage () {
return this.#config.useLocalStorage;
}
get httpEnabled () {
return this.#config.httpEnabled;
}
get dnsEnabled () {
return this.#config.dnsEnabled;
}
// public
async configure () {
// configure useLocalStorage
if ( this.#config.useLocalStorage == null ) {
if ( this.app.dbh && this.app.cluster ) {
this.#config.useLocalStorage = false;
}
else {
this.#config.useLocalStorage = true;
}
}
// check useLocalStorage
if ( !this.#config.useLocalStorage ) {
if ( !this.app.dbh ) {
return result( [ 400, "DBH component is required to use shared storage" ] );
}
if ( !this.app.cluster ) {
return result( [ 400, "Cluster component is required to use shared storage" ] );
}
}
return result( 200 );
}
async init () {
if ( this.useLocalStorage ) {
console.log( "[acme] use local storage" );
}
else {
console.log( "[acme] use shared storage" );
}
// init db
if ( this.app.dbh ) {
res = await this.app.dbh.schema.migrate( new URL( "db", import.meta.url ) );
if ( !res.ok ) return res;
}
// configure http servers
if ( this.httpEnabled ) {
if ( this.app.publicHttpServer ) {
this.app.publicHttpServer.get( HTTP_LOCATION + "*", this.#downloadAcmeChallenge.bind( this ) );
this.app.publicHttpServer.head( HTTP_LOCATION + "test", this.#testAcmeChallenge.bind( this ) );
}
if ( this.app.privateHttpServer ) {
this.app.privateHttpServer.get( HTTP_LOCATION + "*", this.#downloadAcmeChallenge.bind( this ) );
this.app.privateHttpServer.head( HTTP_LOCATION + "test", this.#testAcmeChallenge.bind( this ) );
}
}
// create Cloudflare API
if ( this.#config.dnsEnabled && this.app.services?.has( this.#config.cloudFlareServiceName ) ) {
this.#cloudFlareApi = this.app.services?.get( this.#config.cloudFlareServiceName );
}
const mutex = this.#getMutex( "init" ),
locked = await mutex.tryLock();
if ( !locked ) return mutex.wait();
var res;
ACCOUNT: {
// get accounnt key from storage
res = await this.#getAcmeAccount();
if ( !res.ok ) return res;
if ( res.data ) {
var acmeAccount = res.data;
}
this.#acme = new Acme( {
"email": this.#config.email,
"test": this.#config.test,
"accountKey": acmeAccount?.accountKey,
"accountUrl": acmeAccount?.accountUrl,
} );
// create acme account
res = await this.#acme.createAccount();
if ( !res.ok ) break ACCOUNT;
// store account key
if ( !acmeAccount ) {
res = await this.#storeAcmeAccount( this.#acme.accountUrl, this.#acme.accountKey );
if ( !res.ok ) break ACCOUNT;
}
res = result( 200 );
}
await mutex.unlock( res );
return res;
}
async getCertificate ( domains, { pem } = {} ) {
if ( !Array.isArray( domains ) ) domains = [ domains ];
domains = domains.sort();
const id = domains.join( "," );
const mutex = this.#getMutex( "get-certificate/" + id );
await mutex.lock();
var res;
CERTIFICATE: {
// get stored certificate
res = await this.#getStoredCertificate( id, {
"checkRenewInterval": true,
pem,
} );
// stored certificate is valid
if ( res.ok ) break CERTIFICATE;
// get certificate
res = await this.#acme.getCertificate( {
domains,
"checkDomain": this.#checkDomain.bind( this ),
"createChallenge": this.#createChallenge.bind( this ),
"deleteChallenge": this.#deleteChallenge.bind( this ),
} );
if ( res.ok ) {
console.info( `[acme] updated certificates for domains: ${ domains }` );
}
else {
break CERTIFICATE;
}
// upload certificate
res = await this.#uploadCertificate( {
id,
"certificate": res.data.certificate,
"privateKey": res.data.privateKey,
"expires": res.data.expires,
} );
if ( !res.ok ) break CERTIFICATE;
// get stored certificate
res = await this.#getStoredCertificate( id, {
pem,
} );
}
await mutex.unlock();
return res;
}
canGetCertificate ( serverName ) {
return Acme.canGetCertificate( serverName );
}
// private
async #checkDomain ( { name, isWildcard, domain, dnsTxtRecordName, resolved } ) {
// check http
if ( resolved && this.httpEnabled ) {
const url = "http://" + domain + HTTP_LOCATION + "test",
hostHash = this.#getHostHash( domain );
let attempts = 3;
TEST: while ( attempts ) {
const res = await fetch( url, {
"method": "head",
} );
if ( res.ok ) {
if ( res.headers.get( "x-acme-test" ) === hostHash ) {
return true;
}
else {
break TEST;
}
}
await sleep( 3000 );
attempts--;
}
}
// check dns
if ( this.#cloudFlareApi ) {
// get zone
const res = await this.#getDomainZone( domain );
// domains zone found
if ( res.ok ) return true;
}
return false;
}
async #createChallenge ( { type, domain, dnsTxtRecordName, httpLocation, token, content } ) {
var res;
// http
if ( type === "http-01" ) {
if ( !this.httpEnabled ) return result( 500 );
if ( this.useLocalStorage ) {
this.#challenges[ token ] = content;
return result( 200 );
}
else {
const res = await this.dbh.do( SQL.insertChallenge, [
//
token,
content,
new Interval( this.#config.challengeMaxAge ).addDate(),
] );
return res;
}
}
// dns
else if ( type === "dns-01" ) {
if ( !this.#cloudFlareApi ) return result( 500 );
// get zone
res = await this.#getDomainZone( domain );
if ( !res.ok ) return res;
const zone = res.data;
// delete record, if exists
await this.#deleteDnsRecord( dnsTxtRecordName, zone );
// create record
res = await this.#cloudFlareApi.createDnsRecord( zone.id, {
"type": "TXT",
"name": dnsTxtRecordName,
content,
"ttl": 60,
} );
if ( !res.ok ) return res;
return result( 200, {
"dnsTtl": res.data.ttl,
} );
}
// not supported
else {
return result( 500 );
}
}
async #deleteChallenge ( { type, domain, dnsTxtRecordName, token, httpLocation } ) {
// http
if ( type === "http-01" ) {
if ( this.useLocalStorage ) {
delete this.#challenges[ token ];
}
else {
await this.dbh.do( SQL.deleteChallenge, [ token ] );
}
}
// dns
else if ( type === "dns-01" ) {
if ( !this.#cloudFlareApi ) return;
var res;
// get zone
res = await this.#getDomainZone( domain );
if ( !res.ok ) return;
const zone = res.data;
await this.#deleteDnsRecord( dnsTxtRecordName, zone );
}
// not supported
else {
return;
}
}
async #getStoredCertificate ( id, { checkRenewInterval, pem } = {} ) {
var certificate, privateKey;
if ( this.useLocalStorage ) {
const certificatePath = this.#createCertificatePath( id );
if ( !( await exists( certificatePath ) ) ) return result( 404 );
( { certificate, privateKey } = JSON.parse( await fs.promises.readFile( certificatePath ) ) );
certificate = await this.app.crypto.decrypt( certificate, { "inputEncoding": "base64url" } );
privateKey = await this.app.crypto.decrypt( privateKey, { "inputEncoding": "base64url" } );
}
else {
const res = await this.dbh.selectRow( SQL.getCertificate, [
//
id,
this.#config.test,
] );
if ( !res.ok ) return res;
if ( !res.data ) return result( 404 );
certificate = await this.app.crypto.decrypt( res.data.certificate );
privateKey = await this.app.crypto.decrypt( res.data.key );
}
const x509Certificate = new crypto.X509Certificate( certificate ),
expires = new Date( x509Certificate.validTo );
if ( expires <= new Date() ) result( 404 );
if ( checkRenewInterval ) {
// renew is required
if ( this.#certificatesRenewInterval.addDate() >= expires ) return result( 404 );
}
if ( pem ) {
return result( 200, {
"certificate": certificate.toString( "latin1" ),
"privateKey": privateKey.toString( "latin1" ),
"fingerprint": x509Certificate.fingerprint512,
expires,
} );
}
else {
const certtificateTmpFile = new TmpFile(),
privateKeyTmpFile = new TmpFile();
fs.writeFileSync( certtificateTmpFile.path, certificate );
fs.writeFileSync( privateKeyTmpFile.path, privateKey );
return result( 200, {
"certificatePath": certtificateTmpFile,
"privateKeyPath": privateKeyTmpFile,
"fingerprint": x509Certificate.fingerprint512,
expires,
} );
}
}
async #uploadCertificate ( { id, certificate, privateKey, expires } ) {
certificate = await this.app.crypto.encrypt( certificate );
privateKey = await this.app.crypto.encrypt( privateKey );
if ( this.useLocalStorage ) {
const certificatePath = this.#createCertificatePath( id );
fs.mkdirSync( path.dirname( certificatePath ), {
"recursive": true,
} );
fs.writeFileSync(
certificatePath,
JSON.stringify( {
"domains": id,
"test": this.#config.test,
expires,
"certificate": certificate.toString( "base64url" ),
"privateKey": privateKey.toString( "base64url" ),
} )
);
return result( 200 );
}
else {
return this.dbh.do( SQL.upsertCertificate, [
//
id,
this.#config.test,
expires,
certificate,
privateKey,
] );
}
}
#createCertificatePath ( id ) {
return this.#dataDir + "/certificates/" + id.replaceAll( "*", "" ) + ".json";
}
#getMutex ( id ) {
id = "/acme/" + id;
if ( this.useLocalStorage ) {
return this.#mutexes.get( id );
}
else {
return this.app.cluster.mutexes.get( id );
}
}
async #getDomainZone ( domain ) {
const res = await this.#cloudFlareApi.getZones();
if ( !res.ok ) return res;
for ( const zone of res.data ) {
if ( domain === zone.name || domain.endsWith( `.${ zone.name }` ) ) {
return result( 200, zone );
}
}
return result( [ 404, "Domain zone not found" ] );
}
async #deleteDnsRecord ( dnsTxtRecordName, zone ) {
var res;
// get records
res = await this.#cloudFlareApi.getDnsRecords( zone.id );
if ( !res.ok ) return;
// delete record
for ( const record of res.data ) {
if ( record.type !== "TXT" ) continue;
if ( record.name !== dnsTxtRecordName ) continue;
res = await this.#cloudFlareApi.deleteDnsRecord( zone.id, record.id );
return;
}
}
#getHostHash ( host ) {
return crypto.createHmac( "SHA256", this.#acme.accountKey ).update( host ).digest( "base64url" );
}
async #downloadAcmeChallenge ( req ) {
const id = req.path.slice( HTTP_LOCATION.length );
var challenge;
if ( this.useLocalStorage ) {
challenge = this.#challenges[ id ];
}
else {
const res = await this.dbh.selectRow( SQL.getChallenge, [
//
id,
] );
if ( !res.ok ) return req.end( res );
challenge = res.data?.content;
}
if ( !challenge ) return req.end( 404 );
return req.end( {
"status": 200,
"headers": {
"cache-control": "no-cache",
},
"body": challenge,
} );
}
async #testAcmeChallenge ( req ) {
const host = req.headers.get( "host" );
if ( !host ) {
return req.end( 400 );
}
else {
return req.end( {
"status": 200,
"headers": {
"x-acme-test": this.#getHostHash( host ),
},
} );
}
}
async #getAcmeAccount () {
var account;
if ( this.useLocalStorage ) {
if ( await exists( this.#accountPath ) ) {
account = JSON.parse( fs.readFileSync( this.#accountPath ) );
account.accountKey = Buffer.from( account.accountKey, "base64url" );
}
}
else {
const res = await this.dbh.selectRow( SQL.getAcmeAccount, [
//
this.#config.email,
this.#config.test,
] );
if ( !res.ok ) return res;
if ( res.data ) {
account = {
"accountUrl": res.data.url,
"accountKey": res.data.key,
};
}
}
if ( account?.accountKey ) {
account.accountKey = await this.app.crypto.decrypt( account.accountKey );
}
return result( 200, account );
}
async #storeAcmeAccount ( accountUrl, accountKey ) {
accountKey = await this.app.crypto.encrypt( crypto.createPrivateKey( accountKey ).export( {
"type": "pkcs8",
"format": "der",
} ) );
if ( this.useLocalStorage ) {
fs.mkdirSync( path.dirname( this.#accountPath ), {
"recursive": true,
} );
fs.writeFileSync(
this.#accountPath,
JSON.stringify( {
"email": this.#config.email,
"test": this.#config.test,
accountUrl,
"accountKey": accountKey.toString( "base64url" ),
} )
);
}
else {
const res = await this.dbh.do( SQL.upsertAccount, [
//
this.#config.email,
this.#config.test,
accountUrl,
accountKey,
] );
if ( !res.ok ) return res;
}
return result( 200 );
}
}