"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) => {
    const BASE = '/.well-known/acme-challenge/';
    if (!req.url?.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 = (0, nat_1.upnpMappingParam)(80, 80, 'hfs temporary', 5000); // from my tests (zyxel VMG8825), lower values won't make a working mapping
// remove temporary port mapping, if any is left from previous execution
(0, misc_1.repeat)(misc_1.MINUTE, async (stop) => {
    if (!await nat_1.upnpEnabled.getWhenReady())
        return stop();
    const client = (0, nat_1.getUpnpClient)();
    await client.getGateway(); // without this, the next call will break upnp support
    const res = await client.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 client.removeMapping(TEMP_MAP);
});
async function generateSSLCert(domain, email, altNames) {
    // will answer the 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?.success === false && nat.upnp && !nat.mapped80) {
            console.debug("Setting temporary port forward");
            tempMap = await (0, misc_1.haveTimeout)(10_000, (0, nat_1.getUpnpClient)().createMapping(TEMP_MAP)).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?.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 && nat_1.upnpEnabled.get()) {
            console.debug("Removing temporary port forward");
            (0, nat_1.getUpnpClient)().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 => {
        throw e.message?.includes('Timeout') ? Error("ensure your router is forwarding port 80 correctly")
            : e.message?.includes('not match this challenge') ? Error("a different server is responding on port 80 of your domain(s)")
                : e;
    });
    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 });
