├── .nvmrc
├── .gitignore
├── .dockerignore
├── Dockerfile
├── .prettierrc.js
├── lib
├── db.js
├── tools.js
├── proxy-server.js
├── redis-challenge.js
├── sni.js
├── check-url.js
├── app.js
└── certs.js
├── .eslintrc
├── errors
└── 502.html
├── setup
├── dhparam.pem
├── https-cert.pem
└── https-privkey.pem
├── CHANGELOG.md
├── LICENSE
├── README.md
├── package.json
├── server.js
├── .github
└── workflows
│ └── release.yaml
├── config
└── default.toml
└── worker.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 16
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | package-lock.json
4 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git*
2 | node_modules
3 | Dockerfile
4 | .vscode
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-alpine
2 | WORKDIR /app
3 | COPY . .
4 | RUN npm ci --only=production
5 | EXPOSE 80 443
6 | ENTRYPOINT npm run start
7 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 160,
3 | tabWidth: 4,
4 | singleQuote: true,
5 | endOfLine: 'lf',
6 | trailingComma: 'none',
7 | arrowParens: 'avoid'
8 | };
9 |
--------------------------------------------------------------------------------
/lib/db.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const config = require('wild-config');
4 | const Redis = require('ioredis');
5 |
6 | const redisClient = new Redis(config.redis);
7 |
8 | module.exports = {
9 | redisClient
10 | };
11 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "indent": 0,
4 | "no-await-in-loop": 0,
5 | "require-atomic-updates": 0
6 | },
7 | "globals": {
8 | "BigInt": true
9 | },
10 | "extends": ["nodemailer", "prettier"],
11 | "parserOptions": {
12 | "ecmaVersion": 2020
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/errors/502.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Gateway Error
6 |
7 |
8 |
9 |
Something went wrong 🤖
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/setup/dhparam.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN DH PARAMETERS-----
2 | MIIBCAKCAQEAwbfIuVOwkQHPiGv2OXTMtzHziWnv0ZgpVl8HG+EXHTQR2xhhg4X9
3 | D/VrKluLDzbLvicPLYw7Lpg1ZzcjVwoFkDRxxodVyRwUFgfcHcXBhpLE891FKSXV
4 | 39U8+pYI+ga4F8ImejutKFSVvt2VsZRTv1Cl92HzRUAZC+M1yENENMetcJI6oYQA
5 | +T2cmiQoA1VwLaqXEGrFgEFOOBqI4WQss0DpCrtHlhaSdBz7MNmWyMFvE70INkLx
6 | sHx0JRmvhswVQ7uDJ84EbH7c5SYszgoT/8Frnfmq3MkSx/fqgvk41LimiKVYdyzY
7 | LandwwLO4sVGIAKDefRm13TvGOtBXMInCwIBAg==
8 | -----END DH PARAMETERS-----
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.3.2](https://github.com/andris9/https-front/compare/v1.3.1...v1.3.2) (2023-10-05)
4 |
5 |
6 | ### Bug Fixes
7 |
8 | * **deploy:** changed env to vars ([a968d17](https://github.com/andris9/https-front/commit/a968d173a8d0a052ef4efa4a968ab25d1b489f3e))
9 | * **deploy:** changed env to vars ([b638759](https://github.com/andris9/https-front/commit/b6387595e2f85ac3d9c6de417f908dea62376abe))
10 |
11 | ## [1.3.1](https://github.com/andris9/https-front/compare/v1.3.0...v1.3.1) (2023-10-05)
12 |
13 |
14 | ### Bug Fixes
15 |
16 | * **deploy:** updated release script ([44a35bf](https://github.com/andris9/https-front/commit/44a35bf908b863e19123b3d348886a553ad0f92c))
17 |
18 | ## [1.3.0](https://github.com/andris9/https-front/compare/v1.2.1...v1.3.0) (2023-10-05)
19 |
20 |
21 | ### Features
22 |
23 | * **deploy:** added automatic release management ([927c5c3](https://github.com/andris9/https-front/commit/927c5c3c477ee31fe7c0b897579b760d778047c7))
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020 Andris Reinman
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
13 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
14 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16 | SOFTWARE.
17 |
--------------------------------------------------------------------------------
/setup/https-cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIC8DCCAdgCCQCfexAThcadzzANBgkqhkiG9w0BAQsFADA6MRowGAYDVQQDDBFo
3 | dHRwcy1mcm9udC5sb2NhbDEPMA0GA1UECgwGQW5kcmlzMQswCQYDVQQGEwJFRTAe
4 | Fw0yMDEwMjMwNzMwNDNaFw0yMTEwMjMwNzMwNDNaMDoxGjAYBgNVBAMMEWh0dHBz
5 | LWZyb250LmxvY2FsMQ8wDQYDVQQKDAZBbmRyaXMxCzAJBgNVBAYTAkVFMIIBIjAN
6 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1H5OwXK4MSu9Y8rdMMc0MZM7t9mq
7 | U7UaMRgj4JPKJ/zjA6pd+RCzqiDUeUzDsrQpVSoRTrTNHgox7qFa/TgDTf6mO6hC
8 | ONK7bSDUp80Uhaq+cyUk6lk1ajSWSnVyFWZMVNh/C2ZuQyCEhgCPO6+0eSgujFiB
9 | NnUiRJuNhTZPRQrxUChCsu7v0qZ1zumQOIUzTwr+idl8XFk55pXTCd2zYixglqQH
10 | aZSLa7HUKRXowKtwNhP36KWoKbnEhxbBNpUF8BEkDwXl9wNnz83aBQV/miSRCH33
11 | tPEgyfMJvAWflsaEYVEcpBuEMuBs8GcFR1ezh4b8XISDQJLS5kt+GLs6mQIDAQAB
12 | MA0GCSqGSIb3DQEBCwUAA4IBAQA6Cb1rfYGZSWWMcnNoyPjtZ547lRNdzFNkWMpG
13 | ICVe1oEDV9Cg+B4o0xnc60npvdFZt7rz0J3KE0P+cKKaWgRcZGntgMsc76pIVrKC
14 | AYGSczAeSwngQnwvg9fOyT96Ny2tZSxcDnGaBH0niyo6AYvKJAmNd83ANz1/w6Nz
15 | Zv6qaDTkTBeYUDdev2r3pb/xnPrEvPn8KqDeTdrMmeMhQmetux3hOglUSGYPldeZ
16 | 56CkhMnIbmIFye/YhPv4LJjOYrdAWgiUZ+tW5FPQENlRtcC6SRYHsvdNPhTNEjN/
17 | dgSEc+F2TtY2Xw8yrJ37JJV3ulOcYDn3kpr8I+5l0LbBxzbO
18 | -----END CERTIFICATE-----
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HTTPS Front
2 |
3 | Simple HTTP/HTTPS proxy server that generously tries to set up LE HTTP certificates for any domain.
4 |
5 | Main use case - you want to expose the same origin via unknown amount of domain names that might require HTTPS.
6 |
7 | ## Features
8 |
9 | - All requests, no matter the domain name, are proxied to a single configured origin
10 | - HTTPS certificates get generated on first request
11 | - Certificates are renewed for active domain names only
12 | - All data is stored in Redis, so you can run several instances in different servers that all share the same certificate pool
13 |
14 | ## Usage
15 |
16 | ### 1. Configure
17 |
18 | Edit the [configuration file](config/default.toml).
19 |
20 | ### 2. Install dependencies
21 |
22 | ```
23 | $ npm install --production
24 | ```
25 |
26 | ### 3. Run the application
27 |
28 | **NB!** your service user must have the privileges to use ports 443 and 80
29 |
30 | ```
31 | $ npm start
32 | ```
33 |
34 | ## Default Certificates
35 |
36 | Default certificate files reside in [setup](setup) folder. You can regenerate these by running
37 |
38 | ```
39 | $ npm run testcerts
40 | ```
41 |
42 | This will take some time as a new dhparam file is generated as well.
43 |
44 | ## License
45 |
46 | **MIT**
47 |
--------------------------------------------------------------------------------
/lib/tools.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const ipaddr = require('ipaddr.js');
4 | const net = require('net');
5 | const punycode = require('punycode/');
6 |
7 | const normalizeDomain = domain => {
8 | domain = (domain || '').toString().toLowerCase().trim();
9 | try {
10 | if (/[\x80-\uFFFF]/.test(domain)) {
11 | domain = punycode.toASCII(domain);
12 | }
13 | } catch (E) {
14 | // ignore
15 | }
16 |
17 | return domain;
18 | };
19 |
20 | const normalizeIp = ip => {
21 | ip = (ip || '').toString().toLowerCase().trim();
22 |
23 | if (/^[a-f0-9:]+:(\d+\.){3}\d+$/.test(ip)) {
24 | // remove pseudo IPv6 prefix
25 | ip = ip.replace(/^[a-f0-9:]+:((\d+\.){3}\d+)$/, '$1');
26 | }
27 |
28 | if (net.isIPv6(ip)) {
29 | // use the short version
30 | return ipaddr.parse(ip).toString();
31 | }
32 |
33 | return ip;
34 | };
35 |
36 | const getHostname = req => {
37 | let host =
38 | []
39 | .concat(req.headers.host || [])
40 | .concat(req.authority || [])
41 | .concat(req.ip || [])
42 | .shift() || '';
43 | host = host.split(':').shift();
44 |
45 | if (host) {
46 | host = normalizeDomain(host);
47 | }
48 |
49 | return host;
50 | };
51 |
52 | module.exports = { normalizeDomain, normalizeIp, getHostname };
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "https-front",
3 | "version": "1.3.2",
4 | "description": "Simple HTTPS proxy for single origin",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node server.js",
8 | "test": "echo \"Error: no test specified\" && exit 1",
9 | "testcerts": "openssl dhparam -out setup/dhparam.pem 2048 && openssl req -subj \"/CN=https-front.local/O=Andris/C=EE\" -new -newkey rsa:2048 -days 365 -nodes -x509 -keyout setup/https-privkey.pem -out setup/https-cert.pem"
10 | },
11 | "keywords": [],
12 | "author": {
13 | "name": "Andris Reinman",
14 | "email": "andris@kreata.ee"
15 | },
16 | "license": "MIT",
17 | "dependencies": {
18 | "@fidm/x509": "1.2.1",
19 | "@root/acme": "3.1.0",
20 | "@root/csr": "0.8.1",
21 | "axios": "1.5.1",
22 | "http-proxy": "1.18.1",
23 | "ioredfour": "1.2.0-ioredis-07",
24 | "ioredis": "5.3.2",
25 | "ipaddr.js": "2.1.0",
26 | "joi": "17.11.0",
27 | "pem-jwk": "2.0.0",
28 | "pino": "8.15.6",
29 | "psl": "1.9.0",
30 | "punycode": "2.3.0",
31 | "uuid": "9.0.1",
32 | "wild-config": "1.7.1"
33 | },
34 | "devDependencies": {
35 | "eslint": "8.50.0",
36 | "eslint-config-nodemailer": "1.2.0",
37 | "eslint-config-prettier": "9.0.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/setup/https-privkey.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDUfk7BcrgxK71j
3 | yt0wxzQxkzu32apTtRoxGCPgk8on/OMDql35ELOqINR5TMOytClVKhFOtM0eCjHu
4 | oVr9OANN/qY7qEI40rttINSnzRSFqr5zJSTqWTVqNJZKdXIVZkxU2H8LZm5DIISG
5 | AI87r7R5KC6MWIE2dSJEm42FNk9FCvFQKEKy7u/SpnXO6ZA4hTNPCv6J2XxcWTnm
6 | ldMJ3bNiLGCWpAdplItrsdQpFejAq3A2E/fopagpucSHFsE2lQXwESQPBeX3A2fP
7 | zdoFBX+aJJEIffe08SDJ8wm8BZ+WxoRhURykG4Qy4GzwZwVHV7OHhvxchINAktLm
8 | S34YuzqZAgMBAAECggEAEzEBLemNhytbJIsq5P/oz91rVFR9VKgToIF8pAjVBj2J
9 | x0f4ysjeYSwr5HSxbA9neECfZYtgxyjGj7XVAO+xJLcuDk9JA9bMhLOlYS5dfyEH
10 | qGCfb+b0sw3i0QDAd/xQQo13E/GBXeCu92dPiGV7GEIvyg8oRGHZ4XZnFrPr/uEu
11 | /q3rbDUZYcY+dSCVHyl38KWORwBeUEheVgydc5jJUOnZ1M6w+y+2dFZBq7U+URCc
12 | 9nkw0a4pWhMtsgp+WGzSz/4UTSTNUf1s1IQYURupIftwVO5fL2ISKlNwr5/5ALwa
13 | rVmkAYsF2sXHfkSaiiZRGnbatvLmpAjV7pQHp0ClMQKBgQDq8tFNhR9bVKgXK8yf
14 | WBOXFabKTd3jPI5qaDjKDpEavW5LMRiCeIpDAsbXaZnF4EEtZD/sdqwrCpIpLSLR
15 | SeFg4WhFKu0XYWHGUu8zh6YJ1dc/pJVn/NgP2V2/iPuC25o73VfzHgQcU+DrBGEi
16 | stPaU/F/6dJ6FEIEFIR7loT3vQKBgQDniGvBv6EPamrU/3OEi4+JBBXWW0StjbSj
17 | tLtBoXUgNCdz9QcYY1JEbbsx8pxbWTeG5GtdoHhBNZ8/bzlWbpNprqXqu2THMpk/
18 | fA0b0deZgdgujr1DODHpWS4X8H3d5peG3Pgb2ULP5cLQxAMVBVynClMhzasA9VLX
19 | lNbJRqSeDQKBgQCpzWYxtYWNF2kInhIcE6bM6cwKqC42Xfy7sKlidxauEbxVwZzq
20 | Jr4eYjJdWyfU2Bei+7IrbzVNQi2SbtmcEt49i4s1eimyXSIyGJxiTKZWs2MGzydf
21 | 6WAqTDmyBQlpcNdObtFylv33jzOeByNA1afBQivm+5Gvw1ZW5pE9VPKyrQKBgQCZ
22 | D6GU8xcJduNrLfjzDcP042OAUtPDHCPn+Wm1iIRCptfSG5D2OWrAW/5dlbJx3TgN
23 | D+I+ggAds9I0AFZaYj1HpzJ+TCXiXfvbcSnFU2MBU0pT9P7/eh3c0pzbLJw43uEb
24 | QecvmeBGSfERTBNxiRroPrYYabt7pbJ/XCDl7LKU3QKBgQDM6XeBbtafZsgcJDWA
25 | p/irwTyJKU6Ax8sAU6BpydjsOzcUlNxkm8H8D9yOupEN3n8IRW+OS/lEOJDP2OfI
26 | zBdvdSV2MDqLzvxRpkeIJZug/T3UJxjw4cUykGimM+k1sLMzeNUgWbl5kgTjWfyL
27 | HwsR4IijuzSX8egUYW1VQp3BlA==
28 | -----END PRIVATE KEY-----
29 |
--------------------------------------------------------------------------------
/lib/proxy-server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const httpProxy = require('http-proxy');
4 | const config = require('wild-config');
5 | const fs = require('fs');
6 |
7 | const pino = require('pino')();
8 | const logger = pino.child({ app: 'https-front', component: 'proxy' });
9 |
10 | const error502 = fs.readFileSync(config.proxy.error502, 'utf-8');
11 |
12 | const proxyServer = httpProxy.createProxyServer({});
13 |
14 | proxyServer.on('proxyReq', (proxyReq, req) => {
15 | proxyReq.setHeader('X-Forwarded-Proto', req.proto);
16 | proxyReq.setHeader('X-Connecting-IP', req.ip);
17 | req.stats = {
18 | time: Date.now()
19 | };
20 | });
21 |
22 | proxyServer.on('proxyRes', (proxyReq, req, res) => {
23 | logger.info({
24 | msg: `Proxy access`,
25 | remoteAddress: req.ip,
26 | protocol: req.proto,
27 | domain: req.domain,
28 | url: req.url,
29 | userAgent: req.headers['user-agent'] || '',
30 | time: Date.now() - req.stats.time,
31 | response: proxyReq.statusCode
32 | });
33 | if (config?.proxy?.headers?.length) {
34 | for (let header of config.proxy.headers) {
35 | if (header?.key && header?.value) {
36 | res.setHeader(header.key, header.value);
37 | }
38 | }
39 | }
40 | });
41 |
42 | proxyServer.on('error', (err, req, res) => {
43 | res.writeHead(502, {
44 | 'Content-Type': 'text/html'
45 | });
46 |
47 | res.end(error502);
48 |
49 | logger.info({
50 | msg: `Proxy error`,
51 | remoteAddress: req.ip,
52 | protocol: req.proto,
53 | domain: req.domain,
54 | url: req.url,
55 | userAgent: req.headers['user-agent'] || '',
56 | time: Date.now() - req.stats.time,
57 | response: 502,
58 | err
59 | });
60 | });
61 |
62 | module.exports = { proxyServer };
63 |
--------------------------------------------------------------------------------
/lib/redis-challenge.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { v4: uuid } = require('uuid');
4 |
5 | // Unfinished challenges are deleted after this amount of time
6 | const DEFAULT_KEY_TTL = 2 * 3600; // seconds
7 |
8 | class RedisChallenge {
9 | static create(config = {}) {
10 | return new RedisChallenge(config);
11 | }
12 |
13 | constructor(config) {
14 | this.config = config;
15 | const { hashKey, redisClient, keyTtl } = this.config;
16 |
17 | this.uuid = uuid();
18 | this.hashKey = hashKey;
19 | this.redisClient = redisClient;
20 | this.keyTtl = keyTtl || DEFAULT_KEY_TTL;
21 | }
22 |
23 | hashField(domain, token) {
24 | return `${this.hashKey}:${domain}:${token}`;
25 | }
26 |
27 | init(/*opts*/) {
28 | // not much to do here
29 | return null;
30 | }
31 |
32 | async set(opts) {
33 | const { challenge } = opts;
34 | const { altname, keyAuthorization, token } = challenge;
35 |
36 | const keyName = this.hashField(altname, token);
37 | const res = await this.redisClient.multi().set(keyName, keyAuthorization).expire(keyName, this.keyTtl).exec();
38 | if (res?.[0]?.[0]) {
39 | throw res?.[0]?.[0];
40 | }
41 | return res?.[0]?.[1];
42 | }
43 |
44 | async get(query) {
45 | const { challenge } = query;
46 | const { identifier, token } = challenge;
47 | const domain = identifier.value;
48 |
49 | const secret = await this.redisClient.get(this.hashField(domain, token));
50 | return secret ? { keyAuthorization: secret } : null;
51 | }
52 |
53 | async remove(opts) {
54 | const { challenge } = opts;
55 | const { identifier, token } = challenge;
56 | const domain = identifier.value;
57 |
58 | return await this.redisClient.expire(this.hashField(domain, token), 10);
59 | }
60 | }
61 |
62 | module.exports = RedisChallenge;
63 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* eslint global-require: 0 */
4 |
5 | const cluster = require('cluster');
6 | const config = require('wild-config');
7 | const pino = require('pino')();
8 | const logger = pino.child({ app: 'https-front', component: 'cluster' });
9 |
10 | let closing = false;
11 | const closeProcess = code => {
12 | if (closing) {
13 | return;
14 | }
15 | closing = true;
16 | if (cluster.isMaster) {
17 | logger.info({ msg: 'Closing the application...', code });
18 | }
19 | process.exit(code);
20 | };
21 |
22 | process.on('uncaughtException', err => {
23 | logger.fatal({ msg: 'uncaughtException', err });
24 | closeProcess(1);
25 | });
26 |
27 | process.on('unhandledRejection', err => {
28 | logger.fatal({ msg: 'uncaughtException', err });
29 | closeProcess(2);
30 | });
31 |
32 | process.on('SIGTERM', () => {
33 | if (cluster.isMaster) {
34 | logger.info({ msg: 'Received SIGTERM', signal: 'SIGTERM' });
35 | }
36 | closeProcess(0);
37 | });
38 |
39 | process.on('SIGINT', () => {
40 | if (cluster.isMaster) {
41 | logger.info({ msg: 'Received SIGINT', signal: 'SIGINT' });
42 | }
43 | closeProcess(0);
44 | });
45 |
46 | if (cluster.isMaster) {
47 | process.title = 'https-front: main';
48 | logger.info({ msg: 'Master process started', workers: config.proxy.workers });
49 |
50 | const fork = () => {
51 | if (closing) {
52 | return;
53 | }
54 | let worker = cluster.fork();
55 | worker.on('online', () => {
56 | logger.info({ msg: 'Worker came online', worker: worker.process.pid });
57 | });
58 | };
59 |
60 | for (let i = 0; i < config.proxy.workers; i++) {
61 | fork();
62 | }
63 |
64 | cluster.on('exit', (worker, code, signal) => {
65 | if (closing) {
66 | return;
67 | }
68 | logger.error({ msg: 'Worker died', worker: worker.process.pid, code, signal });
69 | setTimeout(() => fork(), 2000).unref();
70 | });
71 | } else {
72 | process.title = 'https-front: worker';
73 | // worker to serve public websites
74 | require('./worker.js');
75 | }
76 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - master
5 |
6 | permissions:
7 | contents: write
8 | pull-requests: write
9 | id-token: write
10 |
11 | name: release
12 | jobs:
13 | release_please:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: google-github-actions/release-please-action@v3
17 | id: release
18 | with:
19 | release-type: node
20 | package-name: ${{vars.PACKAGE_NAME}}
21 | pull-request-title-pattern: 'chore${scope}: release ${version} [skip-ci]'
22 | pull-request-header: ':robot: I have created a release *beep* *boop* (${{vars.PACKAGE_NAME}})'
23 |
24 | - uses: actions/checkout@v3
25 | if: ${{ steps.release.outputs.release_created }}
26 |
27 | - uses: actions/setup-node@v3
28 | with:
29 | node-version: 20
30 | registry-url: 'https://registry.npmjs.org'
31 | if: ${{ steps.release.outputs.release_created }}
32 |
33 | - name: 'Install NPM dependencies'
34 | run: npm install --omit=dev
35 | if: ${{ steps.release.outputs.release_created }}
36 |
37 | - name: 'Create a release file'
38 | run: echo ${{ github.sha }} > Release.txt
39 | if: ${{ steps.release.outputs.release_created }}
40 |
41 | - name: 'Compress folder'
42 | run: tar --exclude-vcs -czf /tmp/${{ vars.PACKAGE_NAME }}.tar.gz .
43 | if: ${{ steps.release.outputs.release_created }}
44 |
45 | - name: 'Upload Release Asset'
46 | id: upload-release-asset
47 | uses: actions/upload-release-asset@v1
48 | env:
49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
50 | with:
51 | upload_url: ${{ steps.release.outputs.upload_url }}
52 | asset_path: /tmp/${{vars.PACKAGE_NAME}}.tar.gz
53 | asset_name: ${{vars.PACKAGE_NAME}}.tar.gz
54 | asset_content_type: application/tar+gzip
55 | if: ${{ steps.release.outputs.release_created }}
56 |
--------------------------------------------------------------------------------
/lib/sni.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Joi = require('joi');
4 | const { normalizeDomain } = require('./tools');
5 | const { getCertificate } = require('./certs');
6 | const { redisClient } = require('./db');
7 | const config = require('wild-config');
8 | const fs = require('fs');
9 | const tls = require('tls');
10 |
11 | const pino = require('pino')();
12 | const logger = pino.child({ app: 'https-front', component: 'sni' });
13 |
14 | const ctxCache = new Map();
15 | const sessionIdContext = config.https.sessionIdContext;
16 |
17 | const defaultKey = fs.readFileSync(config.https.key, 'utf-8');
18 | const defaultCert = fs.readFileSync(config.https.cert, 'utf-8');
19 | const dhparam = fs.readFileSync(config.https.dhParam, 'utf-8');
20 |
21 | const getSNIContext = async servername => {
22 | const domain = normalizeDomain(servername.split(':').shift());
23 |
24 | const validation = Joi.string()
25 | .domain({ tlds: { allow: true } })
26 | .validate(domain);
27 |
28 | if (validation.error) {
29 | // invalid domain name, can not create certificate
30 | return false;
31 | }
32 |
33 | const cert = await getCertificate(
34 | {
35 | redisClient,
36 | acme: config.acme
37 | },
38 | domain
39 | );
40 |
41 | if (!cert) {
42 | return false;
43 | }
44 |
45 | if (ctxCache.has(domain)) {
46 | let { expires, ctx } = ctxCache.get(domain);
47 | if (expires === cert.expires.getTime()) {
48 | return ctx;
49 | }
50 | ctxCache.delete(domain);
51 | }
52 |
53 | const ctxOpts = {
54 | key: cert.key,
55 | cert: [].concat(cert.cert).concat(cert.chain).join('\n\n')
56 | };
57 |
58 | const ctx = tls.createSecureContext(ctxOpts);
59 |
60 | ctxCache.set(domain, {
61 | expires: cert.expires.getTime(),
62 | ctx
63 | });
64 |
65 | return ctx;
66 | };
67 |
68 | const defaultCtx = tls.createSecureContext({
69 | key: defaultKey,
70 | cert: defaultCert,
71 | dhparam,
72 | sessionIdContext
73 | });
74 |
75 | const httpsCredentials = {
76 | key: defaultKey,
77 | cert: defaultCert,
78 | dhparam,
79 | sessionIdContext,
80 | SNICallback(servername, cb) {
81 | getSNIContext(servername)
82 | .then(ctx => {
83 | logger.info({ msg: 'SNI handler', servername, match: !!ctx });
84 | cb(null, ctx || defaultCtx);
85 | })
86 | .catch(err => {
87 | logger.error({ msg: 'SNI failed', servername, err });
88 | return cb(null, defaultCtx);
89 | });
90 | }
91 | };
92 |
93 | module.exports = {
94 | getSNIContext,
95 | defaultCtx,
96 | httpsCredentials
97 | };
98 |
--------------------------------------------------------------------------------
/lib/check-url.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const config = require('wild-config');
4 | const axios = require('axios');
5 | const packageData = require('../package.json');
6 |
7 | async function checkUrl(domain) {
8 | let res;
9 |
10 | switch ((config.checkUrl.method || '').toLowerCase()) {
11 | case 'post':
12 | {
13 | let data;
14 |
15 | if (config.checkUrl.format === 'form') {
16 | //application/x-www-form-urlencoded
17 | data = new URLSearchParams();
18 | data.append(config.checkUrl.key, domain);
19 | } else {
20 | // json
21 | data = { [config.checkUrl.key]: domain };
22 | }
23 |
24 | res = await axios.post(config.checkUrl.url, data, {
25 | headers: {
26 | 'User-Agent': `https-front/${packageData.version}`
27 | }
28 | });
29 | }
30 | break;
31 | case 'get':
32 | default: {
33 | let url = new URL(config.checkUrl.url);
34 | url.searchParams.set(config.checkUrl.key, domain);
35 | res = await axios.get(url.origin, {
36 | headers: {
37 | 'User-Agent': `https-front/${packageData.version}`
38 | }
39 | });
40 | }
41 | }
42 |
43 | let expect = config.checkUrl.expect || {};
44 |
45 | if (expect.status && ![].concat(expect.status).includes(res.status)) {
46 | // status code check failed
47 | let err = new Error(`Invalid response status ${res.status}`);
48 | err.statusCode = res.status;
49 | throw err;
50 | }
51 |
52 | if (expect.key && expect.value) {
53 | let keyPath = expect.key.split('.');
54 | let value;
55 | if (res.data && typeof res.data === 'object') {
56 | value = res.data;
57 | while (keyPath.length && value) {
58 | let key = keyPath.shift();
59 | value = value[key];
60 | }
61 | }
62 | if (expect.value !== value) {
63 | let err = new Error(`Invalid value ${expect.key}=${value} (expected ${expect.value})`);
64 | err.statusCode = res.status;
65 | throw err;
66 | }
67 | }
68 |
69 | if (expect.textMatch) {
70 | let data = res.data && Buffer.isBuffer(res.data) ? res.data.toString() : res.data;
71 | if (typeof data !== 'string' || data.indexOf(expect.textMatch) < 0) {
72 | let err = new Error(`Did not find expected text "${expect.textMatch}"`);
73 | err.statusCode = res.status;
74 | throw err;
75 | }
76 | }
77 |
78 | return true;
79 | }
80 |
81 | module.exports.checkUrl = checkUrl;
82 |
--------------------------------------------------------------------------------
/lib/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { proxyServer } = require('./proxy-server');
4 | const config = require('wild-config');
5 | const { normalizeIp, getHostname } = require('./tools');
6 | const RedisChallenge = require('./redis-challenge');
7 | const { redisClient } = require('./db');
8 |
9 | const pino = require('pino')();
10 | const logger = pino.child({ app: 'https-front', component: 'app' });
11 |
12 | const ACME_PREFIX = '/.well-known/acme-challenge/';
13 |
14 | const redisChallenge = RedisChallenge.create({
15 | hashKey: `acme:challenge:${config.acme.key}`,
16 | redisClient
17 | });
18 |
19 | const app = (req, res) => {
20 | req.ip = normalizeIp(res.socket.remoteAddress);
21 | req.domain = getHostname(req);
22 |
23 | if (req.url.indexOf(ACME_PREFIX) === 0) {
24 | const token = req.url.slice(ACME_PREFIX.length);
25 |
26 | return redisChallenge
27 | .get({
28 | challenge: {
29 | token,
30 | identifier: { value: req.domain }
31 | }
32 | })
33 | .then(val => {
34 | if (!val || !val.keyAuthorization) {
35 | let err = new Error(`Unknown challenge`);
36 | err.statusCode = 404;
37 | throw err;
38 | }
39 | res.statusCode = 200;
40 | res.setHeader('Content-Type', 'text/plain');
41 | res.end(val.keyAuthorization);
42 | logger.debug({
43 | msg: 'Resolved authorization token',
44 | domain: req.domain,
45 | remoteAddress: req.ip,
46 | url: req.url,
47 | token,
48 | keyAuthorization: val.keyAuthorization
49 | });
50 | })
51 | .catch(err => {
52 | res.statusCode = err.statusCode || 500;
53 | res.setHeader('Content-Type', 'text/plain');
54 | res.end('Failed to verify authorization token');
55 | logger.error({
56 | msg: 'Failed to verify authorization token',
57 | domain: req.domain,
58 | remoteAddress: req.ip,
59 | url: req.url,
60 | token,
61 | status: res.statusCode,
62 | userAgent: req.headers['user-agent'] || '',
63 | err
64 | });
65 | });
66 | }
67 |
68 | let rUrl = new URL(config.proxy.origin);
69 | return proxyServer.web(req, res, {
70 | target: rUrl.origin,
71 | changeOrigin: false,
72 | xfwd: true,
73 | secure: false,
74 | prependPath: true,
75 | autoRewrite: true
76 | });
77 | };
78 |
79 | module.exports = { app };
80 |
--------------------------------------------------------------------------------
/config/default.toml:
--------------------------------------------------------------------------------
1 |
2 | [http]
3 | port = 8080
4 |
5 | [https]
6 | port = 8443
7 |
8 | # Default certificates
9 | key = "./setup/https-privkey.pem"
10 | cert = "./setup/https-cert.pem"
11 | dhParam = "./setup/dhparam.pem"
12 |
13 | ciphers = "ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384"
14 | sessionIdContext = "https-front"
15 |
16 | # ACME staging settings
17 | [acme]
18 | key = "test" # identifier for Redis keys
19 | directoryUrl = "https://acme-staging-v02.api.letsencrypt.org/directory"
20 | email = "domainadmin@example.com"
21 |
22 | # ACME production settings
23 | #[acme]
24 | # key = "production"
25 | # directoryUrl = "https://acme-v02.api.letsencrypt.org/directory"
26 | # email = "domainadmin@example.com"
27 |
28 | # Only generate certificates for domain names that pass the following DNS validation
29 | # At least one of the response rows must match the expected value
30 | #[[precheck]]
31 | # key = "A" # A, AAAA, CNAME
32 | # expected = "188.165.168.22"
33 |
34 | # Set to use specific DNS servers for domain validation
35 | #[resolver]
36 | # ns = ["8.8.8.8", "1.1.1.1"]
37 |
38 | [extraChecks]
39 | # if not allowed then checks if the subdomain might be a wildcard domain and blocks certificates if it is
40 | wildCardAllowed = false
41 |
42 | # Validate domain names against the provided URL before provisioning a certificate.
43 | # If enabled then https-front makes a GET or POST request against that URL and only
44 | # continues with the provisioning if the response matches expectations.
45 | [checkUrl]
46 | enabled = false
47 | url = "http://localhost:3000/"
48 | method = "get" # GET or POST request
49 | key = "domain" # key for the domain name
50 | format = "json" # request format for POST, either "json" or "form"
51 |
52 | [checkUrl.expect]
53 | status = [200, 201] # allowed response status codes
54 |
55 | # Search for specific JSON key, use dot notation for subkeys eg "response.status"
56 | #key = "success"
57 | #value = true # Expected value for key
58 |
59 | # Look for a string match if response is not JSON
60 | #textMatch = "success" # Response body must contain this string
61 |
62 |
63 | [proxy]
64 | # All requests are proxied to the following origin
65 | origin = "http://localhost:3000/"
66 |
67 | # Error page to show if connection to origin fails
68 | error502 = "./errors/502.html"
69 |
70 | workers = 2
71 |
72 | # Downgrade user once HTTP/S ports are bound
73 | #user="www-data"
74 | #group="www-data
75 |
76 | # Optional response headers
77 | #[[proxy.headers]]
78 | # key = "server"
79 | # value = "https-front/1.0.0"
80 |
81 | [redis]
82 | host = "127.0.0.1"
83 | port = 6379
84 | db = 4
85 |
--------------------------------------------------------------------------------
/worker.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const http = require('http');
4 | const https = require('https');
5 |
6 | const config = require('wild-config');
7 | const { httpsCredentials } = require('./lib/sni');
8 | const { redisClient } = require('./lib/db');
9 | const { app } = require('./lib/app');
10 |
11 | const pino = require('pino')();
12 | const logger = pino.child({ app: 'https-front', component: 'worker' });
13 |
14 | const httpServer = http.createServer((req, res) => {
15 | req.proto = 'http';
16 | return app(req, res);
17 | });
18 |
19 | const httpsServer = https.createServer(httpsCredentials, (req, res) => {
20 | req.proto = 'https';
21 | return app(req, res);
22 | });
23 |
24 | httpsServer.on('newSession', (id, data, cb) => {
25 | redisClient
26 | .multi()
27 | .set(`tls:${id.toString('hex')}`, data)
28 | .expire(`tls:${id.toString('hex')}`, 30 * 60)
29 | .exec()
30 | .then(() => {
31 | cb();
32 | })
33 | .catch(err => {
34 | logger.error({ msg: 'Failed to store TLS ticket', id, data, err });
35 | cb();
36 | });
37 | });
38 |
39 | httpsServer.on('resumeSession', (id, cb) => {
40 | redisClient
41 | .multi()
42 | .getBuffer(`tls:${id.toString('hex')}`)
43 | // extend ticket
44 | .expire(`tls:${id.toString('hex')}`, 300)
45 | .exec()
46 | .then(result => {
47 | cb(null, result?.[0]?.[1] || null);
48 | })
49 | .catch(err => {
50 | logger.error({ msg: 'Failed to retrieve TLS ticket', err, id });
51 | cb(null);
52 | });
53 | });
54 |
55 | httpsServer.on('error', err => {
56 | logger.error({ msg: 'Web server error', proto: 'https', err });
57 | });
58 |
59 | httpServer.on('error', err => {
60 | logger.error({ msg: 'Web server error', proto: 'http', err });
61 | });
62 |
63 | const startHttp = () =>
64 | new Promise((resolve, reject) => {
65 | httpServer.once('error', reject);
66 | httpServer.listen(config.http.port, config.http.host, () => resolve());
67 | });
68 |
69 | const startHttps = () =>
70 | new Promise((resolve, reject) => {
71 | httpsServer.once('error', reject);
72 | httpsServer.listen(config.https.port, config.https.host, () => resolve());
73 | });
74 |
75 | const start = async () => {
76 | await Promise.all([startHttp(), startHttps()]);
77 | };
78 |
79 | start()
80 | .then(() => {
81 | if (config.proxy.group) {
82 | try {
83 | process.setgid(config.proxy.group);
84 | logger.info({ msg: 'Changed group', group: config.proxy.group, gid: process.getgid() });
85 | } catch (E) {
86 | logger.fatal({ msg: 'Failed to change group', group: config.proxy.group, err: E });
87 | return setTimeout(() => process.exit(1), 3000);
88 | }
89 | }
90 | if (config.proxy.user) {
91 | try {
92 | process.setuid(config.proxy.user);
93 | logger.info({ msg: 'Changed user', user: config.proxy.user, uid: process.getuid() });
94 | } catch (E) {
95 | logger.fatal({ msg: 'Failed to change user', user: config.proxy.user, err: E });
96 | return setTimeout(() => process.exit(1), 3000);
97 | }
98 | }
99 |
100 | logger.info('Server started');
101 | })
102 | .catch(err => {
103 | logger.fatal({ msg: 'Failed to start server', err });
104 | process.exit(1);
105 | });
106 |
--------------------------------------------------------------------------------
/lib/certs.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const crypto = require('crypto');
4 | const ACME = require('@root/acme');
5 | const { pem2jwk } = require('pem-jwk');
6 | const CSR = require('@root/csr');
7 | const { Certificate } = require('@fidm/x509');
8 | const RedisChallenge = require('./redis-challenge');
9 | const pkg = require('../package.json');
10 | const { normalizeDomain } = require('./tools');
11 | const Lock = require('ioredfour');
12 | const util = require('util');
13 | const { Resolver } = require('dns').promises;
14 | const resolver = new Resolver();
15 | const config = require('wild-config');
16 | const Joi = require('joi');
17 | const psl = require('psl');
18 | const { checkUrl } = require('./check-url');
19 |
20 | const { promisify } = require('util');
21 | const generateKeyPair = promisify(crypto.generateKeyPair);
22 |
23 | const pino = require('pino')();
24 | const logger = pino.child({ app: 'https-front', component: 'certs' });
25 |
26 | if (config?.resolver?.ns?.length) {
27 | resolver.setServers([].concat(config.resolver.ns || []));
28 | }
29 |
30 | const BLOCK_RENEW_AFTER_ERROR_TTL = 3600;
31 | const CAA_DOMAIN = 'letsencrypt.org';
32 |
33 | const acme = ACME.create({
34 | maintainerEmail: pkg.author.email,
35 | packageAgent: pkg.name + '/' + pkg.version,
36 | notify(ev, params) {
37 | logger.info({ msg: 'ACME Notification', ev, params });
38 | }
39 | });
40 |
41 | let getLock, releaseLock;
42 |
43 | // First try triggers initialization, others will wait until first is finished
44 | let acmeInitialized = false;
45 | let acmeInitializing = false;
46 | let acmeInitPending = [];
47 | const ensureAcme = async options => {
48 | if (acmeInitialized) {
49 | return true;
50 | }
51 | if (acmeInitializing) {
52 | return new Promise((resolve, reject) => {
53 | acmeInitPending.push({ resolve, reject });
54 | });
55 | }
56 |
57 | try {
58 | await acme.init(options.acme.directoryUrl);
59 | acmeInitialized = true;
60 |
61 | if (acmeInitPending.length) {
62 | for (let entry of acmeInitPending) {
63 | entry.resolve(true);
64 | }
65 | }
66 | } catch (err) {
67 | if (acmeInitPending.length) {
68 | for (let entry of acmeInitPending) {
69 | entry.reject(err);
70 | }
71 | }
72 | throw err;
73 | } finally {
74 | acmeInitializing = false;
75 | }
76 |
77 | return true;
78 | };
79 |
80 | const generateKey = async (keyBits, keyExponent, opts) => {
81 | opts = opts || {};
82 | const { privateKey /*, publicKey */ } = await generateKeyPair('rsa', {
83 | modulusLength: keyBits || 2048, // options
84 | publicExponent: keyExponent || 65537,
85 | publicKeyEncoding: {
86 | type: opts.publicKeyEncoding || 'spki',
87 | format: 'pem'
88 | },
89 | privateKeyEncoding: {
90 | // jwk functions fail on other encodings (eg. pkcs8)
91 | type: opts.privateKeyEncoding || 'pkcs1',
92 | format: 'pem'
93 | }
94 | });
95 |
96 | return privateKey;
97 | };
98 |
99 | const getAcmeAccount = async options => {
100 | await ensureAcme(options);
101 |
102 | const { redisClient } = options;
103 |
104 | const id = options.acme.key;
105 | const entryKey = `acme:account:${id}`;
106 |
107 | const acmeAccount = await redisClient.hgetall(entryKey);
108 | if (acmeAccount && acmeAccount.account) {
109 | try {
110 | acmeAccount.account = JSON.parse(acmeAccount.account);
111 | } catch (err) {
112 | throw new Error('Failed to retrieve ACME account');
113 | }
114 | if (acmeAccount.created) {
115 | acmeAccount.created = new Date(acmeAccount.created);
116 | }
117 | return acmeAccount;
118 | }
119 |
120 | // account not found, create a new one
121 | logger.info({ msg: 'ACME account was not found, provisioning new one', account: id, directory: options.acme.directoryUrl });
122 | const accountKey = await generateKey(options.keyBits);
123 | const jwkAccount = pem2jwk(accountKey);
124 | logger.info({ msg: 'Generated Acme account key', account: id });
125 |
126 | const accountOptions = {
127 | subscriberEmail: options.acme.email,
128 | agreeToTerms: true,
129 | accountKey: jwkAccount
130 | };
131 |
132 | const account = await acme.accounts.create(accountOptions);
133 |
134 | await redisClient.hmset(entryKey, {
135 | key: accountKey,
136 | account: JSON.stringify(account),
137 | created: new Date().toISOString()
138 | });
139 |
140 | logger.info({ msg: 'ACME account provisioned', account: id });
141 | return { key: accountKey, account };
142 | };
143 |
144 | let formatCertificateData = certificateData => {
145 | if (!certificateData) {
146 | return false;
147 | }
148 |
149 | ['validFrom', 'expires', 'lastCheck', 'created'].forEach(key => {
150 | if (certificateData[key] && typeof certificateData[key] === 'string') {
151 | certificateData[key] = new Date(certificateData[key]);
152 | }
153 | });
154 |
155 | ['dnsNames'].forEach(key => {
156 | if (certificateData[key] && typeof certificateData[key] === 'string') {
157 | try {
158 | certificateData[key] = JSON.parse(certificateData[key]);
159 | } catch (err) {
160 | certificateData[key] = false;
161 | }
162 | }
163 | });
164 |
165 | return certificateData;
166 | };
167 |
168 | const validateDomain = async domain => {
169 | // check domain name format
170 | const validation = Joi.string()
171 | .domain({ tlds: { allow: true } })
172 | .validate(domain);
173 |
174 | if (validation.error) {
175 | // invalid domain name, can not create certificate
176 | let err = new Error('${domain} is not a valid domain name');
177 | err.code = 'invalid_domain';
178 | throw err;
179 | }
180 |
181 | // check CAA support
182 | if (typeof resolver.resolveCaa === 'function') {
183 | // CAA support in node 15+
184 |
185 | let parts = domain.split('.');
186 | for (let i = 0; i < parts.length - 1; i++) {
187 | let subdomain = parts.slice(i).join('.');
188 | let caaRes;
189 | try {
190 | caaRes = await resolver.resolveCaa(subdomain);
191 | } catch (err) {
192 | // assume not found
193 | }
194 | if (caaRes?.length && !caaRes.some(r => (r?.issue || '').trim().toLowerCase() === CAA_DOMAIN)) {
195 | let err = new Error(`LE not listed in the CAA record for ${subdomain} (${domain})`);
196 | err.code = 'caa_mismatch';
197 | throw err;
198 | } else if (caaRes?.length) {
199 | logger.info({ msg: 'Found matching CAA record', subdomain, domain });
200 | break;
201 | }
202 | }
203 | }
204 |
205 | // resolve random domain to detect wildcard records
206 | if (!config?.extraChecks?.wildCardAllowed) {
207 | try {
208 | let mainDomain = psl.get(domain) || domain;
209 | if (mainDomain !== domain) {
210 | let altDomain = domain.replace(/^[^.]+\./, `${crypto.randomBytes(8).toString('hex')}.`);
211 | let resolved = await resolver.resolve4(altDomain);
212 |
213 | if (resolved && resolved.length) {
214 | // wildcard DNS detected
215 | let err = new Error(`Wildcard DNS detected for ${domain} of ${mainDomain} [${altDomain} resolved to ${resolved.join(', ')}]`);
216 | err.code = 'wildcard_dns';
217 | throw err;
218 | }
219 | }
220 | } catch (err) {
221 | if (err.code === 'wildcard_dns') {
222 | throw err;
223 | }
224 | // otherwise ignore
225 | }
226 | }
227 |
228 | if (config?.checkUrl?.enabled) {
229 | try {
230 | let validated = await checkUrl(domain);
231 | if (!validated) {
232 | throw new Error('Domain validation failed');
233 | }
234 | } catch (err) {
235 | logger.error({ msg: 'Domain verification failed', domain, err });
236 | throw err;
237 | }
238 | }
239 |
240 | if (!config?.precheck?.length) {
241 | // pass by default if precheck rules not set
242 | return true;
243 | }
244 |
245 | for (let check of config.precheck) {
246 | const { key, expected } = check;
247 |
248 | let queryHandler;
249 | switch (key.toUpperCase()) {
250 | case 'A':
251 | queryHandler = 'resolve4';
252 | break;
253 | case 'AAAA':
254 | queryHandler = 'resolve6';
255 | break;
256 | case 'CNAME':
257 | queryHandler = 'resolveCname';
258 | break;
259 | default:
260 | queryHandler = `resolve${key.toLowerCase().replace(/^./, c => c.toUpperCase())}`;
261 | }
262 |
263 | if (typeof resolver[queryHandler] !== 'function') {
264 | let err = new Error(`Unknown RR type ${key} for ${domain}`);
265 | err.code = 'unknown_rr_type';
266 | throw err;
267 | }
268 |
269 | let resolved;
270 | try {
271 | resolved = await resolver[queryHandler](domain);
272 | } catch (err) {
273 | logger.info({ msg: 'DNS query failed', action: 'precheck', queryHandler, domain, err });
274 | }
275 |
276 | if (!resolved || !resolved.length) {
277 | logger.info({ msg: 'DNS query failed', action: 'precheck', queryHandler, domain, err: 'Empty result' });
278 | continue;
279 | }
280 |
281 | logger.info({ msg: 'DNS query response', action: 'precheck', queryHandler, domain, resolved, expected });
282 |
283 | for (let row of resolved) {
284 | if ((row || '').toString().trim().toLowerCase() === expected.toLowerCase()) {
285 | return true;
286 | }
287 | }
288 | }
289 |
290 | let err = new Error(`Precheck failed for ${domain}`);
291 | err.code = 'precheck_failed';
292 | throw err;
293 | };
294 |
295 | const acquireCert = async opts => {
296 | const { redisClient, certKey, domains, options } = opts;
297 | let { certificateData } = opts;
298 |
299 | if (await redisClient.exists(`${certKey}:lock`)) {
300 | // nothing to do here, renewal blocked
301 | logger.info({ msg: 'Renewal blocked by failsafe lock', domain: domains.join(', '), certKey });
302 |
303 | // use default
304 | return certificateData;
305 | }
306 |
307 | for (let domain of domains) {
308 | try {
309 | // throws if can not validate domain
310 | await validateDomain(domain);
311 | logger.info({ msg: 'Domain validation passed', domain });
312 | } catch (err) {
313 | logger.error({ msg: 'Failed to validate domain', domain, err });
314 | return certificateData;
315 | }
316 | }
317 |
318 | // Use locking to avoid race conditions, first try gets the lock, others wait until first is finished
319 | if (!getLock) {
320 | let lock = new Lock({
321 | redis: redisClient,
322 | namespace: 'acme'
323 | });
324 | getLock = util.promisify(lock.waitAcquireLock.bind(lock));
325 | releaseLock = util.promisify(lock.releaseLock.bind(lock));
326 | }
327 |
328 | let lock = await getLock(certKey, 10 * 60 * 1000, 3 * 60 * 1000);
329 | try {
330 | // reload from db, maybe already renewed
331 | certificateData = formatCertificateData(await redisClient.hgetall(certKey));
332 | if (certificateData && certificateData.expires > new Date(Date.now() + 10000 + 30 * 24 * 3600 * 1000)) {
333 | // no need to renew
334 | return certificateData;
335 | }
336 |
337 | let privateKey = certificateData && certificateData.key;
338 | if (!privateKey) {
339 | // generate new key
340 | logger.info({ msg: 'Provision new private key', domain: domains.join(', ') });
341 | privateKey = await generateKey();
342 | await redisClient.hset(certKey, 'key', privateKey);
343 | }
344 |
345 | const jwkPrivateKey = pem2jwk(privateKey);
346 |
347 | let csr;
348 |
349 | try {
350 | csr = await CSR.csr({
351 | jwk: jwkPrivateKey,
352 | domains,
353 | encoding: 'pem'
354 | });
355 | } catch (err) {
356 | logger.error({
357 | msg: 'Failed to generate CSR file',
358 | domain: domains.join(', '),
359 | canDeleteKey: !certificateData || !certificateData.expires || certificateData.expires < new Date(),
360 | err
361 | });
362 |
363 | if (!certificateData || !certificateData.expires || certificateData.expires < new Date()) {
364 | // delete private key entry
365 | try {
366 | let deleted = await redisClient.del(certKey);
367 | if (deleted) {
368 | logger.info({ msg: 'Deleted domain key from Redis', domain: domains.join(', '), certKey });
369 | } else {
370 | logger.info({ msg: 'Domain key was not found', domain: domains.join(', '), certKey });
371 | }
372 | } catch (err) {
373 | logger.error({ msg: 'Failed to delete key from Redis', domain: domains.join(', '), certKey, err });
374 | }
375 | }
376 |
377 | throw err;
378 | }
379 |
380 | const acmeAccount = await getAcmeAccount(options);
381 | if (!acmeAccount) {
382 | logger.info({ msg: 'Skip certificate renwal, acme account not found', domain: domains.join(', ') });
383 | return false;
384 | }
385 |
386 | const jwkAccount = pem2jwk(acmeAccount.key);
387 | const certificateOptions = {
388 | account: acmeAccount.account,
389 | accountKey: jwkAccount,
390 | csr,
391 | domains,
392 | challenges: {
393 | 'http-01': RedisChallenge.create({
394 | hashKey: `acme:challenge:${options.acme.key}`,
395 | redisClient
396 | })
397 | }
398 | };
399 |
400 | const aID = (acmeAccount?.account?.key?.kid || '').split('/acct/').pop();
401 |
402 | logger.info({ msg: 'Generate ACME cert', domain: domains.join(', '), account: aID });
403 |
404 | let cert;
405 | try {
406 | cert = await acme.certificates.create(certificateOptions);
407 | } catch (err) {
408 | logger.error({
409 | msg: 'Failed to generate certificate',
410 | domain: domains.join(', '),
411 | canDeleteKey: !certificateData || !certificateData.expires || certificateData.expires < new Date(),
412 | err
413 | });
414 | if (err.name === 'TypeError') {
415 | if (!certificateData || !certificateData.expires || certificateData.expires < new Date()) {
416 | // delete private key entry
417 | try {
418 | let deleted = await redisClient.del(certKey);
419 | if (deleted) {
420 | logger.info({ msg: 'Deleted domain key from Redis', domain: domains.join(', '), certKey });
421 | } else {
422 | logger.info({ msg: 'Domain key was not found', domain: domains.join(', '), certKey });
423 | }
424 | } catch (err) {
425 | logger.error({ msg: 'Failed to delete key from Redis', domain: domains.join(', '), certKey, err });
426 | }
427 | }
428 | }
429 | throw err;
430 | }
431 |
432 | if (!cert || !cert.cert) {
433 | logger.error({ msg: 'Failed to generate certificate', domain: domains.join(', ') });
434 | return cert;
435 | }
436 | logger.info({ msg: 'Received certificate from ACME', domain: domains.join(', ') });
437 |
438 | let now = new Date();
439 | const parsed = Certificate.fromPEM(cert.cert);
440 | let result = {
441 | cert: cert.cert,
442 | chain: cert.chain,
443 | validFrom: new Date(parsed.validFrom).toISOString(),
444 | expires: new Date(parsed.validTo).toISOString(),
445 | dnsNames: JSON.stringify(parsed.dnsNames),
446 | issuer: parsed.issuer.CN,
447 | lastCheck: now.toISOString(),
448 | created: now.toISOString(),
449 | status: 'valid'
450 | };
451 |
452 | let updates = {};
453 | Object.keys(result).forEach(key => {
454 | updates[key] = (result[key] || '').toString();
455 | });
456 |
457 | await redisClient
458 | .multi()
459 | .hmset(certKey, updates)
460 | .expire(certKey, Math.round((new Date(parsed.validTo).getTime() - Date.now()) / 1000))
461 | .exec();
462 |
463 | logger.info({ msg: 'Certificate successfully generated', domain: domains.join(', '), expires: parsed.validTo });
464 | return formatCertificateData(await redisClient.hgetall(certKey));
465 | } catch (err) {
466 | try {
467 | await redisClient.multi().set(`${certKey}:lock`, 1).expire(`${certKey}:lock`, BLOCK_RENEW_AFTER_ERROR_TTL).exec();
468 | } catch (err) {
469 | logger.info({ msg: 'Redis call failed', key: `${certKey}:lock`, domain: domains.join(', '), err });
470 | }
471 |
472 | logger.info({ msg: 'Failed to generate cert', domain: domains.join(', '), err });
473 | if (certificateData && certificateData.cert) {
474 | // use existing certificate data if exists
475 | return certificateData;
476 | }
477 |
478 | throw err;
479 | } finally {
480 | try {
481 | await releaseLock(lock);
482 | } catch (err) {
483 | logger.error({ msg: 'Failed to release lock', certKey, err });
484 | }
485 | }
486 | };
487 |
488 | const getCertificate = async (options, domains) => {
489 | await ensureAcme(options);
490 |
491 | domains = []
492 | .concat(domains || [])
493 | .map(domain => normalizeDomain(domain))
494 | .filter(domain => domain);
495 |
496 | const { redisClient } = options;
497 |
498 | let domainHash = crypto.createHash('md5').update(domains.join('\x01')).digest('hex');
499 | let certKey = `acme:certificate:${options.acme.key}:${domainHash}`;
500 |
501 | let certificateData = formatCertificateData(await redisClient.hgetall(certKey));
502 | if (certificateData && certificateData.expires > new Date(Date.now() + 30 * 24 * 3600 * 1000)) {
503 | // no need to renew
504 |
505 | return certificateData;
506 | }
507 |
508 | if (certificateData && certificateData.expires > Date.now()) {
509 | // can use the stored cert and renew in background
510 | acquireCert({ redisClient, certKey, domains, options }).catch(err => {
511 | logger.error({ msg: 'Cert renewal error', domain: domains.join(', '), err });
512 | });
513 |
514 | return certificateData;
515 | }
516 |
517 | return await acquireCert({ redisClient, certKey, domains, options });
518 | };
519 |
520 | module.exports = {
521 | getCertificate
522 | };
523 |
--------------------------------------------------------------------------------