poll upnp externalIp and use it for links

Massimo Melina committed Sep 6, 2023 at 23:31 UTC 3773c18dc8d9fc79ede05ddf2db28f785b91659d
4 files changed +36 -24
src/adminApis.ts
+1 -1
@@ -95,7 +95,7 @@ export const adminApis: ApiHandlers = {
95 apiVersion: API_VERSION,
96 compatibleApiVersion: COMPATIBLE_API_VERSION,
97 ...await getServerStatus(),
98 - urls: getUrls(),
98 + urls: await getUrls(),
99 baseUrl: baseUrl.get(), // can be retrieved with get_config, but it's very handy with urls and low overhead. Case is different because the context is
100 updatePossible: !updateSupported() ? false : await localUpdateAvailable() ? 'local' : true,
101 proxyDetected: getProxyDetected(),
src/api.net.ts
+14 -13
@@ -10,30 +10,31 @@ import { getIps, getServerStatus } from './listen'
10 import { getProjectInfo } from './github'
11 import { httpString } from './util-http'
12 import { exec } from 'child_process'
13 -import { debounceAsync } from './misc'
13 +import { debounceAsync, MINUTE, repeat } from './misc'
14
15 const client = new Client({ timeout: 5_000 })
16 const original = client.getGateway
17 -client.getGateway = () => {
17 +// other client methods call getGateway too, so this will ensure they reuse this same result
18 +client.getGateway = function getGatewayCaching() {
19 const promise = original.apply(client)
19 - promise.then(() => { // store in cache only if successful
20 - console.debug('caching gateway')
21 - // other client methods call getGateway too, so this will ensure they reuse this same result
22 - client.getGateway = () => promise
23 - }, ()=>{})
20 + client.getGateway = () => promise // multiple callings = same job
21 + promise.then(() => console.debug('caching gateway'), // store in cache only if successful.
22 + ()=> client.getGateway = getGatewayCaching) // failed, try again
23 return promise
24 }
25 client.getGateway()
26
27 +export let externalIp = Promise.resolve('') // poll external ip
28 +repeat(10 * MINUTE, () => externalIp = client.getPublicIp().catch(() => externalIp))
29 +
30 const getNatInfo = debounceAsync(async () => {
31 const gettingIp = getPublicIp() // don't wait, do it in parallel
32 const res = await client.getGateway().catch(() => null)
33 const status = await getServerStatus()
34 const mappings = res && await client.getMappings().catch(() => null)
35 console.debug('mappings found', mappings)
34 - const externalIp = res && await client.getPublicIp().catch(() => null)
35 - const gatewayIp = res ? new URL(res.gateway.description).hostname : await getGateway().catch(() => null)
36 - const localIp = res?.address || getIps()[0]
36 + const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => null)
37 + const localIp = res?.address || (await getIps())[0]
38 const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port
39 const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort || x.description === 'hfs')
40 console.debug('responding')
@@ -41,8 +42,8 @@ const getNatInfo = debounceAsync(async () => {
42 upnp: Boolean(res),
43 localIp,
44 gatewayIp,
44 - publicIp: await gettingIp || externalIp,
45 - externalIp,
45 + publicIp: await gettingIp || await externalIp,
46 + externalIp: await externalIp,
47 mapped,
48 internalPort,
49 externalPort: mapped?.public.port,
@@ -63,7 +64,7 @@ async function getPublicIp() {
64 catch (e: any) { console.debug(String(e)) }
65 }
66
66 -function getGateway(): Promise<string | undefined> {
67 +function findGateway(): Promise<string | undefined> {
68 return new Promise((resolve, reject) =>
69 exec(IS_WINDOWS || IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => {
70 if (err) return reject(err)
src/cross.ts
+5
@@ -4,6 +4,7 @@ import _ from 'lodash'
4
5 export const REPO_URL = 'https://github.com/rejetto/hfs/'
6 export const WIKI_URL = REPO_URL + 'wiki/'
7 +export const MINUTE = 60_000
8
9 export type Dict<T=any> = Record<string, T>
10 export type Falsy = false | null | undefined | '' | 0
@@ -216,3 +217,7 @@ export async function asyncGeneratorToArray<T>(generator: AsyncIterable<T>): Pro
217 return ret
218 }
219
220 +export function repeat(every: number, cb: () => unknown) {
221 + Promise.allSettled([cb()]).then(() =>
222 + setTimeout(() => repeat(every, cb), every) )
223 +}
\ No newline at end of file
src/listen.ts
+16 -10
@@ -14,6 +14,7 @@ import findProcess from 'find-process'
14 import { anyAccountCanLoginAdmin } from './adminApis'
15 import _ from 'lodash'
16 import { X509Certificate } from 'crypto'
17 +import { externalIp } from './api.net'
18
19 interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
20 let httpSrv: undefined | http.Server & ServerExtra
@@ -44,10 +45,10 @@ export function openAdmin() {
45 const a = srv?.address()
46 if (!a || typeof a === 'string') continue
47 const baseUrl = srv!.name + '://localhost:' + a.port
47 - open(baseUrl + ADMIN_URI, { wait: true}).catch(e => {
48 + open(baseUrl + ADMIN_URI, { wait: true}).catch(async e => {
49 console.debug(String(e))
50 console.warn("cannot launch browser on this machine >PLEASE< open your browser and reach one of these (you may need a different address)",
50 - ...Object.values(getUrls()).flat().map(x => '\n - ' + x + ADMIN_URI))
51 + ...Object.values(await getUrls()).flat().map(x => '\n - ' + x + ADMIN_URI))
52 if (! anyAccountCanLoginAdmin())
53 console.log(`HINT: you can enter command: create-admin YOUR_PASSWORD`)
54 })
@@ -212,11 +213,14 @@ export async function getServerStatus() {
213
214 const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|anpi\d)$/i // avoid giving too much information
215
215 -export function getIps() {
216 - return v4first(onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
216 +export async function getIps() {
217 + const ips = onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
218 nets && !ignore.test(name)
218 - && v4first(onlyTruthy(nets.map(net => !net.internal && net.address)))[0] // for each interface we consider only 1 address
219 - )).flat())
219 + && v4first(onlyTruthy(nets.map(net => !net.internal && net.address)))[0] // for each interface we consider only 1 address
220 + )).flat()
221 + const e = await externalIp
222 + if (e) ips.unshift(e)
223 + return v4first(ips)
224 .filter((x,i,a) => a.length > 1 || !x.startsWith('169.254')) // 169.254 = dhcp failure on the interface, but keep it if it's our only one
225
226 function v4first(a: string[]) {
@@ -224,8 +228,8 @@ export function getIps() {
228 }
229 }
230
227 -export function getUrls() {
228 - const ips = getIps().map(ip => ip.includes(':') ? '[' + ip + ']' : ip)
231 +export async function getUrls() {
232 + const ips = (await getIps()).map(ip => ip.includes(':') ? '[' + ip + ']' : ip)
233 return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => {
234 if (!srv?.listening)
235 return false
@@ -237,6 +241,8 @@ export function getUrls() {
241 }
242
243 function printUrls(srvName: string) {
240 - for (const url of getUrls()[srvName]!)
241 - console.log('serving on', url)
244 + getUrls().then(urls => {
245 + for (const url of urls[srvName]!)
246 + console.log('serving on', url)
247 + })
248 }