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