Fix SNI lock, update deps, ESM refactor
This commit is contained in:
parent
88662b49bc
commit
447b1b735d
13 changed files with 1204 additions and 1144 deletions
|
|
@ -1,5 +1,10 @@
|
||||||
# CHANGES
|
# CHANGES
|
||||||
|
|
||||||
|
## v2.4 (2023-04-21)
|
||||||
|
|
||||||
|
+ Fix global service lock when a website is verificating certs.
|
||||||
|
+ Update code deps, refactor imports to ESM.
|
||||||
|
|
||||||
## v2.3 (2022-08-16)
|
## v2.3 (2022-08-16)
|
||||||
|
|
||||||
+ Add stat API `s.forwarddomain.net`, separate node script.
|
+ Add stat API `s.forwarddomain.net`, separate node script.
|
||||||
|
|
|
||||||
12
app.js
12
app.js
|
|
@ -1,13 +1,13 @@
|
||||||
|
import { config } from "dotenv";
|
||||||
|
import https from "https";
|
||||||
|
import app from "./index.js";
|
||||||
|
import listener from "./src/client.js";
|
||||||
|
import { SniPrepare, SniListener } from "./src/sni.js";
|
||||||
// production endpoint (use pm2/phusion/whatever)
|
// production endpoint (use pm2/phusion/whatever)
|
||||||
|
config();
|
||||||
|
|
||||||
require('dotenv').config()
|
|
||||||
const https = require("https");
|
|
||||||
const app = require("./index.js");
|
|
||||||
const listener = require("./src/client.js");
|
|
||||||
const { SniPrepare, SniListener } = require("./src/sni.js");
|
|
||||||
const port80 = parseInt(process.env.HTTP_PORT || "80");
|
const port80 = parseInt(process.env.HTTP_PORT || "80");
|
||||||
const port443 = parseInt(process.env.HTTPS_PORT || "443");
|
const port443 = parseInt(process.env.HTTPS_PORT || "443");
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
await SniPrepare();
|
await SniPrepare();
|
||||||
const httpsServer = https.createServer({
|
const httpsServer = https.createServer({
|
||||||
|
|
|
||||||
20
index.js
20
index.js
|
|
@ -1,17 +1,17 @@
|
||||||
|
import { config } from "dotenv";
|
||||||
|
import http from "http";
|
||||||
|
import listener from "./src/client.js";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// development endpoint (use ngrok)
|
// development endpoint (use ngrok)
|
||||||
|
|
||||||
require('dotenv').config()
|
|
||||||
const http = require('http');
|
|
||||||
const listener = require('./src/client');
|
|
||||||
|
|
||||||
|
|
||||||
const server = http.createServer(listener);
|
const server = http.createServer(listener);
|
||||||
const port = parseInt(process.env.HTTP_PORT || "3000");
|
|
||||||
|
|
||||||
if (require.main === module) {
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
config();
|
||||||
|
const port = parseInt(process.env.HTTP_PORT || "3000");
|
||||||
server.listen(port, function () {
|
server.listen(port, function () {
|
||||||
console.log(`server start at port ${port}`);
|
console.log(`server start at port ${port}`);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
module.exports = server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default server;
|
||||||
|
|
|
||||||
935
package-lock.json
generated
935
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
|
@ -2,7 +2,11 @@
|
||||||
"name": "forward-domain",
|
"name": "forward-domain",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Public service to forward domain for free",
|
"description": "Public service to forward domain for free",
|
||||||
"main": "index.js",
|
"main": "app.js",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"start": "pm2 start app.js",
|
"start": "pm2 start app.js",
|
||||||
|
|
@ -13,13 +17,14 @@
|
||||||
"author": "Wildan Mubarok",
|
"author": "Wildan Mubarok",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"await-lock": "^2.1.0",
|
"async-lock": "^1.4.0",
|
||||||
"axios": "^0.21.1",
|
"await-lock": "^2.2.2",
|
||||||
"dotenv": "^16.0.1",
|
"axios": "^1.3.6",
|
||||||
"jose-node-cjs-runtime": "^3.12.2",
|
"dotenv": "^16.0.3",
|
||||||
"pem": "^1.14.4"
|
"jose": "^3.20.4",
|
||||||
|
"pem": "^1.14.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"pm2": "^5.1.1"
|
"pm2": "^5.3.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,15 @@
|
||||||
const fs = require('fs')
|
import fs from "fs";
|
||||||
const path = require('path')
|
import path from "path";
|
||||||
const {
|
import { promisify } from "util";
|
||||||
promisify
|
import fromKeyLike from "jose/jwk/from_key_like";
|
||||||
} = require('util')
|
import generateKeyPair from "jose/util/generate_key_pair";
|
||||||
const {
|
import calculateThumbprint from "jose/jwk/thumbprint";
|
||||||
fromKeyLike
|
import SignJWT from "jose/jwt/sign";
|
||||||
} = require('jose-node-cjs-runtime/jwk/from_key_like')
|
import CompactSign from "jose/jws/compact/sign";
|
||||||
const {
|
import pem from "pem";
|
||||||
generateKeyPair
|
import * as common from "./common.js";
|
||||||
} = require('jose-node-cjs-runtime/util/generate_key_pair')
|
import request from "./request.js";
|
||||||
const {
|
const createCsr = promisify(pem.createCSR);
|
||||||
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
|
* Represents a Let's Encrypt account and
|
||||||
* sends requests to get valid TLS certificates.
|
* sends requests to get valid TLS certificates.
|
||||||
|
|
@ -33,23 +19,23 @@ class Client {
|
||||||
* @param {String} [directoryUrl]
|
* @param {String} [directoryUrl]
|
||||||
*/
|
*/
|
||||||
constructor(directoryUrl = common.DIRECTORY_URL) {
|
constructor(directoryUrl = common.DIRECTORY_URL) {
|
||||||
this.accountPrivateJwk = null
|
this.accountPrivateJwk = null;
|
||||||
/** @type {import('crypto').KeyObject} */
|
/** @type {import('crypto').KeyObject|null} */
|
||||||
this.accountPrivateKey = null
|
this.accountPrivateKey = null;
|
||||||
this.accountPublicJwk = null
|
/** @type {import("jose/types.js").JWK | undefined} */
|
||||||
/** @type {import('crypto').KeyObject} */
|
this.accountPublicJwk = undefined;
|
||||||
this.accountPublicKey = null
|
/** @type {import('crypto').KeyObject|null} */
|
||||||
this.directoryUrl = directoryUrl
|
this.accountPublicKey = null;
|
||||||
this.challengeCallbacks = null
|
this.directoryUrl = directoryUrl;
|
||||||
this.hasDirectory = false
|
this.challengeCallbacks = null;
|
||||||
this.myAccountUrl = ''
|
this.hasDirectory = false;
|
||||||
this.newAccountUrl = ''
|
this.myAccountUrl = '';
|
||||||
this.newNonceUrl = ''
|
this.newAccountUrl = '';
|
||||||
this.newOrderUrl = ''
|
this.newNonceUrl = '';
|
||||||
this.replayNonce = ''
|
this.newOrderUrl = '';
|
||||||
this.thumbprint = ''
|
this.replayNonce = '';
|
||||||
|
this.thumbprint = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export account public and private keys to a directory.
|
* Export account public and private keys to a directory.
|
||||||
*
|
*
|
||||||
|
|
@ -59,34 +45,29 @@ class Client {
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
exportAccountKeyPair(dirname, passphrase) {
|
exportAccountKeyPair(dirname, passphrase) {
|
||||||
const privateKeyFile = path.join(dirname, 'privateKey.pem')
|
if (this.accountPrivateKey == null || this.accountPublicKey == null) {
|
||||||
const publicKeyFile = path.join(dirname, 'publicKey.pem')
|
return Promise.reject(new Error('Account key pair not generated'));
|
||||||
|
}
|
||||||
|
const privateKeyFile = path.join(dirname, 'privateKey.pem');
|
||||||
|
const publicKeyFile = path.join(dirname, 'publicKey.pem');
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase),
|
common.writeKeyToFile(privateKeyFile, this.accountPrivateKey, passphrase),
|
||||||
common.writeKeyToFile(publicKeyFile, this.accountPublicKey)
|
common.writeKeyToFile(publicKeyFile, this.accountPublicKey)
|
||||||
])
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate new account public and private keys.
|
* Generate new account public and private keys.
|
||||||
*
|
*
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
async generateAccountKeyPair() {
|
async generateAccountKeyPair() {
|
||||||
const {
|
const { privateKey, publicKey } = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM);
|
||||||
privateKey,
|
|
||||||
publicKey
|
|
||||||
} = await generateKeyPair(common.ACCOUNT_KEY_ALGORITHM)
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.accountPrivateKey = privateKey
|
this.accountPrivateKey = privateKey;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
this.accountPublicKey = publicKey
|
this.accountPublicKey = publicKey;
|
||||||
|
await this.initAccountJwks();
|
||||||
await this.initAccountJwks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a certificate from Let's Encrypt for your domain.
|
* Generate a certificate from Let's Encrypt for your domain.
|
||||||
*
|
*
|
||||||
|
|
@ -95,32 +76,20 @@ class Client {
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
async generateCertificate(domain) {
|
async generateCertificate(domain) {
|
||||||
await this.directory()
|
await this.directory();
|
||||||
await this.newNonce()
|
await this.newNonce();
|
||||||
if (!this.myAccountUrl)
|
if (!this.myAccountUrl)
|
||||||
await this.newAccount()
|
await this.newAccount();
|
||||||
|
const { authzUrls, finalizeUrl } = await this.newOrder(domain);
|
||||||
const {
|
const { challenge } = await this.authz(authzUrls[0]);
|
||||||
authzUrls,
|
await this.completeChallenge(challenge, domain);
|
||||||
finalizeUrl
|
await this.pollAuthz(authzUrls[0]);
|
||||||
} = await this.newOrder(domain)
|
const { certificate, privateKeyData } = await this.finalizeOrder(finalizeUrl, domain);
|
||||||
const {
|
|
||||||
challenge
|
|
||||||
} = await this.authz(authzUrls[0])
|
|
||||||
|
|
||||||
await this.completeChallenge(challenge, domain)
|
|
||||||
await this.pollAuthz(authzUrls[0])
|
|
||||||
const {
|
|
||||||
certificate,
|
|
||||||
privateKeyData
|
|
||||||
} = await this.finalizeOrder(finalizeUrl, domain)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
certificate,
|
certificate,
|
||||||
privateKeyData
|
privateKeyData
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import account public and private keys from a directory.
|
* Import account public and private keys from a directory.
|
||||||
*
|
*
|
||||||
|
|
@ -133,115 +102,83 @@ class Client {
|
||||||
const [privateKeyData, publicKeyData] = await Promise.all([
|
const [privateKeyData, publicKeyData] = await Promise.all([
|
||||||
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'),
|
fs.promises.readFile(path.join(dirname, 'privateKey.pem'), 'utf8'),
|
||||||
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
|
fs.promises.readFile(path.join(dirname, 'publicKey.pem'), 'utf8')
|
||||||
])
|
]);
|
||||||
|
this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase);
|
||||||
this.accountPrivateKey = common.importPrivateKey(privateKeyData, passphrase)
|
this.accountPublicKey = common.importPublicKey(publicKeyData);
|
||||||
this.accountPublicKey = common.importPublicKey(publicKeyData)
|
await this.initAccountJwks();
|
||||||
|
|
||||||
await this.initAccountJwks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async authz(authzUrl) {
|
async authz(authzUrl) {
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
kid: this.myAccountUrl,
|
kid: this.myAccountUrl,
|
||||||
nonce: this.replayNonce,
|
nonce: this.replayNonce,
|
||||||
url: authzUrl
|
url: authzUrl
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await request(authzUrl, {
|
const res = await request(authzUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`authz() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
const { challenges, identifier, ...rest } = res.data;
|
||||||
const {
|
const challenge = challenges.find(({ type }) => type === 'http-01');
|
||||||
challenges,
|
|
||||||
identifier,
|
|
||||||
...rest
|
|
||||||
} = res.data
|
|
||||||
const challenge = challenges.find(({
|
|
||||||
type
|
|
||||||
}) => type === 'http-01')
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
challenge,
|
challenge,
|
||||||
domain: identifier.value,
|
domain: identifier.value,
|
||||||
...rest
|
...rest
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async completeChallenge(challenge, domain) {
|
async completeChallenge(challenge, domain) {
|
||||||
await this.readyChallenge(challenge)
|
await this.readyChallenge(challenge);
|
||||||
await this.receiveServerRequest(challenge, domain)
|
await this.receiveServerRequest(challenge, domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
async directory() {
|
async directory() {
|
||||||
if (this.hasDirectory) return false
|
if (this.hasDirectory)
|
||||||
|
return false;
|
||||||
const res = await request(this.directoryUrl)
|
const res = await request(this.directoryUrl);
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`directory() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`directory() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
this.hasDirectory = true;
|
||||||
this.hasDirectory = true
|
this.newAccountUrl = res.data.newAccount;
|
||||||
this.newAccountUrl = res.data.newAccount
|
this.newNonceUrl = res.data.newNonce;
|
||||||
this.newNonceUrl = res.data.newNonce
|
this.newOrderUrl = res.data.newOrder;
|
||||||
this.newOrderUrl = res.data.newOrder
|
return true;
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchCertificate(certificateUrl) {
|
async fetchCertificate(certificateUrl) {
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
kid: this.myAccountUrl,
|
kid: this.myAccountUrl,
|
||||||
nonce: this.replayNonce,
|
nonce: this.replayNonce,
|
||||||
url: certificateUrl
|
url: certificateUrl
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await request(certificateUrl, {
|
const res = await request(certificateUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/pem-certificate-chain',
|
accept: 'application/pem-certificate-chain',
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`fetchCertificate() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`fetchCertificate() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
return res.data;
|
||||||
return res.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async finalizeOrder(finalizeUrl, domain) {
|
async finalizeOrder(finalizeUrl, domain) {
|
||||||
const {
|
const { privateKey } = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM);
|
||||||
privateKey
|
|
||||||
} = await generateKeyPair(common.CERTIFICATE_KEY_ALGORITHM)
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const clientKey = common.exportPrivateKey(privateKey)
|
const clientKey = common.exportPrivateKey(privateKey);
|
||||||
let {
|
let { csr
|
||||||
csr
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} = await createCsr({
|
} = await createCsr({
|
||||||
clientKey,
|
clientKey,
|
||||||
commonName: domain,
|
commonName: domain,
|
||||||
})
|
});
|
||||||
|
|
||||||
// "The CSR is sent in the base64url-encoded version of the DER format.
|
// "The CSR is sent in the base64url-encoded version of the DER format.
|
||||||
// (Note: Because this field uses base64url, and does not include headers,
|
// (Note: Because this field uses base64url, and does not include headers,
|
||||||
// it is different from PEM.)"
|
// it is different from PEM.)"
|
||||||
|
|
@ -252,51 +189,43 @@ class Client {
|
||||||
.join('')
|
.join('')
|
||||||
.replace(/\+/g, '-')
|
.replace(/\+/g, '-')
|
||||||
.replace(/\//g, '_')
|
.replace(/\//g, '_')
|
||||||
.replace(/=/g, '')
|
.replace(/=/g, '');
|
||||||
|
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
kid: this.myAccountUrl,
|
kid: this.myAccountUrl,
|
||||||
nonce: this.replayNonce,
|
nonce: this.replayNonce,
|
||||||
url: finalizeUrl
|
url: finalizeUrl
|
||||||
}, {
|
}, {
|
||||||
csr
|
csr
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await request(finalizeUrl, {
|
const res = await request(finalizeUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`finalizeOrder() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
const certificate = await this.fetchCertificate(res.data.certificate);
|
||||||
const certificate = await this.fetchCertificate(res.data.certificate)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
certificate,
|
certificate,
|
||||||
privateKeyData: clientKey
|
privateKeyData: clientKey
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async initAccountJwks() {
|
async initAccountJwks() {
|
||||||
|
if (this.accountPrivateKey == null || this.accountPublicKey == null) {
|
||||||
|
return Promise.reject(new Error('Account key pair not generated'));
|
||||||
|
}
|
||||||
const [publicJwk, accountPrivateJwk] = await Promise.all([
|
const [publicJwk, accountPrivateJwk] = await Promise.all([
|
||||||
fromKeyLike(this.accountPublicKey),
|
fromKeyLike(this.accountPublicKey),
|
||||||
fromKeyLike(this.accountPrivateKey)
|
fromKeyLike(this.accountPrivateKey)
|
||||||
])
|
]);
|
||||||
|
this.accountPublicJwk = publicJwk;
|
||||||
this.accountPublicJwk = publicJwk
|
this.accountPrivateJwk = accountPrivateJwk;
|
||||||
this.accountPrivateJwk = accountPrivateJwk
|
this.thumbprint = await calculateThumbprint(publicJwk);
|
||||||
this.thumbprint = await calculateThumbprint(publicJwk)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async newAccount(...emails) {
|
async newAccount(...emails) {
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
jwk: this.accountPublicJwk,
|
jwk: this.accountPublicJwk,
|
||||||
|
|
@ -304,136 +233,101 @@ class Client {
|
||||||
url: this.newAccountUrl
|
url: this.newAccountUrl
|
||||||
}, {
|
}, {
|
||||||
termsOfServiceAgreed: true
|
termsOfServiceAgreed: true
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await request(this.newAccountUrl, {
|
const res = await request(this.newAccountUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (![200, 201].includes(res.statusCode)) {
|
if (![200, 201].includes(res.statusCode)) {
|
||||||
throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`newAccount() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
this.myAccountUrl = res.headers.location;
|
||||||
this.myAccountUrl = res.headers.location
|
return res.statusCode === 201;
|
||||||
|
|
||||||
return res.statusCode === 201
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async newNonce() {
|
async newNonce() {
|
||||||
if (this.replayNonce) return false
|
if (this.replayNonce)
|
||||||
|
return false;
|
||||||
const res = await request(this.newNonceUrl, {
|
const res = await request(this.newNonceUrl, {
|
||||||
method: 'HEAD'
|
method: 'HEAD'
|
||||||
})
|
});
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`newNonce() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
return true;
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async newOrder(...domains) {
|
async newOrder(...domains) {
|
||||||
const identifiers = domains.map(domain => ({
|
const identifiers = domains.map(domain => ({
|
||||||
type: 'dns',
|
type: 'dns',
|
||||||
value: domain
|
value: domain
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
kid: this.myAccountUrl,
|
kid: this.myAccountUrl,
|
||||||
nonce: this.replayNonce,
|
nonce: this.replayNonce,
|
||||||
url: this.newOrderUrl
|
url: this.newOrderUrl
|
||||||
}, {
|
}, {
|
||||||
identifiers
|
identifiers
|
||||||
})
|
});
|
||||||
|
|
||||||
const res = await request(this.newOrderUrl, {
|
const res = await request(this.newOrderUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (res.statusCode !== 201) {
|
if (res.statusCode !== 201) {
|
||||||
throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`newOrder() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
|
const orderUrl = res.headers.location;
|
||||||
const orderUrl = res.headers.location
|
const { authorizations: authzUrls, finalize: finalizeUrl } = res.data;
|
||||||
const {
|
|
||||||
authorizations: authzUrls,
|
|
||||||
finalize: finalizeUrl
|
|
||||||
} = res.data
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authzUrls,
|
authzUrls,
|
||||||
domains,
|
domains,
|
||||||
finalizeUrl,
|
finalizeUrl,
|
||||||
orderUrl
|
orderUrl
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async pollAuthz(authzUrl) {
|
async pollAuthz(authzUrl) {
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
const result = await this.authz(authzUrl)
|
const result = await this.authz(authzUrl);
|
||||||
|
|
||||||
if (result.status === 'pending') {
|
if (result.status === 'pending') {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1e3))
|
await new Promise(resolve => setTimeout(resolve, 1e3));
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'invalid') {
|
if (result.status === 'invalid') {
|
||||||
throw new Error('pollAuthz() authorization is invalid: ' + JSON.stringify(result, null, 2))
|
throw new Error('pollAuthz() authorization is invalid: ' + JSON.stringify(result, null, 2));
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
throw new Error('pollAuthz() timed out');
|
||||||
throw new Error('pollAuthz() timed out')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async readyChallenge(challenge) {
|
async readyChallenge(challenge) {
|
||||||
const data = await this.sign({
|
const data = await this.sign({
|
||||||
kid: this.myAccountUrl,
|
kid: this.myAccountUrl,
|
||||||
nonce: this.replayNonce,
|
nonce: this.replayNonce,
|
||||||
url: challenge.url
|
url: challenge.url
|
||||||
}, {})
|
}, {});
|
||||||
|
|
||||||
const res = await request(challenge.url, {
|
const res = await request(challenge.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/jose+json'
|
'content-type': 'application/jose+json'
|
||||||
},
|
},
|
||||||
|
|
||||||
data
|
data
|
||||||
})
|
});
|
||||||
|
this.setReplayNonce(res);
|
||||||
this.setReplayNonce(res)
|
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`)
|
throw new Error(`readyChallenge() Status Code: ${res.statusCode} Data: ${res.data}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
receiveServerRequest(challenge, domain) {
|
receiveServerRequest(challenge, domain) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const time = setTimeout(() => {
|
const time = setTimeout(() => {
|
||||||
reject(new Error('Timed out waiting for server request'))
|
reject(new Error('Timed out waiting for server request'));
|
||||||
}, 10e3);
|
}, 10e3);
|
||||||
let hasResolved = false;
|
let hasResolved = false;
|
||||||
this.challengeCallbacks = () => {
|
this.challengeCallbacks = () => {
|
||||||
|
|
@ -446,49 +340,49 @@ class Client {
|
||||||
// wanted to clear callbacks here but LE does the call multiple times.
|
// wanted to clear callbacks here but LE does the call multiple times.
|
||||||
// remember we're in mutex lock so no worries for racing.
|
// remember we're in mutex lock so no worries for racing.
|
||||||
return challenge.token + '.' + this.thumbprint;
|
return challenge.token + '.' + this.thumbprint;
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setReplayNonce(res) {
|
setReplayNonce(res) {
|
||||||
const replayNonce = (res.headers['replay-nonce'] || '').trim()
|
const replayNonce = (res.headers['replay-nonce'] || '').trim();
|
||||||
|
|
||||||
if (!replayNonce) {
|
if (!replayNonce) {
|
||||||
throw new Error('No Replay-Nonce header in response')
|
throw new Error('No Replay-Nonce header in response');
|
||||||
}
|
}
|
||||||
|
this.replayNonce = replayNonce;
|
||||||
this.replayNonce = replayNonce
|
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @param {import("jose/types.js").JWSHeaderParameters} header
|
||||||
|
* @param {import("jose/types.js").JWTPayload | undefined} [payload]
|
||||||
|
*/
|
||||||
async sign(header, payload) {
|
async sign(header, payload) {
|
||||||
let data
|
if (this.accountPrivateKey == null) {
|
||||||
|
return Promise.reject(new Error('Account key pair not generated'));
|
||||||
|
}
|
||||||
|
let data;
|
||||||
if (payload) {
|
if (payload) {
|
||||||
data = await new SignJWT(payload)
|
data = await new SignJWT(payload)
|
||||||
.setProtectedHeader({
|
.setProtectedHeader({
|
||||||
alg: common.ACCOUNT_KEY_ALGORITHM,
|
alg: common.ACCOUNT_KEY_ALGORITHM,
|
||||||
...header
|
...header
|
||||||
})
|
})
|
||||||
.sign(this.accountPrivateKey)
|
.sign(this.accountPrivateKey);
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// SignJWT constructor only accepts object but RFC8555 requires empty payload
|
// SignJWT constructor only accepts object but RFC8555 requires empty payload
|
||||||
// Workaround: manually pass empty Uint8Array to CompactSign constructor
|
// Workaround: manually pass empty Uint8Array to CompactSign constructor
|
||||||
const sig = new CompactSign(new Uint8Array())
|
const sig = new CompactSign(new Uint8Array());
|
||||||
sig.setProtectedHeader({
|
sig.setProtectedHeader({
|
||||||
alg: common.ACCOUNT_KEY_ALGORITHM,
|
alg: common.ACCOUNT_KEY_ALGORITHM,
|
||||||
...header
|
...header
|
||||||
})
|
});
|
||||||
data = await sig.sign(this.accountPrivateKey)
|
data = await sig.sign(this.accountPrivateKey);
|
||||||
}
|
}
|
||||||
|
const [b64Header, b64Payload, b64Signature] = data.split('.');
|
||||||
const [b64Header, b64Payload, b64Signature] = data.split('.')
|
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
protected: b64Header,
|
protected: b64Header,
|
||||||
payload: b64Payload,
|
payload: b64Payload,
|
||||||
signature: b64Signature
|
signature: b64Signature
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export default Client;
|
||||||
module.exports = Client
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
const crypto = require('crypto')
|
import crypto from "crypto";
|
||||||
const fs = require('fs')
|
import fs from "fs";
|
||||||
|
const ACCOUNT_KEY_ALGORITHM = 'ES256';
|
||||||
const ACCOUNT_KEY_ALGORITHM = 'ES256'
|
const CERTIFICATE_KEY_ALGORITHM = 'RS256';
|
||||||
const CERTIFICATE_KEY_ALGORITHM = 'RS256'
|
const env = (process.env.NODE_ENV || '').trim().toLowerCase();
|
||||||
|
|
||||||
const env = (process.env.NODE_ENV || '').trim().toLowerCase()
|
|
||||||
|
|
||||||
const DIRECTORY_URL = ['development', 'test'].includes(env)
|
const DIRECTORY_URL = ['development', 'test'].includes(env)
|
||||||
? 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
? 'https://acme-staging-v02.api.letsencrypt.org/directory'
|
||||||
: 'https://acme-v02.api.letsencrypt.org/directory'
|
: 'https://acme-v02.api.letsencrypt.org/directory';
|
||||||
|
const PRIVATE_KEY_CIPHER = 'aes-256-cbc';
|
||||||
const PRIVATE_KEY_CIPHER = 'aes-256-cbc'
|
const PRIVATE_KEY_FORMAT = 'pem';
|
||||||
const PRIVATE_KEY_FORMAT = 'pem'
|
const PRIVATE_KEY_PERMISSIONS = 0o600;
|
||||||
const PRIVATE_KEY_PERMISSIONS = 0o600
|
const PRIVATE_KEY_TYPE = 'pkcs8';
|
||||||
const PRIVATE_KEY_TYPE = 'pkcs8'
|
const PUBLIC_KEY_FORMAT = 'pem';
|
||||||
|
const PUBLIC_KEY_PERMISSIONS = 0o666;
|
||||||
const PUBLIC_KEY_FORMAT = 'pem'
|
const PUBLIC_KEY_TYPE = 'spki';
|
||||||
const PUBLIC_KEY_PERMISSIONS = 0o666
|
|
||||||
const PUBLIC_KEY_TYPE = 'spki'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {crypto.KeyObject} privateKey
|
* @param {crypto.KeyObject} privateKey
|
||||||
* @param {String} [passphrase]
|
* @param {String} [passphrase]
|
||||||
|
|
@ -29,16 +23,13 @@ const exportPrivateKey = (privateKey, passphrase) => {
|
||||||
const privateKeyOpts = {
|
const privateKeyOpts = {
|
||||||
type: PRIVATE_KEY_TYPE,
|
type: PRIVATE_KEY_TYPE,
|
||||||
format: PRIVATE_KEY_FORMAT
|
format: PRIVATE_KEY_FORMAT
|
||||||
}
|
};
|
||||||
|
|
||||||
if (passphrase) {
|
if (passphrase) {
|
||||||
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER
|
privateKeyOpts.cipher = PRIVATE_KEY_CIPHER;
|
||||||
privateKeyOpts.passphrase = passphrase
|
privateKeyOpts.passphrase = passphrase;
|
||||||
}
|
}
|
||||||
|
return privateKey.export(privateKeyOpts);
|
||||||
return privateKey.export(privateKeyOpts)
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {crypto.KeyObject} publicKey
|
* @param {crypto.KeyObject} publicKey
|
||||||
*/
|
*/
|
||||||
|
|
@ -47,9 +38,8 @@ const exportPublicKey = publicKey => {
|
||||||
return publicKey.export({
|
return publicKey.export({
|
||||||
type: PUBLIC_KEY_TYPE,
|
type: PUBLIC_KEY_TYPE,
|
||||||
format: PUBLIC_KEY_FORMAT
|
format: PUBLIC_KEY_FORMAT
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {String} privateKeyData
|
* @param {String} privateKeyData
|
||||||
* @param {String} [passphrase]
|
* @param {String} [passphrase]
|
||||||
|
|
@ -57,24 +47,22 @@ const exportPublicKey = publicKey => {
|
||||||
* @return {crypto.KeyObject}
|
* @return {crypto.KeyObject}
|
||||||
*/
|
*/
|
||||||
const importPrivateKey = (privateKeyData, passphrase) => {
|
const importPrivateKey = (privateKeyData, passphrase) => {
|
||||||
/** @type {crypto.KeyExportOptions<'pem'>} */
|
/** @type {crypto.PrivateKeyInput} */
|
||||||
const privateKeyOpts = {
|
const privateKeyOpts = {
|
||||||
key: privateKeyData,
|
key: privateKeyData,
|
||||||
format: PRIVATE_KEY_FORMAT,
|
format: PRIVATE_KEY_FORMAT,
|
||||||
type: PRIVATE_KEY_TYPE
|
type: PRIVATE_KEY_TYPE
|
||||||
}
|
};
|
||||||
|
|
||||||
if (passphrase) {
|
if (passphrase) {
|
||||||
privateKeyOpts.passphrase = passphrase
|
privateKeyOpts.passphrase = passphrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return crypto.createPrivateKey(privateKeyOpts)
|
return crypto.createPrivateKey(privateKeyOpts);
|
||||||
} catch {
|
|
||||||
throw new Error('Failed to import private key')
|
|
||||||
}
|
}
|
||||||
}
|
catch {
|
||||||
|
throw new Error('Failed to import private key');
|
||||||
|
}
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @param {String} publicKeyData
|
* @param {String} publicKeyData
|
||||||
*
|
*
|
||||||
|
|
@ -86,12 +74,12 @@ const importPublicKey = publicKeyData => {
|
||||||
key: publicKeyData,
|
key: publicKeyData,
|
||||||
format: PUBLIC_KEY_FORMAT,
|
format: PUBLIC_KEY_FORMAT,
|
||||||
type: PUBLIC_KEY_TYPE
|
type: PUBLIC_KEY_TYPE
|
||||||
})
|
});
|
||||||
} catch {
|
|
||||||
throw new Error('Failed to import public key')
|
|
||||||
}
|
}
|
||||||
}
|
catch {
|
||||||
|
throw new Error('Failed to import public key');
|
||||||
|
}
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @param {String} filename
|
* @param {String} filename
|
||||||
* @param {crypto.KeyObject|string} key
|
* @param {crypto.KeyObject|string} key
|
||||||
|
|
@ -103,37 +91,16 @@ const writeKeyToFile = async (filename, key, passphrase) => {
|
||||||
if (typeof key === 'string') {
|
if (typeof key === 'string') {
|
||||||
key = key.includes('PRIVATE KEY')
|
key = key.includes('PRIVATE KEY')
|
||||||
? importPrivateKey(key, passphrase)
|
? importPrivateKey(key, passphrase)
|
||||||
: importPublicKey(key)
|
: importPublicKey(key);
|
||||||
} else if (!(key instanceof crypto.KeyObject)) {
|
|
||||||
throw new Error('Expected "key" to be crypto.KeyObject or string')
|
|
||||||
}
|
}
|
||||||
|
else if (!(key instanceof crypto.KeyObject)) {
|
||||||
const isPrivateKey = key.type === 'private'
|
throw new Error('Expected "key" to be crypto.KeyObject or string');
|
||||||
|
}
|
||||||
|
const isPrivateKey = key.type === 'private';
|
||||||
const keyData = isPrivateKey
|
const keyData = isPrivateKey
|
||||||
? exportPrivateKey(key, passphrase)
|
? exportPrivateKey(key, passphrase)
|
||||||
: exportPublicKey(key)
|
: exportPublicKey(key);
|
||||||
|
const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS;
|
||||||
const mode = isPrivateKey ? PRIVATE_KEY_PERMISSIONS : PUBLIC_KEY_PERMISSIONS
|
await fs.promises.writeFile(filename, keyData, { mode });
|
||||||
|
};
|
||||||
await fs.promises.writeFile(filename, keyData, { mode })
|
export { 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 };
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
module.exports = {
|
import Client from './client.js';
|
||||||
...require('./common'),
|
export * from './common.js';
|
||||||
Client: require('./client')
|
export { Client };
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,40 @@
|
||||||
const https = require('https')
|
import https from "https";
|
||||||
|
|
||||||
const request = (/** @type {string | import("url").URL} */ url, /** @type {https.RequestOptions&{data?: string}} */ { data = '', ...options } = {}, /** @type {() => any} */ cb) => {
|
const request = (/** @type {string | import("url").URL} */ url, /** @type {https.RequestOptions&{data?: string}} */ { data = '', ...options } = {}, /** @type {() => any} */ cb) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
url = new URL(url)
|
url = new URL(url);
|
||||||
} catch (err) {
|
}
|
||||||
return reject(err)
|
catch (err) {
|
||||||
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
https.request(url, options, res => {
|
https.request(url, options, res => {
|
||||||
const { statusCode, headers } = res
|
const { statusCode, headers } = res;
|
||||||
|
let data = '';
|
||||||
let data = ''
|
|
||||||
|
|
||||||
res
|
res
|
||||||
.on('data', chunk => {
|
.on('data', chunk => {
|
||||||
data += chunk
|
data += chunk;
|
||||||
})
|
})
|
||||||
.once('end', () => {
|
.once('end', () => {
|
||||||
if (headers['content-type']?.includes('application/json')) {
|
if (headers['content-type']?.includes('application/json')) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(data)
|
data = JSON.parse(data);
|
||||||
} catch (err) {
|
}
|
||||||
reject(err)
|
catch (err) {
|
||||||
return
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resolve({ data, headers, statusCode });
|
||||||
resolve({ data, headers, statusCode })
|
})
|
||||||
|
.once('error', reject);
|
||||||
})
|
})
|
||||||
.once('error', reject)
|
.once('error', reject)
|
||||||
})
|
.end(data);
|
||||||
.once('error', reject)
|
|
||||||
.end(data)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const method = options.method || 'GET'
|
const method = options.method || 'GET';
|
||||||
reject(new Error(`${method} request to "${url}" timed out`))
|
reject(new Error(`${method} request to "${url}" timed out`));
|
||||||
}, 10e3)
|
}, 10e3);
|
||||||
|
cb && cb();
|
||||||
cb && cb()
|
});
|
||||||
})
|
};
|
||||||
}
|
export default request;
|
||||||
|
|
||||||
module.exports = request
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
|
import { client } from "./sni.js";
|
||||||
|
import { findTxtRecord, isHostBlacklisted, combineURLs } from "./util.js";
|
||||||
const record_prefix = 'forward-domain=';
|
const record_prefix = 'forward-domain=';
|
||||||
const {
|
|
||||||
client
|
|
||||||
} = require('./sni');
|
|
||||||
const {
|
|
||||||
findTxtRecord,
|
|
||||||
isHostBlacklisted
|
|
||||||
} = require('./util');
|
|
||||||
const combineURLs = require('axios/lib/helpers/combineURLs');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {Object<string, {expire: number, expand: boolean, url: string}>}
|
* @typedef {Object} Cache
|
||||||
|
* @property {string} url
|
||||||
|
* @property {boolean} expand
|
||||||
|
* @property {boolean} blacklisted
|
||||||
|
* @property {number} expire
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @type {Object<string, Cache>}
|
||||||
*/
|
*/
|
||||||
const resolveCache = {};
|
const resolveCache = {};
|
||||||
|
/**
|
||||||
|
* @param {string} host
|
||||||
|
* @returns {Promise<Cache>}
|
||||||
|
*/
|
||||||
async function buildCache(host) {
|
async function buildCache(host) {
|
||||||
let expand = false;
|
let expand = false;
|
||||||
let url = await findTxtRecord(host, record_prefix);
|
let url = await findTxtRecord(host, record_prefix);
|
||||||
|
|
@ -30,28 +33,36 @@ async function buildCache(host) {
|
||||||
expire: Date.now() + 86400 * 1000,
|
expire: Date.now() + 86400 * 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const acme_prefix = '/.well-known/acme-challenge/';
|
const acme_prefix = '/.well-known/acme-challenge/';
|
||||||
|
/**
|
||||||
const listener = async function ( /** @type {import('http').IncomingMessage} */ req, /** @type {import('http').ServerResponse} */ res) {
|
* @type {import('http').RequestListener}
|
||||||
|
*/
|
||||||
|
const listener = async function (req, res) {
|
||||||
try {
|
try {
|
||||||
if (req.url.startsWith(acme_prefix)) {
|
const url = req.url || '';
|
||||||
|
if (url.startsWith(acme_prefix)) {
|
||||||
if (client.challengeCallbacks) {
|
if (client.challengeCallbacks) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
// This is important :)
|
// This is important :)
|
||||||
'content-type': 'application/octet-stream'
|
'content-type': 'application/octet-stream'
|
||||||
});
|
});
|
||||||
res.write(client.challengeCallbacks());
|
res.write(client.challengeCallbacks());
|
||||||
} else {
|
}
|
||||||
res.writeHead(404)
|
else {
|
||||||
|
res.writeHead(404);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const host = (req.headers.host || '').toLowerCase();
|
||||||
let cache = resolveCache[req.headers.host];
|
if (!host) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.write('Host header is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cache = resolveCache[host];
|
||||||
if (!cache || (Date.now() > cache.expire)) {
|
if (!cache || (Date.now() > cache.expire)) {
|
||||||
cache = await buildCache(req.headers.host);
|
cache = await buildCache(host);
|
||||||
resolveCache[req.headers.host] = cache;
|
resolveCache[host] = cache;
|
||||||
}
|
}
|
||||||
if (cache.blacklisted) {
|
if (cache.blacklisted) {
|
||||||
res.writeHead(301, {
|
res.writeHead(301, {
|
||||||
|
|
@ -60,15 +71,16 @@ const listener = async function ( /** @type {import('http').IncomingMessage} */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(301, {
|
res.writeHead(301, {
|
||||||
'Location': cache.expand ? combineURLs(cache.url, req.url) : cache.url,
|
'Location': cache.expand ? combineURLs(cache.url, url) : cache.url,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
res.writeHead(400);
|
res.writeHead(400);
|
||||||
res.write(error.message || 'Unknown error');
|
res.write(error.message || 'Unknown error');
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
export default listener;
|
||||||
module.exports = listener;
|
|
||||||
|
|
|
||||||
105
src/sni.js
105
src/sni.js
|
|
@ -1,28 +1,36 @@
|
||||||
const tls = require('tls');
|
import tls from "tls";
|
||||||
const certnode = require('./certnode/lib');
|
import { Client, writeKeyToFile } from "./certnode/lib/index.js";
|
||||||
const fs = require('fs');
|
import fs from "fs";
|
||||||
const path = require('path');
|
import path from "path";
|
||||||
const {
|
import { md5, ensureDir } from "./util.js";
|
||||||
md5,
|
import AsyncLock from 'async-lock';
|
||||||
ensureDir,
|
|
||||||
} = require('./util');
|
const lock = new AsyncLock();
|
||||||
const {
|
const __dirname = new URL('.', import.meta.url).pathname;
|
||||||
default: AwaitLock
|
|
||||||
} = require('await-lock');
|
|
||||||
const certsDir = path.join(__dirname, '../.certs');
|
const certsDir = path.join(__dirname, '../.certs');
|
||||||
const accountDir = path.join(__dirname, '../.certs/account');
|
const accountDir = path.join(__dirname, '../.certs/account');
|
||||||
const client = new certnode.Client();
|
const client = new Client();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {Object<string, {cert: any, key: any, expire: number}>}
|
* @typedef {Object} Cache
|
||||||
|
* @property {string} cert
|
||||||
|
* @property {string} key
|
||||||
|
* @property {number} expire
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Object<string, Cache>}
|
||||||
*/
|
*/
|
||||||
const resolveCache = {};
|
const resolveCache = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} host
|
||||||
|
*/
|
||||||
function getCertCachePath(host) {
|
function getCertCachePath(host) {
|
||||||
const hash = md5(host);
|
const hash = md5(host);
|
||||||
return path.join(certsDir, hash.substr(0, 2), hash.substr(2), host);
|
return path.join(certsDir, hash.substring(0, 2), hash.substring(2), host);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} host
|
* @param {string} host
|
||||||
*/
|
*/
|
||||||
|
|
@ -40,19 +48,17 @@ async function buildCache(host) {
|
||||||
if (Date.now() > expire)
|
if (Date.now() > expire)
|
||||||
throw new Error('expired'); // expired
|
throw new Error('expired'); // expired
|
||||||
const cert = await fs.promises.readFile(certP, 'utf8');
|
const cert = await fs.promises.readFile(certP, 'utf8');
|
||||||
const key = await fs.promises.readFile(keyP, 'utf8')
|
const key = await fs.promises.readFile(keyP, 'utf8');
|
||||||
return {
|
return {
|
||||||
cert,
|
cert,
|
||||||
key,
|
key,
|
||||||
expire
|
expire
|
||||||
};
|
};
|
||||||
} catch (error) {
|
}
|
||||||
const {
|
catch (error) {
|
||||||
certificate,
|
const { certificate, privateKeyData } = await client.generateCertificate(host);
|
||||||
privateKeyData
|
|
||||||
} = await client.generateCertificate(host);
|
|
||||||
await fs.promises.writeFile(certP, certificate);
|
await fs.promises.writeFile(certP, certificate);
|
||||||
await certnode.writeKeyToFile(keyP, privateKeyData, '');
|
await writeKeyToFile(keyP, privateKeyData, '');
|
||||||
const expire = (Date.now() + 45 * 86400 * 1000);
|
const expire = (Date.now() + 45 * 86400 * 1000);
|
||||||
await fs.promises.writeFile(extP, expire.toString());
|
await fs.promises.writeFile(extP, expire.toString());
|
||||||
return {
|
return {
|
||||||
|
|
@ -61,61 +67,60 @@ async function buildCache(host) {
|
||||||
expire
|
expire
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} servername
|
* @param {string} servername
|
||||||
|
* @param {AsyncLock} lock
|
||||||
*/
|
*/
|
||||||
async function getKeyCert(servername) {
|
async function getKeyCert(servername, lock) {
|
||||||
|
// Had to use lock because the best authenticator
|
||||||
|
// library seems don't yet fully stateless.
|
||||||
|
servername = servername.toLowerCase();
|
||||||
let cache = resolveCache[servername];
|
let cache = resolveCache[servername];
|
||||||
await ensureDir(certsDir);
|
await ensureDir(certsDir);
|
||||||
if (!cache || (Date.now() > cache.expire)) {
|
if (!cache || (Date.now() > cache.expire)) {
|
||||||
cache = await buildCache(servername);
|
cache = await lock.acquire(servername, (cb) => {
|
||||||
|
if (!resolveCache[servername] || (Date.now() > resolveCache[servername].expire)) {
|
||||||
|
buildCache(servername).then((cache) => {
|
||||||
resolveCache[servername] = cache;
|
resolveCache[servername] = cache;
|
||||||
|
cb(null, cache);
|
||||||
|
}).catch((error) => {
|
||||||
|
cb(error, undefined);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cb(null, resolveCache[servername]);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
key: cache.key,
|
key: cache.key,
|
||||||
cert: cache.cert,
|
cert: cache.cert,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let lock = new AwaitLock();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} servername
|
* @param {string} servername
|
||||||
* @param {(err: any, cb: tls.SecureContext) => void} ctx
|
* @param {(err: any, cb: tls.SecureContext|undefined) => void} ctx
|
||||||
*/
|
*/
|
||||||
async function SniListener(servername, ctx) {
|
async function SniListener(servername, ctx) {
|
||||||
// Had to use lock because the best authenticator
|
|
||||||
// library seems don't yet fully stateless.
|
|
||||||
// Generate fresh account keys for Let's Encrypt
|
// Generate fresh account keys for Let's Encrypt
|
||||||
await lock.acquireAsync();
|
|
||||||
try {
|
try {
|
||||||
ctx(null, tls.createSecureContext(await getKeyCert(servername)));
|
ctx(null, tls.createSecureContext(await getKeyCert(servername, lock)));
|
||||||
} catch (error) {
|
}
|
||||||
console.log(JSON.stringify(error));
|
catch (error) {
|
||||||
ctx(error, null);
|
console.log(error);
|
||||||
} finally {
|
ctx(error, undefined);
|
||||||
lock.release();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SniPrepare = async () => {
|
const SniPrepare = async () => {
|
||||||
await ensureDir(certsDir);
|
await ensureDir(certsDir);
|
||||||
await ensureDir(accountDir);
|
await ensureDir(accountDir);
|
||||||
|
|
||||||
if (fs.existsSync(path.join(accountDir, 'privateKey.pem')) &&
|
if (fs.existsSync(path.join(accountDir, 'privateKey.pem')) &&
|
||||||
fs.existsSync(path.join(accountDir, 'publicKey.pem'))) {
|
fs.existsSync(path.join(accountDir, 'publicKey.pem'))) {
|
||||||
await client.importAccountKeyPair(accountDir, '');
|
await client.importAccountKeyPair(accountDir, '');
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
await client.generateAccountKeyPair();
|
await client.generateAccountKeyPair();
|
||||||
await client.exportAccountKeyPair(accountDir, '');
|
await client.exportAccountKeyPair(accountDir, '');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
export { SniListener, SniPrepare, client };
|
||||||
module.exports = {
|
|
||||||
SniListener,
|
|
||||||
SniPrepare,
|
|
||||||
client,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
35
src/util.js
35
src/util.js
|
|
@ -1,18 +1,20 @@
|
||||||
const {
|
import { default as axios } from "axios";
|
||||||
default: axios
|
import crypto from "crypto";
|
||||||
} = require('axios');
|
import fs from "fs";
|
||||||
var crypto = require('crypto');
|
|
||||||
const fs = require('fs');
|
|
||||||
const blacklistURL = (process.env.BLACKLIST_HOSTS || "").split(',').reduce((acc, host) => {
|
const blacklistURL = (process.env.BLACKLIST_HOSTS || "").split(',').reduce((acc, host) => {
|
||||||
acc[host] = true;
|
acc[host] = true;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
function md5(str) {
|
/**
|
||||||
|
* @param {crypto.BinaryLike} str
|
||||||
|
*/
|
||||||
|
export function md5(str) {
|
||||||
return crypto.createHash('md5').update(str).digest('hex');
|
return crypto.createHash('md5').update(str).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHostBlacklisted(domain = '') {
|
export function isHostBlacklisted(domain = '') {
|
||||||
if (domain.length > 6) {
|
if (domain.length > 6) {
|
||||||
let p = domain.lastIndexOf('.', domain.length - 6);
|
let p = domain.lastIndexOf('.', domain.length - 6);
|
||||||
if (p > 0) {
|
if (p > 0) {
|
||||||
|
|
@ -22,10 +24,14 @@ function isHostBlacklisted(domain = '') {
|
||||||
return blacklistURL[domain];
|
return blacklistURL[domain];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDir(dir) {
|
/**
|
||||||
|
* @param {fs.PathLike} dir
|
||||||
|
*/
|
||||||
|
export async function ensureDir(dir) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(dir, fs.constants.W_OK | fs.constants.O_DIRECTORY);
|
await fs.promises.access(dir, fs.constants.W_OK | fs.constants.O_DIRECTORY);
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
await fs.promises.mkdir(dir, {
|
await fs.promises.mkdir(dir, {
|
||||||
recursive: true
|
recursive: true
|
||||||
});
|
});
|
||||||
|
|
@ -36,7 +42,7 @@ async function ensureDir(dir) {
|
||||||
* @param {string} host
|
* @param {string} host
|
||||||
* @param {string} prefix
|
* @param {string} prefix
|
||||||
*/
|
*/
|
||||||
async function findTxtRecord(host, prefix, required = true) {
|
export async function findTxtRecord(host, prefix, required = true) {
|
||||||
const resolve = await axios(`https://dns.google/resolve?name=_.${encodeURIComponent(host)}&type=TXT`);
|
const resolve = await axios(`https://dns.google/resolve?name=_.${encodeURIComponent(host)}&type=TXT`);
|
||||||
if (resolve.data.Answer) {
|
if (resolve.data.Answer) {
|
||||||
for (const head of resolve.data.Answer) {
|
for (const head of resolve.data.Answer) {
|
||||||
|
|
@ -51,9 +57,8 @@ async function findTxtRecord(host, prefix, required = true) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export function combineURLs(baseURL, relativeURL) {
|
||||||
md5,
|
return relativeURL
|
||||||
ensureDir,
|
? baseURL.replace(/\/+$/, '') + '/' + relativeURL.replace(/^\/+/, '')
|
||||||
findTxtRecord,
|
: baseURL;
|
||||||
isHostBlacklisted,
|
|
||||||
}
|
}
|
||||||
31
stat.js
31
stat.js
|
|
@ -1,8 +1,7 @@
|
||||||
// separate stat endpoint
|
import { config } from "dotenv";
|
||||||
|
import { execSync } from "child_process";
|
||||||
require('dotenv').config()
|
import http from "http";
|
||||||
const { execSync } = require('child_process');
|
import { fileURLToPath } from "url";
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const updateStat = function () {
|
const updateStat = function () {
|
||||||
// run npm stat
|
// run npm stat
|
||||||
|
|
@ -12,13 +11,13 @@ const updateStat = function () {
|
||||||
domains: parseInt(lines[lines.length - 1]),
|
domains: parseInt(lines[lines.length - 1]),
|
||||||
iat: Date.now(),
|
iat: Date.now(),
|
||||||
exp: Date.now() + 1000 * 60 * 60 * 24,
|
exp: Date.now() + 1000 * 60 * 60 * 24,
|
||||||
}
|
};
|
||||||
return stat;
|
return stat;
|
||||||
}
|
};
|
||||||
|
|
||||||
let cacheStat = updateStat();
|
let cacheStat = updateStat();
|
||||||
|
|
||||||
const listener = async function ( /** @type {import('http').IncomingMessage} */ req, /** @type {import('http').ServerResponse} */ res) {
|
const listener = async function (/** @type {import('http').IncomingMessage} */ req, /** @type {import('http').ServerResponse} */ res) {
|
||||||
try {
|
try {
|
||||||
// handle CORS
|
// handle CORS
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
|
@ -29,7 +28,6 @@ const listener = async function ( /** @type {import('http').IncomingMessage} */
|
||||||
res.statusCode = 204;
|
res.statusCode = 204;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (req.url) {
|
switch (req.url) {
|
||||||
case '/':
|
case '/':
|
||||||
if (cacheStat.exp < Date.now()) {
|
if (cacheStat.exp < Date.now()) {
|
||||||
|
|
@ -48,21 +46,24 @@ const listener = async function ( /** @type {import('http').IncomingMessage} */
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
res.writeHead(400);
|
res.writeHead(400);
|
||||||
res.write(error.message || 'Unknown error');
|
res.write(error.message || 'Unknown error');
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const server = http.createServer(listener);
|
const server = http.createServer(listener);
|
||||||
|
|
||||||
if (require.main === module) {
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
config();
|
||||||
const port = parseInt(process.env.STAT_PORT || "3000");
|
const port = parseInt(process.env.STAT_PORT || "3000");
|
||||||
server.listen(port, function () {
|
server.listen(port, function () {
|
||||||
console.log(`server start at port ${port}`);
|
console.log(`server start at port ${port}`);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
module.exports = server
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default server;
|
||||||
|
|
|
||||||
Loading…
Add table
editor.link_modal.header
Reference in a new issue