admin/internet: router port mapping
Massimo Melina committed
Aug 15, 2023 at 23:19 UTC
257c3fb45130cc1d57d4d47d553bcd607cd3ba90
4 files changed
+96
-42
admin/src/InternetPage.ts
+47
-8
@@ -1,36 +1,75 @@
1
import { createElement as h } from 'react'
2
-import { Box } from '@mui/material'
2
+import { Box, Button, Link } from '@mui/material'
3
import { HomeWorkTwoTone, PublicTwoTone, RouterTwoTone } from '@mui/icons-material'
4
-import { useApiEx } from './api'
5
-import { with_ } from '@hfs/shared'
4
+import { apiCall, useApiEx } from './api'
5
+import { closeDialog, with_ } from '@hfs/shared'
6
import { Flex } from './misc'
7
+import { confirmDialog, promptDialog, toast, waitDialog } from './dialog'
8
+import { NumberField } from '@hfs/mui-grid-form'
9
+import md from './md'
10
11
export default function InternetPage() {
12
const { data: status } = useApiEx('get_status')
13
const localColor = with_([status?.http?.error, status?.https?.error], ([h, s]) =>
14
h && s ? 'error' : h || s ? 'warning' : 'success')
12
- const { data: nat } = useApiEx('get_nat')
15
+ const { data: nat, reload } = useApiEx('get_nat')
16
+ const port = nat?.port
17
+ const wrongMap = nat?.mapped && nat.mapped.private.port !== port
18
return h(Box, {},
19
h(Box, { mb: 2 }, "This page helps you making your server work on the internet"),
20
h(Flex, { justifyContent: 'space-around', alignItems: 'center', maxWidth: '40em' },
16
- h(Device, { name: "Local network", icon: HomeWorkTwoTone, color: localColor, ip: nat?.local_ip }),
21
+ h(Device, { name: "Local network", icon: HomeWorkTwoTone, color: localColor, ip: nat?.local_ip,
22
+ below: port && h(Box, { fontSize: 'smaller' }, "port ", port),
23
+ }),
24
h(Sep),
18
- h(Device, { name: "Router", icon: RouterTwoTone, ip: nat?.gateway_ip }),
25
+ h(Device, {
26
+ name: "Router", icon: RouterTwoTone, ip: nat?.gateway_ip,
27
+ color: nat?.mapped && (wrongMap ? 'warning' : 'success'),
28
+ below: h(Link, { fontSize: 'smaller', display: 'block', onClick: configure, sx: { cursor: 'pointer' } },
29
+ "port ", wrongMap ? 'is wrong' : nat?.mapped ? nat.mapped.public.port : "unknown"),
30
+ }),
31
h(Sep),
32
h(Device, { name: "Internet", icon: PublicTwoTone, ip: nat?.public_ip }),
33
),
34
)
35
+
36
+ async function configure() {
37
+ if (wrongMap) {
38
+ if (!await confirmDialog(`There is a port mapping but it is pointing to the wrong port (${nat.mapped.private.port})`, { confirmText: "Fix it" })) return
39
+ return mapPort(nat.mapped.public.port, "Map corrected")
40
+ }
41
+ 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.`), {
42
+ value: nat?.mapped?.public.port || port,
43
+ field: { label: "Port seen from the Internet", comp: NumberField },
44
+ addToBar: nat?.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")],
45
+ dialogProps: { sx: { maxWidth: '20em' } },
46
+ })
47
+ if (res)
48
+ await mapPort(Number(res), "Port mapped")
49
+
50
+ function remove() {
51
+ closeDialog()
52
+ mapPort(0, "Port removed")
53
+ }
54
+
55
+ async function mapPort(external: number, msg: string) {
56
+ await apiCall('map_port', { external }, { modal: waitDialog })
57
+ reload()
58
+ toast(msg, 'success')
59
+ }
60
+ }
61
}
62
63
function Sep() {
64
return h(Box, { flex: 1, className: 'animated-dashed-line' })
65
}
66
29
-function Device({ name, icon, color, ip }: any) {
67
+function Device({ name, icon, color, ip, below }: any) {
68
const fontSize = 'min(20vw, 10vh)'
69
return h(Box, { display: 'inline-block', textAlign: 'center' },
70
h(icon, { color, sx: { fontSize, mb: '-0.1em' } }),
71
h(Box, { fontSize: 'larger' }, name),
34
- h(Box, { fontSize: 'smaller' }, ip || '…')
72
+ h(Box, { fontSize: 'smaller' }, ip || '…'),
73
+ below,
74
)
75
}
\ No newline at end of file
admin/src/dialog.ts
+9
-12
@@ -117,8 +117,8 @@ export function alertDialog(msg: ReactElement | string | Error, options?: AlertT
117
return Object.assign(promise, dialog)
118
}
119
120
-interface ConfirmOptions extends Omit<DialogOptions, 'Content'> { href?: string }
121
-export function confirmDialog(msg: ReactNode, { href, ...rest }: ConfirmOptions={}) {
120
+interface ConfirmOptions extends Omit<DialogOptions, 'Content'> { href?: string, confirmText?: string, dontText?: string }
121
+export function confirmDialog(msg: ReactNode, { href, confirmText="Confirm", dontText="Don't", ...rest }: ConfirmOptions={}) {
122
const promise = pendingPromise<boolean>()
123
const dialog = newDialog({
124
className: 'dialog-confirm',
@@ -136,8 +136,8 @@ export function confirmDialog(msg: ReactNode, { href, ...rest }: ConfirmOptions=
136
h('a', {
137
href,
138
onClick: () => closeDialog(true),
139
- }, h(Button, { variant: 'contained' }, "Confirm")),
140
- h(Button, { onClick: () => closeDialog(false) }, "Don't"),
139
+ }, h(Button, { variant: 'contained' }, confirmText)),
140
+ h(Button, { onClick: () => closeDialog(false) }, dontText),
141
),
142
)
143
}
@@ -189,24 +189,21 @@ export async function formDialog<T>(
189
}
190
}
191
192
-export async function promptDialog(msg: string, props:any={}) : Promise<string | undefined> {
193
- return formDialog<{ text: string }>({ ...props, form: {
192
+export async function promptDialog(msg: ReactNode, { value, field, save, addToBar=[], ...props }:any={}) : Promise<string | undefined> {
193
+ return formDialog<{ text: string }>({ ...props, values: { text: value }, form: {
194
fields: [
195
- { k: 'text', label: null, autoFocus: true,
196
- before: h(Box, { mb: 2 }, msg),
197
- ...props.field
198
- },
195
+ { k: 'text', label: null, autoFocus: true, ...field, before: h(Box, { mb: 2 }, msg) },
196
],
197
save: {
198
children: "Continue",
199
startIcon: h(Forward),
203
- ...props.save,
200
+ ...save,
201
},
202
saveOnEnter: true,
203
barSx: { gap: 2 },
204
addToBar: [
205
h(Button, { onClick: closeDialog }, "Cancel"),
209
- ...props.addToBar||[],
206
+ ...addToBar,
207
]
208
} }).then(values => values?.text)
209
}
central.json
+2
-2
@@ -3,7 +3,7 @@
3
{
4
"url": "https://ports.yougetsignal.com/check-port.php",
5
"headers": {"content-type": "application/x-www-form-urlencoded"},
6
- "method": "POST",
6
+ "method": "post",
7
"selector": "p:nth-child(1)",
8
"body": "remoteAddress=$IP&portNumber=$PORT",
9
"regexpFailure": "is closed",
@@ -12,7 +12,7 @@
12
{
13
"url": "https://canyouseeme.org/",
14
"headers": {"content-type": "application/x-www-form-urlencoded"},
15
- "method": "POST",
15
+ "method": "post",
16
"selector": "div.tool > p:nth-child(1)",
17
"body": "port=$PORT",
18
"regexpFailure": "I could not see your service",
src/api.net.ts
+38
-20
@@ -2,7 +2,7 @@
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, HTTP_SERVER_ERROR } from './const'
6
import axios from 'axios'
7
import {parse} from 'node-html-parser'
8
import _ from 'lodash'
@@ -19,43 +19,61 @@ export interface PortScannerService {
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
+ })
27
+ const status = await getServerStatus()
28
+ const port = 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 === port || x.description === 'hfs')
31
+ return {
32
+ local_ip: address,
33
+ gateway_ip: new URL(gateway.description).hostname,
34
+ public_ip: await client.getPublicIp().catch(() => null),
35
+ mapped,
36
+ mappings,
37
+ port,
38
+ }
39
+}
40
+
41
const apis: ApiHandlers = {
42
+ get_nat,
43
24
- async get_nat() {
44
+ async map_port({ external }) {
45
+ const { mapped, port } = await get_nat()
46
const client = new Client()
26
- const { gateway, address} = await client.getGateway()
27
- return {
28
- local_ip: address,
29
- gateway_ip: new URL(gateway.description).hostname,
30
- public_ip: await client.getPublicIp()
31
- }
47
+ if (mapped)
48
+ await client.removeMapping({ private: mapped.private.port, public: mapped.public.port, protocol: 'tcp' })
49
+ if (external)
50
+ await client.createMapping({ private: port, public: external, description: 'hfs', ttl: 0 })
51
+ return {}
52
},
53
54
async check_server() {
35
- const noop = () => null;
55
+ const noop = () => null
56
const client = new Client()
57
const publicIp = await client.getPublicIp().catch(noop)
38
- if (publicIp === null) return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'cannot detect public ip')
58
+ if (!publicIp) return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'cannot detect public ip')
59
const {http, https} = await getServerStatus()
60
const port = https.port > 0 ? https.port : http.port
61
const prjInfo = await getProjectInfo()
62
for (const svc of _.shuffle<PortScannerService>(prjInfo.checkServerServices)) {
43
- const api = (axios as any)[svc.method.toLowerCase()]
44
- const body = (svc.body || "")
45
- .replace('$IP', publicIp)
46
- .replace('$PORT', port)
63
+ const api = (axios as any)[svc.method]
64
+ const body = svc.body?.replace('$IP', publicIp).replace('$PORT', port) || ''
65
const res = await api(svc.url, body, {headers: svc.headers}).catch(noop)
48
- if (res == null) continue
66
+ if (!res) continue
67
const parsed = parse(res.data).querySelector(svc.selector)?.innerText
68
if (!parsed) continue
51
- const result = new RegExp(svc.regexpSuccess).test(parsed)
52
- const ftest = new RegExp(svc.regexpFailure).test(parsed)
53
- if (result === ftest) continue;
69
+ const success = new RegExp(svc.regexpSuccess).test(parsed)
70
+ const failure = new RegExp(svc.regexpFailure).test(parsed)
71
+ if (success === failure) continue // this result cannot be trusted
72
const service = new URL(svc.url).hostname
55
- return {result, service}
73
+ return { success, service }
74
}
75
return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'no service available to detect upnp mapping')
58
- }
76
+ },
77
78
}
79