main
ts 156 lines 7.49 KB
Raw
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 }