@samitouri / QOSami-HFS / commits / cb270ef9

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)