Fix SNI lock, update deps, ESM refactor

This commit is contained in:
Wildan M 2023-04-21 10:46:24 +07:00
commit 447b1b735d
13 changed files with 1204 additions and 1144 deletions

View file

@ -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
View file

@ -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({

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
} }
} }

View file

@ -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

View file

@ -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
}

View file

@ -1,4 +1,3 @@
module.exports = { import Client from './client.js';
...require('./common'), export * from './common.js';
Client: require('./client') export { Client };
}

View file

@ -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

View file

@ -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;

View file

@ -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,
}

View file

@ -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
View file

@ -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;