UNPKG

hfs

Version:
138 lines (137 loc) 7.16 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.acmeRenewError = exports.makeCert = exports.acmeMiddleware = void 0; const misc_1 = require("./misc"); const http_1 = require("http"); const nat_1 = require("./nat"); const listen_1 = require("./listen"); const apiMiddleware_1 = require("./apiMiddleware"); const acme_client_1 = __importDefault(require("acme-client")); const debounceAsync_1 = require("./debounceAsync"); const promises_1 = __importDefault(require("fs/promises")); const config_1 = require("./config"); const events_1 = __importDefault(require("./events")); const selfCheck_1 = require("./selfCheck"); let acmeOngoing = false; const acmeTokens = {}; const acmeListener = (req, res) => { var _a; const BASE = '/.well-known/acme-challenge/'; if (!((_a = req.url) === null || _a === void 0 ? void 0 : _a.startsWith(BASE))) return; const token = req.url.slice(BASE.length); console.debug("got http challenge", token); res.statusCode = misc_1.HTTP_OK; res.end(acmeTokens[token]); return true; // true = responded }; const acmeMiddleware = (ctx, next) => { if (!acmeOngoing || !acmeListener(ctx.req, ctx.res)) return next(); }; exports.acmeMiddleware = acmeMiddleware; const TEMP_MAP = { private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 5000 }; // from my tests (zyxel VMG8825), lower values won't make a working mapping (0, misc_1.repeat)(misc_1.MINUTE, async (stop) => { await nat_1.upnpClient.getGateway(); // without this, the next call will break upnp support const res = await nat_1.upnpClient.getMappings(); const leftover = res.find(x => x.description === TEMP_MAP.description); // in case the process is interrupted if (!leftover) return void stop(); // we are good if (acmeOngoing) return; // it doesn't count, as we are in the middle of something. Retry later stop(); return nat_1.upnpClient.removeMapping(TEMP_MAP); }); async function generateSSLCert(domain, email, altNames) { // will answer challenge through our koa app (if on port 80) or must we spawn a dedicated server? const nat = await (0, nat_1.getNatInfo)(); const { http } = await (0, listen_1.getServerStatus)(); const tempSrv = nat.externalPort === 80 || http.listening && http.port === 80 ? undefined : (0, http_1.createServer)((req, res) => acmeListener(req, res) || res.end('HFS')); // also satisfy self-check if (tempSrv) await new Promise(resolve => tempSrv.listen(80, resolve).on('error', (e) => { console.debug("cannot listen on 80", e.code || e); resolve(); // go on anyway })); acmeOngoing = true; console.debug("acme challenge server ready"); let tempMap; try { const checkUrl = `http://${domain.split(',')[0]}`; let check = await (0, selfCheck_1.selfCheck)(checkUrl); // some check services may not consider the domain, but we already verified that if ((check === null || check === void 0 ? void 0 : check.success) === false && nat.upnp && !nat.mapped80) { console.debug("setting temporary port forward"); tempMap = await (0, misc_1.haveTimeout)(10000, nat_1.upnpClient.createMapping(TEMP_MAP).catch(() => { })).catch(() => { }); check = await (0, selfCheck_1.selfCheck)(checkUrl); // repeat test } //if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80") if ((check === null || check === void 0 ? void 0 : check.success) === false) throw new apiMiddleware_1.ApiError(misc_1.HTTP_FAILED_DEPENDENCY, "port 80 is not working on the specified domain"); const acmeClient = new acme_client_1.default.Client({ accountKey: await acme_client_1.default.crypto.createPrivateKey(), directoryUrl: acme_client_1.default.directory.letsencrypt.production }); acme_client_1.default.setLogger(console.debug); const [key, csr] = await acme_client_1.default.crypto.createCsr({ commonName: domain, altNames }); const cert = await acmeClient.auto({ csr, email, challengePriority: ['http-01'], skipChallengeVerification: true, // on NAT, trying to connect to your external ip will likely get your modem instead of the challenge server termsOfServiceAgreed: true, async challengeCreateFn(_, c, ka) { acmeTokens[c.token] = ka; }, async challengeRemoveFn(_, c) { delete acmeTokens[c.token]; }, }); console.log("acme certificate generated"); return { key, cert }; } finally { if (tempMap) { console.debug("removing temporary port forward"); nat_1.upnpClient.removeMapping(TEMP_MAP).catch(() => { }); // clean after ourselves } acmeOngoing = false; if (tempSrv) await new Promise(res => tempSrv.close(res)); console.debug('acme terminated'); } } exports.makeCert = (0, debounceAsync_1.debounceAsync)(async (domain, email, altNames) => { if (!domain) return new apiMiddleware_1.ApiError(misc_1.HTTP_BAD_REQUEST, 'bad params'); const res = await generateSSLCert(domain, email, altNames).catch(e => { var _a; throw !((_a = e.message) === null || _a === void 0 ? void 0 : _a.includes('not match this challenge')) ? e // another acme server? : Error("a different server is responding on port 80 of your domain(s)"); }); const CERT_FILE = 'acme.cer'; const KEY_FILE = 'acme.key'; await promises_1.default.writeFile(CERT_FILE, res.cert); await promises_1.default.writeFile(KEY_FILE, res.key); listen_1.cert.set(CERT_FILE); // update config listen_1.privateKey.set(KEY_FILE); exports.acmeRenewError = ''; }); exports.acmeRenewError = ''; const acmeDomain = (0, config_1.defineConfig)('acme_domain', ''); const acmeRenew = (0, config_1.defineConfig)('acme_renew', false); // handle config changes events_1.default.once('httpsReady', () => (0, misc_1.repeat)(misc_1.HOUR, renewCert)); // checks if the cert is near expiration date, and if so renews it const renewCert = (0, debounceAsync_1.debounceAsync)(async () => { const [domain, ...altNames] = acmeDomain.get().split(','); if (!acmeRenew.get() || !domain) return; const cert = (0, listen_1.getCertObject)(); if (!cert) return; const now = new Date(); const validTo = new Date(cert.validTo); // not expiring in a month if (now > new Date(cert.validFrom) && now < validTo && validTo.getTime() - now.getTime() >= 30 * misc_1.DAY) return console.log("certificate still good"); await (0, exports.makeCert)(domain, undefined, altNames) .catch(e => console.log(exports.acmeRenewError = `Error renewing certificate, expiring ${(0, misc_1.formatDate)(validTo)}: ${String(e.message || e)}`)); }, { retain: misc_1.DAY, retainFailure: misc_1.HOUR });