hfs
Version:
HTTP File Server
138 lines (137 loc) • 7.16 kB
JavaScript
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 });
;