"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getNatInfo = exports.getPublicIps = exports.upnpEnabled = exports.mappedPort = exports.defaultBaseUrl = void 0;
exports.upnpMappingParam = upnpMappingParam;
exports.getUpnpClient = getUpnpClient;
const valtio_1 = require("valtio");
const nat_upnp_1 = require("@rejetto/nat-upnp");
const debounceAsync_1 = require("./debounceAsync");
const cross_1 = require("./cross");
const github_1 = require("./github");
const lodash_1 = __importDefault(require("lodash"));
const util_http_1 = require("./util-http");
const promises_1 = require("dns/promises");
const net_1 = require("net");
const listen_1 = require("./listen");
const child_process_1 = require("child_process");
const const_1 = require("./const");
const config_1 = require("./config");
exports.defaultBaseUrl = (0, valtio_1.proxy)({
    proto: 'http',
    publicIps: [],
    externalIp: '',
    localIp: '',
    port: 0,
    async get() {
        const defPort = this.proto === 'https' ? 443 : 80;
        const status = await (0, listen_1.getServerStatus)();
        const port = this.port || (this.proto === 'https' ? status.https.port : status.http.port);
        const ip = this.publicIps[0] || this.externalIp || this.localIp;
        return `${this.proto}://${(0, cross_1.ipForUrl)(ip || 'localhost')}${!port || port === defPort ? '' : ':' + port}`;
    }
});
exports.mappedPort = (0, config_1.defineConfig)('mapped_port', 0);
exports.upnpEnabled = (0, config_1.defineConfig)(cross_1.CFG.upnp_enabled, true);
let upnpClient;
// poll external ip – when UPnP is enabled, asking the modem is inexpensive, so it can be done often
(0, cross_1.repeat)(cross_1.MINUTE, async () => {
    if (!await exports.upnpEnabled.getWhenReady())
        return;
    const v = await getUpnpClient().getPublicIp().catch(() => '');
    if (!v || v === exports.defaultBaseUrl.externalIp)
        return;
    exports.getPublicIps.clearRetain();
    exports.defaultBaseUrl.externalIp = v;
});
function upnpMappingParam(privatePort, publicPort, description = 'hfs', ttl = 0) {
    // nat-upnp-rejetto requires the object form of `public` to preserve the host field correctly
    return { private: privatePort, public: { host: '', port: publicPort }, description, ttl };
}
exports.getPublicIps = (0, debounceAsync_1.debounceAsync)(async () => {
    const res = await (0, github_1.getProjectInfo)();
    const groupedByVersion = Object.values(lodash_1.default.groupBy(res.publicIpServices, x => x.v ?? 4));
    const ips = await (0, cross_1.promiseBestEffort)(groupedByVersion.map(singleVersion => Promise.any(singleVersion.map(async (svc) => {
        if (typeof svc === 'string')
            svc = { type: 'http', url: svc };
        console.debug("Trying ip service", svc.url || svc.name);
        if (svc.type === 'http') {
            const timeout = 5_000;
            return (0, util_http_1.httpString)(svc.url, { timeout, proxy: '' }).catch(e => {
                if (!e.cause?.statusCode && util_http_1.httpStream.defaultProxy) // only for network errors (not http, where we have statusCode), retry using the proxy (if any)
                    return (0, util_http_1.httpString)(svc.url, { timeout });
                throw e;
            });
        }
        if (svc.type !== 'dns')
            throw "unsupported";
        const resolver = new promises_1.Resolver({ timeout: 2_000 });
        resolver.setServers(svc.ips);
        return resolver.resolve(svc.name, svc.dnsRecord);
    }).map(async (ret) => {
        const validIps = (0, cross_1.wantArray)(await ret).map(x => x.trim()).filter(net_1.isIP);
        if (!validIps.length)
            throw "no good";
        return validIps;
    }))));
    const ret = exports.defaultBaseUrl.publicIps = lodash_1.default.uniq(ips.flat());
    if (!ret.length) // don't keep empty results for long
        setTimeout(() => exports.getPublicIps.clearRetain(), 5_000);
    return ret;
}, { retain: 10 * cross_1.MINUTE });
exports.getNatInfo = (0, debounceAsync_1.debounceAsync)(async () => {
    const upnp = await exports.upnpEnabled.getWhenReady() ? getUpnpClient() : null;
    const gatewayIpPromise = findGateway().catch(() => undefined);
    const gw = upnp && await (0, cross_1.haveTimeout)(10_000, upnp.getGateway()).catch(() => null);
    const status = await (0, listen_1.getServerStatus)();
    let mappings = gw && await (0, cross_1.haveTimeout)(5_000, upnp.getMappings())?.catch(() => null);
    console.debug(gw ? "Mappings found:" : "Mappings not queried:", mappings?.map(x => x.description).join(', ') || (gw ? "none" : upnp ? "gateway not found" : "UPnP disabled"));
    const localIps = await (0, listen_1.getIps)(false);
    const gatewayIp = await gatewayIpPromise;
    const localIp = gw?.address || (gatewayIp ? lodash_1.default.maxBy(localIps, x => (0, cross_1.inCommon)(x, gatewayIp)) : localIps[0]);
    const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined;
    let mapped = lodash_1.default.find(mappings, x => x.private.host === localIp && x.private.port === internalPort);
    if (upnp && mappings && localIp && internalPort && !mapped && exports.mappedPort.get())
        // restore the HFS-created mapping after routers that forget UPnP state across reboots
        await (0, cross_1.haveTimeout)(5_000, upnp.createMapping(upnpMappingParam(internalPort, exports.mappedPort.get()))).then(async () => {
            // confirm router state after restore instead of trusting the AddPortMapping result
            mappings = await (0, cross_1.haveTimeout)(5_000, upnp.getMappings());
            mapped = lodash_1.default.find(mappings, x => x.private.host === localIp && x.private.port === internalPort);
        }).catch(e => console.warn('UPnP mapping restore failed:', e?.message || String(e)));
    const externalPort = mapped?.public.port;
    if (localIp)
        exports.defaultBaseUrl.localIp = localIp;
    exports.defaultBaseUrl.port = externalPort || internalPort || 0;
    return {
        upnp: Boolean(gw),
        localIp,
        gatewayIp,
        externalIp: exports.defaultBaseUrl.externalIp,
        mapped,
        mapped80: lodash_1.default.find(mappings, x => x.private.host === localIp && x.private.port === 80 && x.public.port === 80),
        internalPort,
        externalPort,
        proto: status?.https?.listening ? 'https' : status?.http?.listening ? 'http' : '',
    };
}, { reuseRunning: true });
exports.upnpEnabled.sub(v => {
    exports.getNatInfo.clearRetain();
    if (v) {
        getUpnpClient().getGateway().then(res => console.log("UPnP found", res.gateway.description), e => console.debug('UPnP failed:', e.message || String(e)));
        return;
    }
    // closing the client guarantees the disabled setting also stops any existing SSDP socket
    upnpClient?.close();
    upnpClient = undefined;
    exports.defaultBaseUrl.externalIp = '';
    exports.getPublicIps.clearRetain();
});
config_1.configReady.then(exports.getNatInfo).catch(() => { });
function getUpnpClient() {
    if (!exports.upnpEnabled.get()) // keep this guard upstream so disabled UPnP cannot leak SSDP traffic through callers
        throw Error("UPnP disabled");
    if (!upnpClient) {
        upnpClient = new nat_upnp_1.Client({ timeout: 4_000 });
        const originalMethod = upnpClient.getGateway;
        // other client methods call getGateway too, so this will ensure they reuse this same result
        upnpClient.getGateway = (0, debounceAsync_1.debounceAsync)(() => originalMethod.apply(upnpClient), { retain: cross_1.HOUR, retainFailure: 30_000 });
    }
    return upnpClient;
}
function findGateway() {
    return new Promise((resolve, reject) => (0, child_process_1.exec)(const_1.IS_WINDOWS || const_1.IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => {
        if (err)
            return reject(err);
        if (!const_1.IS_WINDOWS)
            return resolve(out.match(const_1.IS_MAC ? /default +([\d.]+)/ : /^0\.0\.0\.0 +([\d.]+)/)?.[1]);
        const sortedByMetric = lodash_1.default.sortBy([...out.matchAll(/(?:0\.0\.0\.0 +){2}([\d.]+)\s+[\d.]+\s+(\d+)/g)], x => Number(x[2]));
        resolve(sortedByMetric[0]?.[1]); // take ip with lowest metric
    }));
}
