main
ts 492 lines 25.8 KB
Raw
1 import { createElement as h, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
2 import {
3 Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link, Typography, Skeleton,
4 } from '@mui/material'
5 import { CardMembership, Check, Dns, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RouterTwoTone, Send, Storage,
6 Error as ErrorIcon, SvgIconComponent, Search } from '@mui/icons-material'
7 import { apiCall, useApiEvents, useApiEx } from './api'
8 import {
9 closeDialog, DAY, formatTimestamp, wait, wantArray, with_, PORT_DISABLED, isIP, CFG, md,
10 useRequestRender, replace, restartAnimation, prefix, isIpLan, HIDE_IN_TESTS
11 } from './misc'
12 import { Flex, LinkBtn, Btn, Country, wikiLink } from './mui'
13 import { alertDialog, confirmDialog, formDialog, promptDialog, toast, waitDialog } from './dialog'
14 import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
15 import { suggestMakingCert } from './OptionsPage'
16 import { changeBaseUrl } from './FileForm'
17 import { adminApis } from '../../src/adminApis'
18 import { ALL, WITH_IP } from './countries'
19 import _ from 'lodash'
20 import { SvgIconProps } from '@mui/material/SvgIcon'
21 import { ConfigForm } from './ConfigForm'
22 import { DynamicDnsResult } from '../../src/ddns'
23 import { ArrayField } from './ArrayField'
24 import VfsPathField from './VfsPathField'
25 import { PageProps } from './App'
26
27 const COUNTRIES = ALL.filter(x => WITH_IP.includes(x.code))
28
29 const PORT_FORWARD_URL = 'https://portforward.com/'
30 const HIGHER_PORT = 1080
31 const MSG_ISP = h('div', {}, "HFS will probably not be reachable on the Internet. ", wikiLink('Work-on-the-internet#double-nat', "Read more"))
32
33 export default function InternetPage({ setTitleSide }: PageProps) {
34 const [checkResult, setCheckResult] = useState<boolean | undefined>()
35 const [checking, setChecking] = useState(false)
36 const [mapping, setMapping] = useState(false)
37 const status = useApiEx('get_status')
38 const config = useApiEx('get_config', { only: [CFG.base_url] })
39 const baseUrl = config.data?.[CFG.base_url]
40 const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
41 h && s ? 'error' : h || s ? 'warning' : 'success')
42 const nat = useApiEx<typeof adminApis.get_nat>('get_nat', {}, { timeout: 20 })
43 const { data: publicIps } = useApiEx<typeof adminApis.get_public_ips>('get_public_ips', { timeout: 20 })
44 const { data } = nat
45 const port = data?.internalPort
46 const wrongMap = data?.mapped && data.mapped.private.port !== port && data.mapped.private.port
47 const doubleNat = data?.externalIp && publicIps && !publicIps.includes(data.externalIp)
48 const verifyAgain = useRequestRender()
49 useEffect(() => {
50 if (verifyAgain.state) // skip first
51 void verify(true)
52 }, [verifyAgain.state])
53 setTitleSide(useMemo(() =>
54 h(Alert, { severity: 'info', sx: { display: { xs: 'none', sm: 'inherit' } } }, "This page makes sure your site is working correctly on the Internet"),
55 []))
56 return h(Flex, { vert: true, gap: '2em' },
57 h(Box, { sx: { maxWidth: '40em' } }, networkBox()),
58 h(Flex, { gap: '2em', flexWrap: 'wrap', maxWidth: '84em', '&>*': { maxWidth: '40em', width: { md: '40em' } }, alignItems: 'flex-start', justifyContent: 'space-between' },
59 baseUrlBox(),
60 httpsBox(),
61 geoBox(),
62 ddnsBox(),
63 ))
64
65 function stripTags(html: string) {
66 return html.replace(/.+<body>(.+)<\/body>.+/is, (all,x) => x || all) // extract body, if any
67 .replace(/<[^>]+>/g, ' ')
68 }
69
70 function ddnsBox() {
71 const { data } = useApiEvents<DynamicDnsResult>('get_dynamic_dns_error')
72 const ref = useRef<any>()
73 useEffect(() => ref.current && restartAnimation(ref.current, '1s blink'), [data]);
74 return h(TitleCard, { icon: Dns, title: "Dynamic DNS updater" },
75 data && h(Flex, {},
76 data.error ? h(ErrorIcon, { color: 'error', ref }) : h(Check, { color: 'success', ref }),
77 formatTimestamp(data.ts), '',
78 prefix("Error: ", stripTags(data.error)).slice(0, 500) || "Updated successfully",
79 ),
80 "This tool can keep your domain updated with your latest IP address. Not every service is compatible, and most of them have their own software for the job, which is superior, but we offer this lightweight solution if you prefer it.",
81 h(ConfigForm<{
82 [CFG.dynamic_dns_url]: string,
83 }>, {
84 form: (v, { setValues }) => ({
85 fields: [
86 h(Flex, {},
87 _.map({
88 NoIP: {
89 url: 'https://$username:$password@dynupdate.no-ip.com/nic/update?hostname=$domain',
90 fields: ['username', 'password', 'domain'],
91 },
92 DuckDNS: {
93 url: 'https://www.duckdns.org/update/$domain/$token>OK',
94 fields: [{ k: 'domain', helperText: "do NOT include the .duckdns.org part" }, 'token'],
95 }
96 }, ({ url, fields }, label) =>
97 h(Btn, {
98 key: url,
99 onClick: () => formDialog({
100 title: label + " wizard",
101 form: {
102 sx: { maxWidth: '20em' },
103 before: h(Box, { sx: { mb: 1 } }, "The following information is stored unencrypted"),
104 fields: fields.map(k => _.isString(k) ? { k } : k)
105 }
106 }).then(symbols => symbols && setValues({ [CFG.dynamic_dns_url]: replace(url, symbols as any, '$') }))
107 }, label + " wizard")
108 )
109 ),
110 { k: CFG.dynamic_dns_url, label: "Updater URL", multiline: true,
111 helperText: "Refer to your DNS service provider to know what URL can automatically keep your domain updated. Supported symbols are $IP4, $IP6, $IPX. Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used."
112 },
113 ]
114 })
115 })
116 )
117 }
118
119 function geoBox() {
120 const countryOptions = useMemo(() => COUNTRIES.map(x => ({ value: x.code, label: x.name })), [COUNTRIES])
121 return h(TitleCard, { title: "Geo IP", icon: Public },
122 h(ConfigForm<{
123 [CFG.geo_enable]: boolean
124 [CFG.geo_allow]: null | boolean
125 [CFG.geo_list]: string[]
126 [CFG.geo_allow_unknown]: boolean
127 }>, {
128 keys: [ CFG.geo_enable, CFG.geo_allow, CFG.geo_list, CFG.geo_allow_unknown ],
129 form: values => ({ fields: [
130 { k: CFG.geo_enable, comp: BoolField, label: "Enable", helperText: md("Necessary database will be downloaded every month (2MB). Service is made possible thanks to [IP2Location](https://www.ip2location.com).") },
131 ...!values?.[CFG.geo_enable] ? [] : [
132 {
133 k: CFG.geo_allow,
134 comp: SelectField,
135 label: "Rule",
136 options: { "no restriction": null, "block selected countries": false, "allow selected countries": true },
137 },
138 values[CFG.geo_allow] != null && {
139 k: CFG.geo_list,
140 comp: MultiSelectField<string>,
141 label: `Selected countries (${values[CFG.geo_list]?.length || 0})`,
142 valueSeparator: false,
143 placeholder: "none",
144 options: countryOptions,
145 renderOption: (v: any) => h(Country, { code: v.value, long: true }),
146 clearable: true,
147 getError: (v: any) => values[CFG.geo_allow] && !v?.length && "Cannot be empty",
148 },
149 values[CFG.geo_allow] != null && {
150 k: CFG.geo_allow_unknown,
151 comp: SelectField,
152 label: "When country cannot be determined",
153 helperText: "Local IPs are ignored",
154 options: { Allow: true, Block: false },
155 },
156 ]
157 ] }),
158 addToBar: [
159 h(Box, { sx: { flex: 1 } }),
160 h(Btn, { icon: Search, onClick: lookup }, "Lookup IP")
161 ],
162 })
163 )
164 }
165
166 async function lookup() {
167 const ip = await promptDialog("Lookup IP")
168 if (!ip) return
169 const { country } = await apiCall('geo_ip', { ip })
170 if (!country)
171 return alertDialog("IP not found", 'error')
172 return alertDialog(h(Country, { code: country, long: true }), 'success')
173 }
174
175 function httpsBox() {
176 const [values, setValues] = useState<any>()
177 const cert = useApiEx('get_cert')
178 useEffect(() => { apiCall('get_config', { only: ['acme_domain', 'acme_renew'] }).then(setValues) } , [])
179 const [saving, setSaving] = useState(false)
180 if (!values) return h(CircularProgress)
181 const { https } = status.data ||{}
182 const disabled = https?.port === PORT_DISABLED
183 const error = https?.error
184 return status.element || h(TitleCard, { title: "HTTPS", icon: Lock, color: https?.listening && !error ? 'success' : 'warning' },
185 error ? h(Alert, { severity: 'warning' }, error) :
186 (disabled && h(LinkBtn, { onClick: notEnabled }, "Not enabled")),
187 cert.element || with_(cert.data, c => c.none ? h(LinkBtn, { onClick: noCertClick }, "No certificate configured") : h(Box, {},
188 h(CardMembership, { fontSize: 'small', sx: { mr: 1, verticalAlign: 'middle' } }), "Current certificate",
189 h('ul', {},
190 h('li', {}, "Domain: ", c.altNames?.join(' + ') ||'-'),
191 h('li', {}, "Issuer: ", c.issuer?.O || h('i', {}, 'self-signed')),
192 h('li', {}, "Validity: ", ['validFrom', 'validTo'].map(k => formatTimestamp(c[k])).join('')),
193 )
194 )),
195 h(Divider),
196 h(Form, {
197 sx: { gap: 1 },
198 gridProps: {rowSpacing:1},
199 values,
200 set(v, k) {
201 setValues((was: any) => {
202 const values = { ...was, [k]: v }
203 setSaving(true)
204 apiCall('set_config', { values }).finally(() => setSaving(false))
205 return values
206 })
207 },
208 fields: [
209 md("Generate certificate using [Let's Encrypt](https://letsencrypt.org)"),
210 {
211 k: 'acme_domain',
212 label: "Domain for certificate",
213 sm: values.acme_domain?.length > 30 ? 12 : 6,
214 required: true,
215 multiline: true,
216 fromField: x => x.replaceAll('\n', ','),
217 toField: x => x.replaceAll(',', '\n'),
218 helperText: md("Example: your.domain.com\nMultiple domains on separate lines")
219 },
220 {
221 k: 'acme_renew',
222 label: "Automatic renew one month before expiration",
223 comp: BoolField,
224 disabled: !values.acme_domain
225 },
226 with_(status.data.acmeRenewError, x => x && h(Alert, { severity: 'error' }, x)),
227 ],
228 save: {
229 children: "Request",
230 startIcon: h(Send),
231 ...saving && { loading: true },
232 async onClick() {
233 const [domain, ...altNames] = values.acme_domain.split(',')
234 const fresh = domain === cert.data.subject?.CN && Number(new Date(cert.data.validTo)) - Date.now() >= 30 * DAY
235 if (fresh && !await confirmDialog("Your certificate is still good", { trueText: "Make a new one anyway" }))
236 return
237 if (!await confirmDialog("HFS must temporarily serve HTTP on public port 80, and your router must be configured or this operation will fail")) return
238 if (await stopOnCheckDomain(domain)) return
239 await apiCall('make_cert', { domain, altNames }, { timeout: 20_000 })
240 .then(async () => {
241 await alertDialog("Certificate created", 'success')
242 if (disabled)
243 await notEnabled()
244 cert.reload()
245 }, alertDialog)
246 .finally(status.reload)
247 }
248 },
249 })
250 )
251
252 async function noCertClick() {
253 await suggestMakingCert()
254 cert.reload()
255 status.reload()
256 }
257 }
258
259 async function notEnabled() {
260 if (!await confirmDialog("HTTPS is currently disabled.\nFull configuration is available in the Options page.", { trueText: "Enable it"})) return
261 const stop = waitDialog()
262 try {
263 await apiCall('set_config', { values: { https_port: 443 } })
264 await wait(1000)
265 status.reload()
266 }
267 finally { stop() }
268 }
269
270 function baseUrlBox() {
271 return config.element || h(TitleCard, { icon: Public, title: "Address" },
272 h(Flex, { flexWrap: 'wrap' },
273 "Main address: ",
274 baseUrl ? h('tt', {}, baseUrl) : "automatic, not configured",
275 h(Btn, {
276 size: 'small',
277 variant: 'outlined',
278 'aria-label': "Change address",
279 onClick: () => void changeBaseUrl().then(config.reload)
280 }, "Change"),
281 ),
282 h(Divider),
283 h(ConfigForm<{ roots: any, force_address: boolean }>, {
284 saveOnChange: true,
285 onSave() {
286 status.reload() // this config is affecting status data
287 },
288 form: {
289 fields: [
290 {
291 k: CFG.roots,
292 label: "Domain roots",
293 helperText: "You can decide different home-folders (in the VFS) for different domains, a bit like virtual hosts. If none is matched, the default home will be used.",
294 comp: ArrayField,
295 fields: [
296 { k: 'host', label: "Domain/Host", helperText: "Wildcards supported: *.domain.com|other.com",
297 getError: (v?: string) => v?.includes('/') && "No URLs or paths here!" },
298 { k: 'root', label: "Home/Root", comp: VfsPathField, files: false, placeholder: "default", helperText: "Root path in VFS",
299 $column: { renderCell({ value }: any) { return value || h('i', {}, 'default') } } },
300 ],
301 toField: x => Object.entries(x || {}).map(([host,root]) => ({ host, root })),
302 fromField: x => Object.fromEntries(x.map((row: any) => [row.host, row.root || ''])),
303 },
304 {
305 k: CFG.force_address,
306 label: "Accept requests only using domains above (and localhost)",
307 comp: BoolField,
308 }
309 ]
310 },
311 })
312 )
313 }
314
315 function networkBox() {
316 if (nat.error) return nat.element
317 const direct = publicIps?.includes(data?.localIp!)
318 return h(Flex, { justifyContent: 'space-around' },
319 h(Device, { name: "Server", icon: direct ? Storage : HomeWorkTwoTone, color: localColor, ip: data?.localIp,
320 below: port && h(Box, { className: 'port ' + HIDE_IN_TESTS }, "port ", port),
321 }),
322 !direct && h(DataLine),
323 !direct && h(Device, {
324 name: "Router", icon: RouterTwoTone, ip: data?.gatewayIp,
325 color: checkResult ? 'success' : data?.mapped && (wrongMap ? 'warning' : 'success'),
326 below: mapping ? h(LinearProgress, { sx: { height: '1em' } })
327 : data && (
328 checkResult && !data.mapped ? `port ${data.externalPort || data.internalPort}`
329 : h(LinkBtn, { sx: { display: 'block' }, onClick: configure },
330 "port ", wrongMap ? "is wrong" : data?.externalPort || (checkResult ? "verified" : "unknown"))
331 ),
332 }),
333 h(DataLine),
334 h(Device, { name: "Internet", icon: PublicTwoTone, ip: publicIps,
335 color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined,
336 below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : publicIps && h(Box, { className: HIDE_IN_TESTS },
337 doubleNat && h(LinkBtn, { sx: { display: 'block' }, onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
338 checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
339 ' ',
340 (baseUrl > '' || publicIps?.length > 0) && data?.internalPort && h(LinkBtn, { onClick: () => verify() }, "Verify")
341 || ' ' // steadier layout, mainly for testing
342 )
343 }),
344 )
345 }
346
347 async function stopOnCheckDomain(domain: string) {
348 return domain && false === await apiCall('check_domain', { domain }).catch(e =>
349 confirmDialog(String(e), { trueText: "Continue anyway", falseText: "Stop" }))
350 }
351
352 async function verify(again=false): Promise<any> {
353 await nat.loading
354 const data = nat.getData() // fresh data
355 if (!data) return
356 setCheckResult(undefined)
357 if (!again && !await confirmDialog("This test will check if your server is working properly on the Internet")) return
358 setChecking(true)
359 try {
360 const hostname = baseUrl && new URL(baseUrl).hostname
361 const checkUrl = !isIpLan(hostname) && baseUrl
362 if (!isIP(hostname) && await stopOnCheckDomain(hostname)) return
363 const urlResult = checkUrl && await apiCall('self_check', { url: checkUrl }).catch(e =>
364 alertDialog(!e.code ? e : "Sorry, this function is not available at the moment. Retry later.", 'error'))
365 if (checkUrl && !urlResult)
366 return
367 if (urlResult?.success) {
368 setCheckResult(true)
369 return alertDialog(h(Box, {}, "Your server is responding correctly over the Internet:",
370 h('ul', {}, h('li', {}, urlResult.url))), 'success')
371 }
372 if (urlResult?.success === false)
373 await alertDialog(md(`Your configured address ${checkUrl} doesn't seem to work 😰\nstill, we are going to test your IP address 🤞`), 'warning')
374 const res = await apiCall('self_check', {})
375 if (res.some((x: any) => x.success)) {
376 setCheckResult(true)
377 const mild = urlResult.success === false && md(`Your server is responding over the Internet 👍\nbut not with configured address ${checkUrl} 👎\njust on your IP:`)
378 return alertDialog(h(Box, {}, mild || "Your server is responding correctly over the Internet:",
379 h('ul', {}, ...res.map((x: any) => h('li', {}, x.url)))), mild ? 'warning' : 'success')
380 }
381 setCheckResult(false)
382 if (wrongMap)
383 return fixPort().then(verifyAgain)
384 if (doubleNat)
385 return alertDialog(MSG_ISP, 'warning')
386 const msg = "We couldn't reach your server from the Internet. "
387 if (data.upnp && !data!.mapped)
388 return confirmDialog(msg + "Try port-forwarding on your router", { trueText: "Fix it" }).then(async go => {
389 if (!go) return
390 try { await mapPort(data!.internalPort!, '', '') }
391 catch { await mapPort(HIGHER_PORT, '') }
392 toast("Port forwarded, now we verify again", 'success')
393 verifyAgain()
394 })
395 const cfg = await apiCall('get_config', { only: [CFG.geo_enable, CFG.geo_allow] })
396 const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {},
397 cfg[CFG.geo_enable] && cfg[CFG.geo_allow] != null && h('li', {}, "You may be blocking a country from where the test is performed"),
398 !data.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")),
399 h('li', {}, "There could be a firewall, try configuring or disabling it."),
400 (data.externalPort || data.internalPort!) <= 1024 && h('li', {},
401 "Your Internet Provider may be blocking ports under 1024. ",
402 data.upnp && h(Button, {
403 size: 'small',
404 onClick() {
405 close()
406 mapPort(HIGHER_PORT).then(verifyAgain)
407 }
408 }, "Try " + HIGHER_PORT)),
409 data.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."),
410 h('li', {}, h('div', {}, "Your Internet Provider may not assign you a public IP address. ", wikiLink('Work-on-the-internet#double-nat', "Read more"))),
411 )), 'warning')
412 }
413 catch(e: any) {
414 alertDialog(e)
415 }
416 finally {
417 setChecking(false)
418 }
419 }
420
421 async function configure() {
422 if (!data) return // shut up ts
423 if (wrongMap)
424 return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { trueText: "Fix it" })
425 && fixPort()
426 if (!data.upnp)
427 return alertDialog(h(Box, { sx: { 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')
428 const msg = `For HFS to work over the Internet, you need a port on your modem/router forwarded to this computer's port ${port}.\n\n`
429 + (data?.mapped ? '' : `You may want to check if that's already the case before trying the following.\n\n`)
430 + `This will ask the router to forward a port.\nYou can use the same number as the local network port (${port}), or a different one.`
431 const res = await promptDialog(md(msg), {
432 value: data.externalPort || port,
433 field: { label: "Port seen from the Internet", comp: NumberField },
434 addToBar: data.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")],
435 dialogProps: { sx: { maxWidth: '20em' } },
436 })
437 if (res)
438 await mapPort(Number(res), "Port forwarded").catch(() => {})
439
440 function remove() {
441 closeDialog()
442 mapPort(0, "Port removed")
443 }
444 }
445
446 function fixPort() {
447 if (!data?.externalPort) return alertDialog("externalPort not found", 'error')
448 return mapPort(data.externalPort, "Forwarding corrected")
449 }
450
451 async function mapPort(external: number, msg='', errMsg="Operation failed") {
452 setMapping(true)
453 try {
454 await apiCall('map_port', { external })
455 nat.reload()
456 if (msg) toast(msg, 'success')
457 setCheckResult(undefined) // things have changed, invalidate check result
458 }
459 catch(e: any) {
460 if (errMsg) {
461 const low = (external || data!.internalPort!) < 1024
462 const msg = errMsg + prefix(': ', e?.message) + (low ? ". Some routers refuse to work with ports under 1024." : '')
463 await alertDialog(msg, 'error')
464 }
465 throw e
466 }
467 finally {
468 setMapping(false)
469 }
470 }
471 }
472
473 function DataLine() {
474 return h(Box, { sx: { flex: 1 }, className: 'animated-dashed-line' })
475 }
476
477 function Device({ name, icon, color, ip, below }: any) {
478 const fontSize = 'min(20vw, 10vh)'
479 return h(Box, { sx: { display: 'inline-block', textAlign: 'center' } },
480 h(icon, { color, sx: { fontSize, mb: '-0.1em' } }),
481 h(Box, { sx: { fontSize: 'larger' } }, name),
482 ip === undefined ? h(Skeleton) : h(Box, { sx: { fontSize: 'smaller', whiteSpace: 'pre-wrap' }, className: 'ip ' + HIDE_IN_TESTS }, wantArray(ip).join('\n') || "unknown"),
483 below ? h(Box, { sx: { fontSize: 'smaller' } }, below) : h(Skeleton),
484 )
485 }
486
487 function TitleCard({ title, icon, color, children }: { title: ReactNode, icon?: SvgIconComponent, color?: SvgIconProps['color'], children?: ReactNode }) {
488 return h(Card, {}, h(CardContent, {}, h(Flex, { vert: true },
489 h(Typography, { variant: 'h3', sx: { fontSize: 'x-large' } }, icon && h(icon, { color, sx: { mr: 1, mb: '2px' } }), title),
490 children
491 )))
492 }