Extract certnode & stuffs out of app.js
This commit is contained in:
parent
ed3caed0eb
commit
43841ff081
13 changed files with 868 additions and 184 deletions
5
src/certnode/LICENSE
Normal file
5
src/certnode/LICENSE
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Copyright 2021 Zachary Balder
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
3
src/certnode/README.md
Normal file
3
src/certnode/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# certnode
|
||||
|
||||
Modified certnode. Please see https://github.com/zbo14/certnode
|
||||
469
src/certnode/lib/client.js
Normal file
469
src/certnode/lib/client.js
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
const fs = require('fs')
|
||||
const http = require('http')
|
||||
const path = require('path')
|
||||
const { promisify } = require('util')
|
||||
const { fromKeyLike } = require('jose-node-cjs-runtime/jwk/from_key_like')
|
||||
const { generateKeyPair } = require('jose-node-cjs-runtime/util/generate_key_pair')
|
||||
const { calculateThumbprint } = require('jose-node-cjs-runtime/jwk/thumbprint')
|
||||
const { SignJWT } = require('jose-node-cjs-runtime/jwt/sign')
|
||||
const { CompactSign } = require('jose-node-cjs-runtime/jws/compact/sign')
|
||||
const pem = require('pem')
|
||||
const common = require('./common')
|
||||
const request = require('./request')
|
||||
|
||||
const createCsr = promisify(pem.createCSR)
|
||||
|
||||
/**
|
||||
* Represents a Let's Encrypt account and
|
||||
* sends requests to get valid TLS certificates.
|
||||
*/
|
||||
class Client {
|
||||
/**
|
||||
* @param {String} [directoryUrl]
|
||||
*/
|
||||
constructor (directoryUrl = common.DIRECTORY_URL) {
|
||||
this.accountPrivateJwk = null
|
||||
this.accountPrivateKey = null
|
||||
this.accountPublicJwk = null
|
||||
this.accountPublicKey = null
|
||||
this.directoryUrl = directoryUrl
|
||||
this.hasDirectory = false
|
||||
this.myAccountUrl = ''
|
||||
this.newAccountUrl = ''
|
||||
this.newNonceUrl = ''
|
||||
this.newOrderUrl = ''
|
||||
this.replayNonce = ''
|
||||
this.server = null
|
||||
this.thumbprint = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Export account public and private keys to a directory.
|
||||
*
|
||||
* @param {String} dirname - name of directory to write key files to
|
||||
* @param {String} [passphrase] - optional passphrase to encrypt private key with
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
exportAccountKeyPair (dirname, passphrase) {
|
||||
const privateKeyFile = path.join(dirname, 'privateKey.pem')
|
||||
const publicKeyFile = path.join(dirname, 'publicKey.pem')
|
||||
|
||||
return Promise.all([
|
||||
common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase),
|
||||
common.writeKeyToFile(publicKeyFile, this.accountPublicKey)
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new account public and private keys.
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async generateAccountKeyPair () {
|
||||
const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM)
|
||||
|
||||
this.accountPrivateKey = privateKey
|
||||
this.accountPublicKey = publicKey
|
||||
|
||||
await this.initAccountJwks()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a certificate from Let's Encrypt for your domain.
|
||||
*
|
||||
* @param {String} domain - the domain you want a certificate for
|
||||
* @param {String} email - the email used to register the certificate
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async generateCertificate (domain, email) {
|
||||
await this.directory()
|
||||
await this.newNonce()
|
||||
await this.newAccount(email)
|
||||
|
||||
const { authzUrls, finalizeUrl } = await this.newOrder(domain)
|
||||
const { challenge } = await this.authz(authzUrls[0])
|
||||
|
||||
await this.completeChallenge(challenge)
|
||||
await this.pollAuthz(authzUrls[0])
|
||||
const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain, email)
|
||||
|
||||
this.server?.close()
|
||||
|
||||
return { certificate, privateKeyData }
|
||||
}
|
||||
|
||||
/**
|
||||
* Import account public and private keys from a directory.
|
||||
*
|
||||
* @param {String} dirname - name of directory to read key files from
|
||||
* @param {String} [passphrase] - optional passphrase to decrypt private key with
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
async importAccountKeyPair (dirname, passphrase) {
|
||||
const [privateKeyData, publicKeyData] = await Promise.all([
|
||||
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'),
|
||||
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
|
||||
])
|
||||
|
||||
this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase)
|
||||
this.accountPublicKey = common.importPublicKey(publicKeyData)
|
||||
|
||||
await this.initAccountJwks()
|
||||
}
|
||||
|
||||
async authz (authzUrl) {
|
||||
const data = await this.sign(
|
||||
{
|
||||
kid: this.myAccountUrl,
|
||||
nonce: this.replayNonce,
|
||||
url: authzUrl
|
||||
}
|
||||
)
|
||||
|
||||
const res = await request(authzUrl, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
const { challenges, identifier, ...rest } = res.data
|
||||
const challenge = challenges.find(({ type }) => type === 'http-01')
|
||||
|
||||
return {
|
||||
challenge,
|
||||
domain: identifier.value,
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
async completeChallenge (challenge, cb) {
|
||||
await this.readyChallenge(challenge)
|
||||
await this.receiveServerRequest(challenge, cb)
|
||||
}
|
||||
|
||||
async directory () {
|
||||
if (this.hasDirectory) return false
|
||||
|
||||
const res = await request(this.directoryUrl)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`directory() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
this.hasDirectory = true
|
||||
this.newAccountUrl = res.data.newAccount
|
||||
this.newNonceUrl = res.data.newNonce
|
||||
this.newOrderUrl = res.data.newOrder
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async fetchCertificate (certificateUrl) {
|
||||
const data = await this.sign({
|
||||
kid: this.myAccountUrl,
|
||||
nonce: this.replayNonce,
|
||||
url: certificateUrl
|
||||
})
|
||||
|
||||
const res = await request(certificateUrl, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
accept: 'application/pem-certificate-chain',
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`fetchCertificate() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
return res.data
|
||||
}
|
||||
|
||||
async finalizeOrder (finalizeUrl, domain, email) {
|
||||
const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM)
|
||||
const clientKey = common.exportPrivateKey(privateKey)
|
||||
let { csr } = await createCsr({ clientKey, commonName: domain, email })
|
||||
|
||||
// "The CSR is sent in the base64url-encoded version of the DER format.
|
||||
// (Note: Because this field uses base64url, and does not include headers,
|
||||
// it is different from PEM.)"
|
||||
csr = csr
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.slice(1, -1)
|
||||
.join('')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '')
|
||||
|
||||
const data = await this.sign(
|
||||
{
|
||||
kid: this.myAccountUrl,
|
||||
nonce: this.replayNonce,
|
||||
url: finalizeUrl
|
||||
},
|
||||
{
|
||||
csr
|
||||
}
|
||||
)
|
||||
|
||||
const res = await request(finalizeUrl, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
const certificate = await this.fetchCertificate(res.data.certificate)
|
||||
|
||||
return { certificate, privateKeyData: clientKey }
|
||||
}
|
||||
|
||||
async initAccountJwks () {
|
||||
const [publicJwk, accountPrivateJwk] = await Promise.all([
|
||||
fromKeyLike(this.accountPublicKey),
|
||||
fromKeyLike(this.accountPrivateKey)
|
||||
])
|
||||
|
||||
this.accountPublicJwk = publicJwk
|
||||
this.accountPrivateJwk = accountPrivateJwk
|
||||
this.thumbprint = await calculateThumbprint(publicJwk)
|
||||
}
|
||||
|
||||
async newAccount (...emails) {
|
||||
const data = await this.sign(
|
||||
{
|
||||
jwk: this.accountPublicJwk,
|
||||
nonce: this.replayNonce,
|
||||
url: this.newAccountUrl
|
||||
},
|
||||
{
|
||||
contact: emails.map(email => 'mailto:' + email),
|
||||
termsOfServiceAgreed: true
|
||||
}
|
||||
)
|
||||
|
||||
const res = await request(this.newAccountUrl, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (![200, 201].includes(res.statusCode)) {
|
||||
throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
this.myAccountUrl = res.headers.location
|
||||
|
||||
return res.statusCode === 201
|
||||
}
|
||||
|
||||
async newNonce () {
|
||||
if (this.replayNonce) return false
|
||||
|
||||
const res = await request(this.newNonceUrl, { method: 'HEAD' })
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async newOrder (...domains) {
|
||||
const identifiers = domains.map(domain => ({ type: 'dns', value: domain }))
|
||||
|
||||
const data = await this.sign(
|
||||
{
|
||||
kid: this.myAccountUrl,
|
||||
nonce: this.replayNonce,
|
||||
url: this.newOrderUrl
|
||||
},
|
||||
{
|
||||
identifiers
|
||||
}
|
||||
)
|
||||
|
||||
const res = await request(this.newOrderUrl, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (res.statusCode !== 201) {
|
||||
throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
|
||||
const orderUrl = res.headers.location
|
||||
const { authorizations: authzUrls, finalize: finalizeUrl } = res.data
|
||||
|
||||
return {
|
||||
authzUrls,
|
||||
domains,
|
||||
finalizeUrl,
|
||||
orderUrl
|
||||
}
|
||||
}
|
||||
|
||||
async pollAuthz (authzUrl) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = await this.authz(authzUrl)
|
||||
|
||||
if (result.status === 'pending') {
|
||||
await new Promise(resolve => setTimeout(resolve, 1e3))
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.status === 'invalid') {
|
||||
throw new Error('pollAuthz() authorization is invalid: ' + JSON.stringify(result, null, 2))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
throw new Error('pollAuthz() timed out')
|
||||
}
|
||||
|
||||
async readyChallenge (challenge) {
|
||||
const data = await this.sign(
|
||||
{
|
||||
kid: this.myAccountUrl,
|
||||
nonce: this.replayNonce,
|
||||
url: challenge.url
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const res = await request(challenge.url, {
|
||||
method: 'POST',
|
||||
|
||||
headers: {
|
||||
'content-type': 'application/jose+json'
|
||||
},
|
||||
|
||||
data
|
||||
})
|
||||
|
||||
this.setReplayNonce(res)
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`)
|
||||
}
|
||||
}
|
||||
|
||||
receiveServerRequest (challenge, cb) {
|
||||
this.server?.close()
|
||||
this.server = http.createServer()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server
|
||||
.once('error', reject)
|
||||
.on('request', (req, res) => {
|
||||
if (req.method !== 'GET') {
|
||||
res.writeHead(405)
|
||||
res.writeHead(http.STATUS_CODES[405])
|
||||
return
|
||||
}
|
||||
|
||||
if (req.url !== '/.well-known/acme-challenge/' + challenge.token) {
|
||||
res.writeHead(404)
|
||||
res.end(http.STATUS_CODES[404])
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'content-type': 'application/octet-stream'
|
||||
})
|
||||
|
||||
res.end(challenge.token + '.' + this.thumbprint)
|
||||
resolve()
|
||||
})
|
||||
|
||||
this.server.listen(80, '0.0.0.0')
|
||||
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for server request'))
|
||||
}, 10e3)
|
||||
|
||||
cb && cb()
|
||||
})
|
||||
}
|
||||
|
||||
setReplayNonce (res) {
|
||||
const replayNonce = (res.headers['replay-nonce'] || '').trim()
|
||||
|
||||
if (!replayNonce) {
|
||||
throw new Error('No Replay-Nonce header in response')
|
||||
}
|
||||
|
||||
this.replayNonce = replayNonce
|
||||
}
|
||||
|
||||
async sign (header, payload) {
|
||||
let data
|
||||
|
||||
if (payload) {
|
||||
data = await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header })
|
||||
.sign(this.accountPrivateKey)
|
||||
} else {
|
||||
// SignJWT constructor only accepts object but RFC8555 requires empty payload
|
||||
// Workaround: manually pass empty Uint8Array to CompactSign constructor
|
||||
const sig = new CompactSign(new Uint8Array())
|
||||
sig.setProtectedHeader({ alg: common.ACCOUNT_KEY_ALGORITHM, ...header })
|
||||
data = await sig.sign(this.accountPrivateKey)
|
||||
}
|
||||
|
||||
const [b64Header, b64Payload, b64Signature] = data.split('.')
|
||||
|
||||
return JSON.stringify({
|
||||
protected: b64Header,
|
||||
payload: b64Payload,
|
||||
signature: b64Signature
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Client
|
||||
139
src/certnode/lib/common.js
Normal file
139
src/certnode/lib/common.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
|
||||
const ACCOUNT_KEY_ALGORITHM = 'ES256'
|
||||
const CERTIFICATE_KEY_ALGORITHM = 'RS256'
|
||||
|
||||
const env = (process.env.NODE_ENV || '').trim().toLowerCase()
|
||||
|
||||
const DIRECTORY_URL = ['development', 'test'].includes(env)
|
||||
? 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||
: 'https://acme-v02.api.letsencrypt.org/directory'
|
||||
|
||||
const PRIVATE_KEY_CIPHER = 'aes-256-cbc'
|
||||
const PRIVATE_KEY_FORMAT = 'pem'
|
||||
const PRIVATE_KEY_PERMISSIONS = 0o600
|
||||
const PRIVATE_KEY_TYPE = 'pkcs8'
|
||||
|
||||
const PUBLIC_KEY_FORMAT = 'pem'
|
||||
const PUBLIC_KEY_PERMISSIONS = 0o666
|
||||
const PUBLIC_KEY_TYPE = 'spki'
|
||||
|
||||
/**
|
||||
* @param {crypto.KeyObject} privateKey
|
||||
* @param {String} [passphrase]
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
const exportPrivateKey = (privateKey, passphrase) => {
|
||||
const privateKeyOpts = {
|
||||
type: PRIVATE_KEY_TYPE,
|
||||
format: PRIVATE_KEY_FORMAT
|
||||
}
|
||||
|
||||
if (passphrase) {
|
||||
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER
|
||||
privateKeyOpts.passphrase = passphrase
|
||||
}
|
||||
|
||||
return privateKey.export(privateKeyOpts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {crypto.KeyObject} publicKey
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
const exportPublicKey = publicKey => {
|
||||
return publicKey.export({
|
||||
type: PUBLIC_KEY_TYPE,
|
||||
format: PUBLIC_KEY_FORMAT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} privateKeyData
|
||||
* @param {String} [passphrase]
|
||||
*
|
||||
* @return {String}
|
||||
*/
|
||||
const importPrivateKey = (privateKeyData, passphrase) => {
|
||||
const privateKeyOpts = {
|
||||
key: privateKeyData,
|
||||
format: PRIVATE_KEY_FORMAT,
|
||||
type: PRIVATE_KEY_TYPE
|
||||
}
|
||||
|
||||
if (passphrase) {
|
||||
privateKeyOpts.passphrase = passphrase
|
||||
}
|
||||
|
||||
try {
|
||||
return crypto.createPrivateKey(privateKeyOpts)
|
||||
} catch {
|
||||
throw new Error('Failed to import private key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} publicKeyData
|
||||
*
|
||||
* @return {crypto.KeyObject}
|
||||
*/
|
||||
const importPublicKey = publicKeyData => {
|
||||
try {
|
||||
return crypto.createPublicKey({
|
||||
key: publicKeyData,
|
||||
format: PUBLIC_KEY_FORMAT,
|
||||
type: PUBLIC_KEY_TYPE
|
||||
})
|
||||
} catch {
|
||||
throw new Error('Failed to import public key')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} dirname
|
||||
* @param {(crypto.KeyObject|String)} key
|
||||
* @param {String} [passphrase]
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
const writeKeyToFile = async (filename, key, passphrase) => {
|
||||
if (typeof key === 'string') {
|
||||
key = key.includes('PRIVATE KEY')
|
||||
? importPrivateKey(key, passphrase)
|
||||
: importPublicKey(key)
|
||||
} else if (!(key instanceof crypto.KeyObject)) {
|
||||
throw new Error('Expected "key" to be crypto.KeyObject or string')
|
||||
}
|
||||
|
||||
const isPrivateKey = key.type === 'private'
|
||||
|
||||
const keyData = isPrivateKey
|
||||
? exportPrivateKey(key, passphrase)
|
||||
: exportPublicKey(key)
|
||||
|
||||
const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS
|
||||
|
||||
await fs.promises.writeFile(filename, keyData, { mode })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACCOUNT_KEY_ALGORITHM,
|
||||
CERTIFICATE_KEY_ALGORITHM,
|
||||
DIRECTORY_URL,
|
||||
PRIVATE_KEY_CIPHER,
|
||||
PRIVATE_KEY_FORMAT,
|
||||
PRIVATE_KEY_PERMISSIONS,
|
||||
PRIVATE_KEY_TYPE,
|
||||
PUBLIC_KEY_FORMAT,
|
||||
PUBLIC_KEY_PERMISSIONS,
|
||||
PUBLIC_KEY_TYPE,
|
||||
env,
|
||||
exportPrivateKey,
|
||||
exportPublicKey,
|
||||
importPrivateKey,
|
||||
importPublicKey,
|
||||
writeKeyToFile
|
||||
}
|
||||
4
src/certnode/lib/index.js
Normal file
4
src/certnode/lib/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
...require('./common'),
|
||||
Client: require('./client')
|
||||
}
|
||||
46
src/certnode/lib/request.js
Normal file
46
src/certnode/lib/request.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const https = require('https')
|
||||
|
||||
const request = (url, { data = '', ...options } = {}, cb) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
url = new URL(url)
|
||||
} catch (err) {
|
||||
return reject(err)
|
||||
}
|
||||
|
||||
https.request(url, options, res => {
|
||||
const { statusCode, headers } = res
|
||||
|
||||
let data = ''
|
||||
|
||||
res
|
||||
.on('data', chunk => {
|
||||
data += chunk
|
||||
})
|
||||
.once('end', () => {
|
||||
if (headers['content-type']?.includes('application/json')) {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resolve({ data, headers, statusCode })
|
||||
})
|
||||
.once('error', reject)
|
||||
})
|
||||
.once('error', reject)
|
||||
.end(data)
|
||||
|
||||
setTimeout(() => {
|
||||
const method = options.method || 'GET'
|
||||
reject(new Error(`${method} request to "${url}" timed out`))
|
||||
}, 10e3)
|
||||
|
||||
cb && cb()
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = request
|
||||
52
src/client.js
Normal file
52
src/client.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
const record_prefix = 'forward-domain=';
|
||||
|
||||
const {
|
||||
default: axios
|
||||
} = require('axios');
|
||||
const path = require('path');
|
||||
const {
|
||||
findTxtRecord
|
||||
} = require('./util');
|
||||
|
||||
/**
|
||||
* @type {Object<string, {expire: number, expand: boolean, url: string}>}
|
||||
*/
|
||||
const resolveCache = {};
|
||||
|
||||
async function buildCache(host) {
|
||||
let expand = false;
|
||||
let url = await findTxtRecord(host, record_prefix);
|
||||
if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
|
||||
throw new Error(url + ' in TXT record is not an absolute URL');
|
||||
}
|
||||
if (url.endsWith('*')) {
|
||||
url = url.slice(0, -1);
|
||||
expand = true;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
expand,
|
||||
expire: Date.now() + 86400 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
const listener = async function (req, res) {
|
||||
try {
|
||||
let cache = resolveCache[req.headers.host];
|
||||
if (!cache || (Date.now() > cache.expire)) {
|
||||
cache = await buildCache(req.headers.host);
|
||||
resolveCache[req.headers.host] = cache;
|
||||
}
|
||||
res.writeHead(301, {
|
||||
'Location': cache.expand ? path.join(cache.url, req.url) : cache.url,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
res.writeHead(400);
|
||||
res.write(error.message || 'Unknown error');
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = listener;
|
||||
98
src/sni.js
Normal file
98
src/sni.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
const tls = require('tls');
|
||||
const certnode = require('./certnode/lib');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const {
|
||||
md5,
|
||||
ensureDir,
|
||||
findTxtRecord
|
||||
} = require('./util');
|
||||
const record_email_prefix = 'forward-domain-cert-maintainer=';
|
||||
const client = new certnode.Client();
|
||||
const certsDir = path.join(__dirname, '.certs');
|
||||
|
||||
/**
|
||||
* @type {Object<string, {cert: any, key: any, expire: number}>}
|
||||
*/
|
||||
const resolveCache = {};
|
||||
|
||||
function getCertCachePath(host) {
|
||||
const hash = md5(host);
|
||||
return path.join(certsDir, hash.substr(0, 2), hash.substr(2), host);
|
||||
}
|
||||
|
||||
async function findMaintainerEmail(host) {
|
||||
return await findTxtRecord(host, record_email_prefix);
|
||||
}
|
||||
|
||||
async function buildCache(host) {
|
||||
const dir = getCertCachePath(host);
|
||||
const keyP = path.join(dir, 'privateKey.pem');
|
||||
const certP = path.join(dir, 'publicKey.pem');
|
||||
const extP = path.join(dir, 'expire');
|
||||
await ensureDir(dir);
|
||||
try {
|
||||
await fs.promises.access(keyP, fs.constants.R_OK | fs.constants.W_OK);
|
||||
await fs.promises.access(certP, fs.constants.R_OK | fs.constants.W_OK);
|
||||
await fs.promises.access(extP, fs.constants.R_OK | fs.constants.W_OK);
|
||||
const expire = parseInt((await fs.promises.readFile(extP)).toString('utf8'));
|
||||
if (Date.now() > expire)
|
||||
throw null; // expired
|
||||
const cert = await fs.promises.readFile(certP, 'utf8');
|
||||
const key = await fs.promises.readFile(keyP, 'utf8')
|
||||
return {
|
||||
cert,
|
||||
key,
|
||||
expire
|
||||
};
|
||||
} catch (error) {
|
||||
const {
|
||||
certificate,
|
||||
privateKeyData
|
||||
} = await client.generateCertificate(host, await findMaintainerEmail(host));
|
||||
await fs.promises.writeFile(certP, certificate);
|
||||
await certnode.writeKeyToFile(keyP, privateKeyData, '');
|
||||
const expire = (Date.now() + 45 * 86400 * 1000);
|
||||
await fs.promises.writeFile(extP, expire.toString());
|
||||
return {
|
||||
cert: certificate,
|
||||
key: privateKeyData,
|
||||
expire
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async function getKeyCert(servername) {
|
||||
let cache = resolveCache[servername];
|
||||
await ensureDir(certsDir);
|
||||
if (!cache || (Date.now() > cache.expire)) {
|
||||
cache = await buildCache(servername);
|
||||
resolveCache[servername] = cache;
|
||||
}
|
||||
return {
|
||||
key: cache.key,
|
||||
cert: cache.cert,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const SniListener = async (servername, ctx) => {
|
||||
// Generate fresh account keys for Let's Encrypt
|
||||
try {
|
||||
ctx(null, tls.createSecureContext(await getKeyCert(servername)));
|
||||
} catch (error) {
|
||||
ctx(error, null);
|
||||
}
|
||||
}
|
||||
|
||||
const SniPrepare = async () => {
|
||||
await client.generateAccountKeyPair()
|
||||
await ensureDir(certsDir);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SniListener,
|
||||
SniPrepare,
|
||||
client,
|
||||
}
|
||||
39
src/util.js
Normal file
39
src/util.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
const { default: axios } = require('axios');
|
||||
var crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
|
||||
function md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest('hex');
|
||||
}
|
||||
|
||||
|
||||
async function ensureDir(dir) {
|
||||
try {
|
||||
await fs.promises.access(dir, fs.constants.W_OK | fs.constants.O_DIRECTORY);
|
||||
} catch (error) {
|
||||
await fs.promises.mkdir(dir, {
|
||||
recursive: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function findTxtRecord(host, prefix, required = true) {
|
||||
const resolve = await axios(`https://dns.google/resolve?name=${encodeURIComponent(host)}&type=TXT`);
|
||||
if (resolve.data.Answer) {
|
||||
for (const head of resolve.data.Answer) {
|
||||
if (!head.data.startsWith(prefix))
|
||||
continue;
|
||||
return head.data.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
if (required) {
|
||||
throw new Error(prefix + ' TXT is missing');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
md5,
|
||||
ensureDir,
|
||||
findTxtRecord,
|
||||
}
|
||||
Loading…
Add table
editor.link_modal.header
Reference in a new issue