├── .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 | --------------------------------------------------------------------------------