| 1 | import { proxy } from 'valtio' |
| 2 | import { Client } from '@rejetto/nat-upnp' |
| 3 | import { debounceAsync } from './debounceAsync' |
| 4 | import { CFG, haveTimeout, HOUR, inCommon, ipForUrl, MINUTE, promiseBestEffort, repeat, wantArray } from './cross' |
| 5 | import { getProjectInfo } from './github' |
| 6 | import _ from 'lodash' |
| 7 | import { httpStream, httpString } from './util-http' |
| 8 | import { Resolver } from 'dns/promises' |
| 9 | import { isIP } from 'net' |
| 10 | import { getIps, getServerStatus } from './listen' |
| 11 | import { exec } from 'child_process' |
| 12 | import { IS_MAC, IS_WINDOWS } from './const' |
| 13 | import { configReady, defineConfig } from './config' |
| 14 | |
| 15 | export const defaultBaseUrl = proxy({ |
| 16 | proto: 'http', |
| 17 | publicIps: [] as string[], |
| 18 | externalIp: '', |
| 19 | localIp: '', |
| 20 | port: 0, |
| 21 | async get() { |
| 22 | const defPort = this.proto === 'https' ? 443 : 80 |
| 23 | const status = await getServerStatus() |
| 24 | const port = this.port || (this.proto === 'https' ? status.https.port : status.http.port) |
| 25 | const ip = this.publicIps[0] || this.externalIp || this.localIp |
| 26 | return `${this.proto}://${ipForUrl(ip || 'localhost')}${!port || port === defPort ? '' : ':' + port}` |
| 27 | } |
| 28 | }) |
| 29 | |
| 30 | export const mappedPort = defineConfig('mapped_port', 0) |
| 31 | export const upnpEnabled = defineConfig(CFG.upnp_enabled, true) |
| 32 | |
| 33 | let upnpClient: Client | undefined |
| 34 | |
| 35 | // poll external ip – when UPnP is enabled, asking the modem is inexpensive, so it can be done often |
| 36 | repeat(MINUTE, async () => { |
| 37 | if (!await upnpEnabled.getWhenReady()) return |
| 38 | const v = await getUpnpClient().getPublicIp().catch(() => '') |
| 39 | if (!v || v === defaultBaseUrl.externalIp) return |
| 40 | getPublicIps.clearRetain() |
| 41 | defaultBaseUrl.externalIp = v |
| 42 | }) |
| 43 | |
| 44 | export function upnpMappingParam(privatePort: number, publicPort: number, description='hfs', ttl=0) { |
| 45 | // nat-upnp-rejetto requires the object form of `public` to preserve the host field correctly |
| 46 | return { private: privatePort, public: { host: '', port: publicPort }, description, ttl } |
| 47 | } |
| 48 | |
| 49 | export const getPublicIps = debounceAsync(async () => { |
| 50 | const res = await getProjectInfo() |
| 51 | const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4)) |
| 52 | const ips = await promiseBestEffort(groupedByVersion.map(singleVersion => |
| 53 | Promise.any(singleVersion.map(async (svc: any) => { |
| 54 | if (typeof svc === 'string') |
| 55 | svc = { type: 'http', url: svc } |
| 56 | console.debug("Trying ip service", svc.url || svc.name) |
| 57 | if (svc.type === 'http') { |
| 58 | const timeout = 5_000 |
| 59 | return httpString(svc.url, { timeout, proxy: '' }).catch(e => { // first try without a proxy |
| 60 | if (!e.cause?.statusCode && httpStream.defaultProxy) // only for network errors (not http, where we have statusCode), retry using the proxy (if any) |
| 61 | return httpString(svc.url, { timeout }) |
| 62 | throw e |
| 63 | }) |
| 64 | } |
| 65 | if (svc.type !== 'dns') throw "unsupported" |
| 66 | const resolver = new Resolver({ timeout: 2_000 }) |
| 67 | resolver.setServers(svc.ips) |
| 68 | return resolver.resolve(svc.name, svc.dnsRecord) |
| 69 | }).map(async ret => { |
| 70 | const validIps = wantArray(await ret).map(x => x.trim()).filter(isIP) |
| 71 | if (!validIps.length) throw "no good" |
| 72 | return validIps |
| 73 | }) ))) |
| 74 | const ret = defaultBaseUrl.publicIps = _.uniq(ips.flat()) |
| 75 | if (!ret.length) // don't keep empty results for long |
| 76 | setTimeout(() => getPublicIps.clearRetain(), 5_000) |
| 77 | return ret |
| 78 | }, { retain: 10 * MINUTE }) |
| 79 | |
| 80 | export const getNatInfo = debounceAsync(async () => { |
| 81 | const upnp = await upnpEnabled.getWhenReady() ? getUpnpClient() : null |
| 82 | const gatewayIpPromise = findGateway().catch(() => undefined) |
| 83 | const gw = upnp && await haveTimeout(10_000, upnp.getGateway()).catch(() => null) |
| 84 | const status = await getServerStatus() |
| 85 | let mappings = gw && await haveTimeout(5_000, upnp.getMappings())?.catch(() => null) |
| 86 | console.debug(gw ? "Mappings found:" : "Mappings not queried:", |
| 87 | mappings?.map(x => x.description).join(', ') || (gw ? "none" : upnp ? "gateway not found" : "UPnP disabled") ) |
| 88 | const localIps = await getIps(false) |
| 89 | const gatewayIp = await gatewayIpPromise |
| 90 | const localIp = gw?.address || (gatewayIp ? _.maxBy(localIps, x => inCommon(x, gatewayIp)) : localIps[0]) |
| 91 | const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined |
| 92 | let mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort) |
| 93 | if (upnp && mappings && localIp && internalPort && !mapped && mappedPort.get()) |
| 94 | // restore the HFS-created mapping after routers that forget UPnP state across reboots |
| 95 | await haveTimeout(5_000, upnp!.createMapping(upnpMappingParam(internalPort, mappedPort.get()))).then(async () => { |
| 96 | // confirm router state after restore instead of trusting the AddPortMapping result |
| 97 | mappings = await haveTimeout(5_000, upnp.getMappings()) |
| 98 | mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort) |
| 99 | }).catch(e => console.warn('UPnP mapping restore failed:', e?.message || String(e))) |
| 100 | const externalPort = mapped?.public.port |
| 101 | if (localIp) |
| 102 | defaultBaseUrl.localIp = localIp |
| 103 | defaultBaseUrl.port = externalPort || internalPort || 0 |
| 104 | return { |
| 105 | upnp: Boolean(gw), |
| 106 | localIp, |
| 107 | gatewayIp, |
| 108 | externalIp: defaultBaseUrl.externalIp, |
| 109 | mapped, |
| 110 | mapped80: _.find(mappings, x => x.private.host === localIp && x.private.port === 80 && x.public.port === 80), |
| 111 | internalPort, |
| 112 | externalPort, |
| 113 | proto: status?.https?.listening ? 'https' : status?.http?.listening ? 'http' : '', |
| 114 | } |
| 115 | }, { reuseRunning: true }) |
| 116 | |
| 117 | upnpEnabled.sub(v => { |
| 118 | getNatInfo.clearRetain() |
| 119 | if (v) { |
| 120 | getUpnpClient().getGateway().then(res => console.log("UPnP found", res.gateway.description), |
| 121 | e => console.debug('UPnP failed:', e.message || String(e))) |
| 122 | return |
| 123 | } |
| 124 | // closing the client guarantees the disabled setting also stops any existing SSDP socket |
| 125 | upnpClient?.close() |
| 126 | upnpClient = undefined |
| 127 | defaultBaseUrl.externalIp = '' |
| 128 | getPublicIps.clearRetain() |
| 129 | }) |
| 130 | |
| 131 | configReady.then(getNatInfo).catch(() => {}) |
| 132 | |
| 133 | export function getUpnpClient() { |
| 134 | if (!upnpEnabled.get()) // keep this guard upstream so disabled UPnP cannot leak SSDP traffic through callers |
| 135 | throw Error("UPnP disabled") |
| 136 | if (!upnpClient) { |
| 137 | upnpClient = new Client({ timeout: 4_000 }) |
| 138 | const originalMethod = upnpClient.getGateway |
| 139 | // other client methods call getGateway too, so this will ensure they reuse this same result |
| 140 | upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), { retain: HOUR, retainFailure: 30_000 }) |
| 141 | } |
| 142 | return upnpClient |
| 143 | } |
| 144 | |
| 145 | function findGateway(): Promise<string | undefined> { |
| 146 | return new Promise((resolve, reject) => |
| 147 | exec(IS_WINDOWS || IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => { |
| 148 | if (err) return reject(err) |
| 149 | if (!IS_WINDOWS) { |
| 150 | // linux route output starts with headers, so its default-route pattern must be multiline |
| 151 | return resolve(out.match(IS_MAC ? /default +([\d.]+)/ : /^0\.0\.0\.0 +([\d.]+)/m)?.[1]) |
| 152 | } |
| 153 | const sortedByMetric = _.sortBy([...out.matchAll(/(?:0\.0\.0\.0 +){2}([\d.]+)\s+[\d.]+\s+(\d+)/g)], x => Number(x[2])) |
| 154 | resolve(sortedByMetric[0]?.[1]) // take ip with lowest metric |
| 155 | }) ) |
| 156 | } |