admin/internet: work also without upnp
Massimo Melina committed
Aug 18, 2023 at 21:02 UTC
cb270ef91b0ae044bd6d900eacc7662610c9c9cf
9 files changed
+173
-81
admin/src/InternetPage.ts
+98
-40
@@ -1,62 +1,107 @@
1
-import { createElement as h, useState } from 'react'
1
+import { createElement as h, useEffect, useState } from 'react'
2
import { Alert, Box, Button, CircularProgress, LinearProgress, Link } from '@mui/material'
3
import { HomeWorkTwoTone, PublicTwoTone, RouterTwoTone } from '@mui/icons-material'
4
import { apiCall, useApiEx } from './api'
5
import { closeDialog, with_ } from '@hfs/shared'
6
import { Flex } from './misc'
7
-import { alertDialog, confirmDialog, promptDialog, toast, waitDialog } from './dialog'
7
+import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
8
import { NumberField } from '@hfs/mui-grid-form'
9
import md from './md'
10
11
+const PORT_FORWARD_URL = 'https://portforward.com/'
12
+const HIGHER_PORT = 1080
13
+const MSG_ISP = `It is possible that your Internet Provider won't let you get incoming connections. Ask them if they sell "public IP" as an extra service.`
14
+
15
export default function InternetPage() {
16
const [checkResult, setCheckResult] = useState<boolean | undefined>()
17
const [checking, setChecking] = useState(false)
18
+ const [mapping, setMapping] = useState(false)
19
+ const [verifyAgain, setVerifyAgain] = useState(false)
20
const { data: status } = useApiEx('get_status')
21
const localColor = with_([status?.http?.error, status?.https?.error], ([h, s]) =>
22
h && s ? 'error' : h || s ? 'warning' : 'success')
17
- const { data: nat, reload, error } = useApiEx('get_nat')
23
+ const { data: nat, reload, error, loading } = useApiEx('get_nat')
24
const port = nat?.internalPort
25
const wrongMap = nat?.mapped && nat.mapped.private.port !== port
26
+ const doubleNat = nat?.externalIp && nat.externalIp !== nat.publicIp
27
+ useEffect(() => {
28
+ if (!verifyAgain || !nat || loading) return
29
+ setVerifyAgain(false)
30
+ verify().then()
31
+ }, [verifyAgain, nat, loading])
32
return h(Box, {},
21
- h(Box, { mb: 2 }, "This page helps you making your server work on the internet"),
22
- error ? h(Alert, { severity: 'warning' }, "Cannot analyze network because UPnP unavailable")
23
- : !nat ? h(CircularProgress)
24
- : h(Flex, { justifyContent: 'space-around', alignItems: 'center', maxWidth: '40em' },
25
- h(Device, { name: "Local network", icon: HomeWorkTwoTone, color: localColor, ip: nat?.localIp,
26
- below: port && h(Box, { fontSize: 'smaller' }, "port ", port),
27
- }),
28
- h(Sep),
29
- h(Device, {
30
- name: "Router", icon: RouterTwoTone, ip: nat?.gatewayIp,
31
- color: nat?.mapped && (wrongMap ? 'warning' : 'success'),
32
- below: h(Link, { fontSize: 'smaller', display: 'block', onClick: configure, sx: { cursor: 'pointer' } },
33
+ h(Alert, { severity: 'info', sx: { mb: 2 } }, "This page helps you making your server work on the Internet"),
34
+ error ? "Error" : !nat ? h(CircularProgress) : h(Flex, { justifyContent: 'space-around', alignItems: 'center', maxWidth: '40em' },
35
+ h(Device, { name: "Local network", icon: HomeWorkTwoTone, color: localColor, ip: nat?.localIp,
36
+ below: port && h(Box, { fontSize: 'smaller' }, "port ", port),
37
+ }),
38
+ h(Sep),
39
+ h(Device, {
40
+ name: "Router", icon: RouterTwoTone, ip: nat?.gatewayIp,
41
+ color: nat?.mapped && (wrongMap ? 'warning' : 'success'),
42
+ below: mapping ? h(LinearProgress, { sx: { height: '1em' } })
43
+ : h(Link, { fontSize: 'smaller', display: 'block', onClick: configure, sx: { cursor: 'pointer' } },
44
"port ", wrongMap ? 'is wrong' : nat?.mapped ? nat.mapped.public.port : "unknown"),
34
- }),
35
- h(Sep),
36
- h(Device, { name: "Internet", icon: PublicTwoTone, ip: nat?.publicIp,
37
- color: checkResult === undefined ? undefined : checkResult ? 'success' : 'warning',
38
- below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller' }, checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
39
- ' ',
40
- h(Link, { onClick: verify, sx: { cursor: 'pointer' } }, "Verify")
41
- )
42
- }),
43
- ),
45
+ }),
46
+ h(Sep),
47
+ h(Device, { name: "Internet", icon: PublicTwoTone, ip: nat?.publicIp,
48
+ color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined,
49
+ below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller' },
50
+ doubleNat && h(Link, { sx: { cursor: 'pointer', display: 'block' }, onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
51
+ checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
52
+ ' ',
53
+ nat?.publicIp && h(Link, { onClick: verify, sx: { cursor: 'pointer' } }, "Verify")
54
+ )
55
+ }),
56
+ ),
57
)
58
46
- async function verify() {
59
+ async function verify(): Promise<any> {
60
+ setCheckResult(undefined)
61
+ if (!await confirmDialog("This test is going to check that your server is working properly on the Internet")) return
62
setChecking(true)
48
- const { success } = await apiCall('check_server', {}).finally(() => setChecking(false))
49
- setCheckResult(success)
50
- if (success)
51
- return toast("Your server is responding correctly over the internet", 'success')
52
- alertDialog("We couldn't reach your server from the internet", 'warning')
63
+ try {
64
+ const { success } = await apiCall('check_server', {})
65
+ setCheckResult(success)
66
+ if (success)
67
+ return toast("Your server is responding correctly over the Internet", 'success')
68
+ if (wrongMap)
69
+ return fixPort().then(retry)
70
+ if (doubleNat)
71
+ return alertDialog(MSG_ISP, 'warning')
72
+ const msg = "We couldn't reach your server from the Internet. "
73
+ if (nat.upnp && !nat.mapped)
74
+ return confirmDialog(msg + "Try port-forwarding on your router", { confirmText: "Fix it" }).then(go => {
75
+ if (go) mapPort(Math.max(nat.internalPort, HIGHER_PORT), "Port forwarded").then(retry)
76
+ })
77
+ const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {},
78
+ !nat.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")),
79
+ h('li', {}, "There could be a firewall, try configuring or disabling it."),
80
+ nat.mapped?.public.port <= 1024 && h('li', {},
81
+ "Your Internet Provider may be blocking ports under 1024. ",
82
+ h(Button, { size: 'small', onClick() { close(); mapPort(HIGHER_PORT).then(retry) } }, "Try " + HIGHER_PORT) ),
83
+ nat.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."),
84
+ h('li', {}, MSG_ISP),
85
+ )), 'warning')
86
+ }
87
+ catch(e: any) {
88
+ alertDialog(e)
89
+ }
90
+ finally {
91
+ setChecking(false)
92
+ }
93
+
94
+ function retry() {
95
+ setVerifyAgain(true)
96
+ }
97
}
98
99
async function configure() {
56
- if (wrongMap) {
57
- if (!await confirmDialog(`There is a port mapping but it is pointing to the wrong port (${nat.mapped.private.port})`, { confirmText: "Fix it" })) return
58
- return mapPort(nat.mapped.public.port, "Map corrected")
59
- }
100
+ if (wrongMap)
101
+ return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${nat.mapped.private.port})`, { confirmText: "Fix it" })
102
+ && fixPort()
103
+ if (!nat?.upnp)
104
+ return alertDialog(h(Box, { lineHeight: 1.5 }, md(`We cannot help you configuring your router because UPnP is not available.\nFind more help [on this website](${PORT_FORWARD_URL}).`)), 'info')
105
const res = await promptDialog(md(`This will ask the router to map your port, so that it can be reached from the Internet.\nYou can set the same number of the local network (${port}), or a different one.`), {
106
value: nat?.mapped?.public.port || port,
107
field: { label: "Port seen from the Internet", comp: NumberField },
@@ -64,17 +109,30 @@ export default function InternetPage() {
109
dialogProps: { sx: { maxWidth: '20em' } },
110
})
111
if (res)
67
- await mapPort(Number(res), "Port mapped")
112
+ await mapPort(Number(res), "Port forwarded")
113
114
function remove() {
115
closeDialog()
116
mapPort(0, "Port removed")
117
}
118
+ }
119
+
120
+ function fixPort() {
121
+ return mapPort(nat.mapped.public.port, "Forwarding corrected")
122
+ }
123
74
- async function mapPort(external: number, msg: string) {
75
- await apiCall('map_port', { external }, { modal: waitDialog })
124
+ async function mapPort(external: number, msg='') {
125
+ setMapping(true)
126
+ try {
127
+ await apiCall('map_port', { external })
128
reload()
77
- toast(msg, 'success')
129
+ if (msg) toast(msg, 'success')
130
+ }
131
+ catch {
132
+ return alertDialog("Operation failed", 'error')
133
+ }
134
+ finally {
135
+ setMapping(false)
136
}
137
}
138
}
@@ -88,7 +146,7 @@ function Device({ name, icon, color, ip, below }: any) {
146
return h(Box, { display: 'inline-block', textAlign: 'center' },
147
h(icon, { color, sx: { fontSize, mb: '-0.1em' } }),
148
h(Box, { fontSize: 'larger' }, name),
91
- h(Box, { fontSize: 'smaller' }, ip || '…'),
149
+ h(Box, { fontSize: 'smaller' }, ip || "unknown"),
150
below,
151
)
152
}
\ No newline at end of file
admin/src/api.ts
+1
-2
@@ -20,9 +20,8 @@ setDefaultApiCallOptions({
20
})
21
22
export function useApiEx<T=any>(...args: Parameters<typeof useApi>) {
23
- const [data, error, reload] = useApi<T>(...args)
23
+ const [data, error, reload, loading] = useApi<T>(...args)
24
const cmd = args[0]
25
- const loading = data === undefined
25
const element = useMemo(() =>
26
!cmd ? null
27
: error ? h(Alert, { severity: 'error' }, String(error), h(IconBtn, { icon: Refresh, onClick: reload, sx: { m:'-8px 0 -8px 16px' } }))
admin/src/dialog.ts
+1
-1
@@ -121,7 +121,7 @@ export function alertDialog(msg: ReactElement | string | Error, options?: AlertT
121
}
122
123
interface ConfirmOptions extends Omit<DialogOptions, 'Content'> { href?: string, confirmText?: string, dontText?: string }
124
-export function confirmDialog(msg: ReactNode, { href, confirmText="Confirm", dontText="Don't", ...rest }: ConfirmOptions={}) {
124
+export function confirmDialog(msg: ReactNode, { href, confirmText="Go", dontText="Don't", ...rest }: ConfirmOptions={}) {
125
const promise = pendingPromise<boolean>()
126
const dialog = newDialog({
127
className: 'dialog-confirm',
central.json
+5
@@ -18,5 +18,10 @@
18
"regexpFailure": "I could not see your service",
19
"regexpSuccess": "I can see your service"
20
}
21
+ ],
22
+ "publicIpServices": [
23
+ "https://checkip.amazonaws.com",
24
+ "https://ipinfo.io/ip",
25
+ "https://ifconfig.io/ip"
26
]
27
}
shared/api.ts
+4
-3
@@ -9,6 +9,7 @@ export const API_URL = '/~/api/'
9
const timeoutByApi: Dict = {
10
loginSrp1: 90, // support antibrute
11
update: 600, // download can be lengthy
12
+ get_nat: 20, // wait more mostly for debug purposes, as we don't want this to take this long
13
get_status: 20 // can be lengthy on slow machines because of the find-process-on-busy-port feature
14
}
15
@@ -67,7 +68,7 @@ export class ApiError extends Error {
68
}
69
}
70
70
-export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void] {
71
+export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undefined, undefined | Error, ()=>void, boolean] {
72
const [ret, setRet] = useStateMounted<T | undefined>(undefined)
73
const [err, setErr] = useStateMounted<Error | undefined>(undefined)
74
const [forcer, setForcer] = useStateMounted(0)
@@ -81,7 +82,7 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
82
let aborted = false
83
const req = apiCall<T>(cmd, params)
84
const wholePromise = req.then(x => aborted || setRet(x), x => aborted || setErr(x))
84
- .finally(()=> loadingRef.current = undefined)
85
+ .finally(() => loadingRef.current = reloadingRef.current = undefined)
86
loadingRef.current = Object.assign(wholePromise, {
87
abort() {
88
aborted = true
@@ -93,7 +94,7 @@ export function useApi<T=any>(cmd: string | Falsy, params?: object) : [T | undef
94
const reload = useCallback(() => loadingRef.current
95
|| setForcer(v => v+1) || (reloadingRef.current = pendingPromise()),
96
[setForcer])
96
- return [ret, err, reload]
97
+ return [ret, err, reload, ret === undefined || Boolean(loadingRef.current || reloadingRef.current)]
98
}
99
100
type EventHandler = (type:string, data?:any) => void
src/api.net.ts
+53
-26
@@ -2,36 +2,33 @@
2
3
import { ApiError, ApiHandlers } from './apiMiddleware'
4
import { Client } from 'nat-upnp'
5
-import { HTTP_SERVICE_UNAVAILABLE } from './const'
5
+import { HTTP_SERVICE_UNAVAILABLE, IS_MAC, IS_WINDOWS } from './const'
6
import axios from 'axios'
7
import {parse} from 'node-html-parser'
8
import _ from 'lodash'
9
-import { getServerStatus } from './listen'
9
+import { getIps, getServerStatus } from './listen'
10
import { getProjectInfo } from './github'
11
+import { httpsString } from './util-http'
12
+import { exec } from 'child_process'
13
12
-export interface PortScannerService {
13
- url: string
14
- headers: {[k: string]: any}
15
- method: string
16
- selector: string
17
- body?: string
18
- regexpFailure: string
19
- regexpSuccess: string
20
-}
21
-
22
-async function get_nat() {
23
- const client = new Client()
24
- const { gateway, address} = await client.getGateway().catch(() => {
25
- throw new ApiError(HTTP_SERVICE_UNAVAILABLE, 'upnp failed')
26
- })
14
+async function getNatInfo() {
15
+ const client = new Client({ timeout: 3000 })
16
+ const res = await client.getGateway().catch(() => null)
17
const status = await getServerStatus()
18
+ const mappings = res && await client.getMappings().catch(() => null)
19
+ const externalIp = res && await client.getPublicIp().catch(() => null)
20
+ const publicIp = await getPublicIp() || externalIp
21
+ const gatewayIp = res ? new URL(res.gateway.description).hostname : await getGateway().catch(() => null)
22
+ const localIp = res?.address || getIps()[0]
23
const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port
29
- const mappings = await client.getMappings().catch(() => null)
30
- const mapped = _.find(mappings, x => x.private.host === address && x.private.port === internalPort || x.description === 'hfs')
24
+ const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort || x.description === 'hfs')
25
+ console.debug('responding')
26
return {
32
- localIp: address,
33
- gatewayIp: new URL(gateway.description).hostname,
34
- publicIp: await client.getPublicIp().catch(() => null),
27
+ upnp: Boolean(res),
28
+ localIp,
29
+ gatewayIp,
30
+ publicIp,
31
+ externalIp,
32
mapped,
33
mappings,
34
internalPort,
@@ -39,11 +36,29 @@ async function get_nat() {
36
}
37
}
38
39
+async function getPublicIp() {
40
+ const prjInfo = await getProjectInfo()
41
+ for (const url of _.shuffle(prjInfo.publicIpServices))
42
+ try { return (await httpsString(url)).body?.trim() }
43
+ catch (e: any) { console.debug(String(e)) }
44
+}
45
+
46
+function getGateway(): Promise<string | undefined> {
47
+ return new Promise((resolve, reject) =>
48
+ exec(IS_WINDOWS || IS_MAC ? 'netstat -rn' : 'route -n', (err, out) => {
49
+ if (err) return reject(err)
50
+ const re = IS_WINDOWS ? /(?:0\.0\.0\.0 +){2}([\d\.]+)/ : IS_MAC ? /default +([\d\.]+)/ : /^0\.0\.0\.0 +([\d\.]+)/
51
+ resolve(re.exec(out)?.[1])
52
+ }) )
53
+}
54
+
55
const apis: ApiHandlers = {
43
- get_nat,
56
+ get_nat: getNatInfo,
57
58
async map_port({ external }) {
46
- const { mapped, internalPort } = await get_nat()
59
+ const { gatewayIp, mapped, internalPort } = await getNatInfo()
60
+ if (!gatewayIp)
61
+ throw new ApiError(HTTP_SERVICE_UNAVAILABLE, 'upnp failed')
62
const client = new Client()
63
if (mapped)
64
await client.removeMapping({ private: mapped.private.port, public: mapped.public.port, protocol: 'tcp' })
@@ -54,22 +69,34 @@ const apis: ApiHandlers = {
69
70
async check_server() {
71
const noop = () => null
57
- const { publicIp, internalPort, externalPort } = await get_nat()
72
+ const { publicIp, internalPort, externalPort } = await getNatInfo()
73
if (!publicIp) return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'cannot detect public ip')
74
const prjInfo = await getProjectInfo()
75
const port = externalPort || internalPort
76
console.log(`checking server ${publicIp}:${port}`)
77
+ interface PortScannerService {
78
+ url: string
79
+ headers: {[k: string]: string}
80
+ method: string
81
+ selector: string
82
+ body?: string
83
+ regexpFailure: string
84
+ regexpSuccess: string
85
+ }
86
for (const svc of _.shuffle<PortScannerService>(prjInfo.checkServerServices)) {
87
+ const service = new URL(svc.url).hostname
88
+ console.log('using service', service)
89
const api = (axios as any)[svc.method]
90
const body = svc.body?.replace('$IP', publicIp).replace('$PORT', port) || ''
91
const res = await api(svc.url, body, {headers: svc.headers}).catch(noop)
92
if (!res) continue
93
+ console.debug('service responded')
94
const parsed = parse(res.data).querySelector(svc.selector)?.innerText
95
if (!parsed) continue
96
const success = new RegExp(svc.regexpSuccess).test(parsed)
97
const failure = new RegExp(svc.regexpFailure).test(parsed)
98
if (success === failure) continue // this result cannot be trusted
72
- const service = new URL(svc.url).hostname
99
+ console.log('server', success ? 'responding' : 'not responding')
100
return { success, service }
101
}
102
return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'no service available to detect upnp mapping')
src/const.ts
+1
@@ -48,6 +48,7 @@ export const HTTP_SERVER_ERROR = 500
48
export const HTTP_SERVICE_UNAVAILABLE = 503
49
50
export const IS_WINDOWS = process.platform === 'win32'
51
+export const IS_MAC = process.platform === 'darwin'
52
export const IS_BINARY = !basename(process.execPath).includes('node') // this won't be node if pkg was used
53
export const APP_PATH = dirname(IS_BINARY ? process.execPath : __dirname)
54
src/listen.ts
+8
-9
@@ -201,21 +201,20 @@ export async function getServerStatus() {
201
202
const ignore = /^(lo|.*loopback.*|virtualbox.*|.*\(wsl\).*|llw\d|awdl\d|utun\d|anpi\d)$/i // avoid giving too much information
203
204
+export function getIps() {
205
+ const ips = onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
206
+ nets && !ignore.test(name) && onlyTruthy(nets.map(net => !net.internal && net.address)) )).flat()
207
+ return _.sortBy(ips, x => x.includes(':')) // IPv4 first
208
+}
209
+
210
export function getUrls() {
211
+ const ips = getIps().map(ip => ip.includes(':') ? '[' + ip + ']' : ip)
212
return Object.fromEntries(onlyTruthy([httpSrv, httpsSrv].map(srv => {
213
if (!srv?.listening)
214
return false
215
const port = (srv?.address() as any)?.port
216
const appendPort = port === (srv.name === 'https' ? 443 : 80) ? '' : ':' + port
210
- const urls = onlyTruthy(Object.entries(networkInterfaces()).map(([name, nets]) =>
211
- nets && !ignore.test(name) && nets.map(net => {
212
- if (net.internal) return
213
- let { address } = net
214
- if (address.includes(':'))
215
- address = '[' + address + ']'
216
- return srv.name + '://' + address + appendPort
217
- })
218
- ).flat())
217
+ const urls = ips.map(ip => `${srv.name}://${ip}${appendPort}`)
218
return urls.length && [srv.name, urls]
219
})))
220
}
src/util-http.ts
+2
@@ -23,7 +23,9 @@ export function httpsStream(url: string, { body, ...options }:XRequestOptions ={
23
return new Promise((resolve, reject) => {
24
if (body)
25
options.method ||= 'POST'
26
+ console.debug("making http request", url)
27
const req = https.request(url, options, res => {
28
+ console.debug("http responded", res.statusCode)
29
if (!res.statusCode || res.statusCode >= 400)
30
return reject(new Error(String(res.statusCode), { cause: res }))
31
if (res.statusCode === HTTP_TEMPORARY_REDIRECT && res.headers.location)