admin/internet: dynamic DNS updater
Massimo Melina committed
Dec 22, 2023 at 21:51 UTC
7d6697df8e569b3de19dcf2ccfd0c4edd8af9352
12 files changed
+159
-36
admin/src/ConfigForm.ts
+3
-3
@@ -1,6 +1,6 @@
1
import { Form, FormProps } from '@hfs/mui-grid-form'
2
import { apiCall, useApiEx } from './api'
3
-import { createElement as h, useEffect, useState } from 'react'
3
+import { createElement as h, useEffect, useState, Dispatch } from 'react'
4
import _ from 'lodash'
5
import { IconBtn, modifiedSx } from './mui'
6
import { RestartAlt } from '@mui/icons-material'
@@ -9,7 +9,7 @@ import { Callback } from '../../src/cross'
9
type FormRest<T> = Omit<FormProps<T>, 'values' | 'set' | 'save'> & Partial<Pick<FormProps<T>, 'save'>>
10
export function ConfigForm<T=any>({ keys, form, saveOnChange, onSave, ...rest }: Partial<FormRest<T>> & {
11
keys: (keyof T)[],
12
- form: FormRest<T> | ((values: T) => FormRest<T>),
12
+ form: FormRest<T> | ((values: T, optional: { setValues: Dispatch<T> }) => FormRest<T>),
13
onSave?: Callback,
14
saveOnChange?: boolean
15
}) {
@@ -22,7 +22,7 @@ export function ConfigForm<T=any>({ keys, form, saveOnChange, onSave, ...rest }:
22
}, [modified])
23
if (!values)
24
return config.element
25
- const formProps = _.isFunction(form) ? form(values) : form
25
+ const formProps = _.isFunction(form) ? form(values, { setValues }) : form
26
return h(Form, {
27
values,
28
set(v, k) {
admin/src/InternetPage.ts
+60
-9
@@ -1,12 +1,12 @@
1
-import { createElement as h, ReactNode, useEffect, useMemo, useState } from 'react'
1
+import { createElement as h, ReactNode, useEffect, useMemo, useRef, useState } from 'react'
2
import { Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link } from '@mui/material'
3
-import { CardMembership, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RouterTwoTone, Send, Storage,
4
- SvgIconComponent } from '@mui/icons-material'
5
-import { apiCall, useApiEx } from './api'
3
+import { CardMembership, Check, Dns, HomeWorkTwoTone, Lock, Public, PublicTwoTone, RouterTwoTone, Send, Storage,
4
+ Error as ErrorIcon, SvgIconComponent } from '@mui/icons-material'
5
+import { apiCall, useApiEvents, useApiEx } from './api'
6
import { closeDialog, DAY, formatTimestamp, wait, wantArray, with_, PORT_DISABLED, isIP, CFG,
7
- useRequestRender } from './misc'
7
+ useRequestRender, replace, restartAnimation, prefix } from './misc'
8
import { Flex, LinkBtn, Btn, Country } from './mui'
9
-import { alertDialog, confirmDialog, promptDialog, toast, waitDialog } from './dialog'
9
+import { alertDialog, confirmDialog, formDialog, promptDialog, toast, waitDialog } from './dialog'
10
import { BoolField, Form, MultiSelectField, NumberField, SelectField } from '@hfs/mui-grid-form'
11
import md from './md'
12
import { suggestMakingCert } from './OptionsPage'
@@ -48,8 +48,59 @@ export default function InternetPage() {
48
networkBox(),
49
httpsBox(),
50
geoBox(),
51
+ ddnsBox(),
52
)
53
54
+ function ddnsBox() {
55
+ const { data } = useApiEvents('get_dynamic_dns_error')
56
+ const ref = useRef<any>()
57
+ useEffect(() => ref.current && restartAnimation(ref.current, '1s blink'), [data]);
58
+ return h(TitleCard, { icon: Dns, title: "Dynamic DNS updater" },
59
+ data && h(Flex, {},
60
+ data.error ? h(ErrorIcon, { color: 'error', ref }) : h(Check, { color: 'success', ref }),
61
+ formatTimestamp(data.ts), ' – ',
62
+ prefix("Error: ", data.error) || "Updated successfully",
63
+ ),
64
+ "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 in case you are more keen to it.",
65
+ h(ConfigForm<{
66
+ [CFG.dynamic_dns_url]: string,
67
+ }>, {
68
+ keys: [CFG.dynamic_dns_url],
69
+ form: (v, { setValues }) => ({
70
+ fields: [
71
+ h(Flex, {},
72
+ _.map({
73
+ NoIP: {
74
+ url: 'https://$username:$password@dynupdate.no-ip.com/nic/update?hostname=$domain',
75
+ fields: ['username', 'password', 'domain'],
76
+ },
77
+ DuckDNS: {
78
+ url: 'https://www.duckdns.org/update/$domain/$token>OK',
79
+ fields: [{ k: 'domain', helperText: "do NOT include the .duckdns.org part" }, 'token'],
80
+ }
81
+ }, ({ url, fields }, label) =>
82
+ h(Btn, {
83
+ key: url,
84
+ onClick: () => formDialog({
85
+ title: label + " wizard",
86
+ form: {
87
+ maxWidth: '20em',
88
+ before: h(Box, { mb: 1 }, "Following information is stored non-encrypted"),
89
+ fields: fields.map(k => _.isString(k) ? { k } : k)
90
+ }
91
+ }).then(symbols => symbols && setValues({ [CFG.dynamic_dns_url]: replace(url, symbols as any, '$') }))
92
+ }, label + " wizard")
93
+ )
94
+ ),
95
+ { k: CFG.dynamic_dns_url, label: "Updater URL",
96
+ 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."
97
+ },
98
+ ]
99
+ })
100
+ })
101
+ )
102
+ }
103
+
104
function geoBox() {
105
const countryOptions = useMemo(() => _.sortBy(COUNTRIES, 'name').map(x => ({
106
value: x.code,
@@ -208,7 +259,7 @@ export default function InternetPage() {
259
h(Device, { name: "Server", icon: direct ? Storage : HomeWorkTwoTone, color: localColor, ip: data?.localIp,
260
below: port && h(Box, { fontSize: 'smaller' }, "port ", port),
261
}),
211
- !direct && h(Sep),
262
+ !direct && h(DataLine),
263
!direct && h(Device, {
264
name: "Router", icon: RouterTwoTone, ip: data?.gatewayIp,
265
color: data?.mapped && (wrongMap ? 'warning' : 'success'),
@@ -216,7 +267,7 @@ export default function InternetPage() {
267
: h(LinkBtn, { fontSize: 'smaller', display: 'block', onClick: configure },
268
"port ", wrongMap ? 'is wrong' : data?.externalPort || "unknown"),
269
}),
219
- h(Sep),
270
+ h(DataLine),
271
h(Device, { name: "Internet", icon: PublicTwoTone, ip: data?.publicIps,
272
color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined,
273
below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller' },
@@ -337,7 +388,7 @@ export default function InternetPage() {
388
}
389
}
390
340
-function Sep() {
391
+function DataLine() {
392
return h(Box, { flex: 1, className: 'animated-dashed-line' })
393
}
394
admin/src/index.scss
+6
-1
@@ -62,4 +62,9 @@ h2.MuiDialogTitle-root { /* less padding */
62
background-size: 10px 100%; /* 20px is the length of each dash + gap */
63
animation: animate-dash 1s alternate linear infinite;
64
}
65
-@keyframes animate-dash { to { background-position: 20px 0; } }
\ No newline at end of file
65
+@keyframes animate-dash { to { background-position: 20px 0; } }
66
+
67
+@keyframes blink {
68
+ 0% {opacity: 1}
69
+ 50% {opacity: 0.2}
70
+}
admin/src/mui.ts
+4
-3
@@ -12,6 +12,7 @@ import { Promisable, StringField } from '@hfs/mui-grid-form'
12
import { alertDialog, confirmDialog, toast } from './dialog'
13
import { LoadingButton, LoadingButtonProps } from '@mui/lab'
14
import { Link as RouterLink } from 'react-router-dom'
15
+import { SvgIconProps } from '@mui/material/SvgIcon/SvgIcon'
16
import _ from 'lodash'
17
import { ALL as COUNTRIES } from './countries'
18
import { apiCall } from '@hfs/shared/api'
@@ -147,7 +148,7 @@ interface BtnProps extends Omit<LoadingButtonProps,'disabled'|'title'|'onClick'>
148
labelFrom?: Breakpoint
149
doneMessage?: boolean | string // displayed only if the result of onClick !== false
150
tooltipProps?: TooltipProps
150
- onClick: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
151
+ onClick?: (...args: Parameters<NonNullable<ButtonProps['onClick']>>) => Promisable<any>
152
}
153
export function Btn({ icon, title, onClick, disabled, progress, link, tooltipProps, confirm, doneMessage, labelFrom, children, ...rest }: BtnProps) {
154
const [loading, setLoading] = useStateMounted(false)
@@ -199,8 +200,8 @@ function execDoneMessage(msg: boolean | string | undefined) {
200
toast(msg === true ? "Operation completed" : msg, 'success')
201
}
202
202
-export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxProps) {
203
- return h(Tooltip, { title: tooltip, children: h(icon, { sx }) })
203
+export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxProps, props?: SvgIconProps) {
204
+ return h(Tooltip, { title: tooltip, children: h(icon, { sx, ...props }) })
205
}
206
207
export function InLink(props:any) {
config.md
+1
@@ -107,6 +107,7 @@ Configuration can be done in several ways
107
- `geo_allow` set true if `geo_list` should be treated as white-list, set false for black-list. Default will ignore the list.
108
- `geo_list` list of country codes to be used as white-list or black-list. Default is empty.
109
- `geo_allow_unknown` set false to disconnect connections for which country cannot be determined. Works only if `geo_allow` is set. Default is true.
110
+- `dynamic_dns_url` URL to be requested to keep a domain updated with your latest IP address. Optionally, you can append “>” followed by a regular expression to determine a successful answer, otherwise status code will be used.
111
- `create-admin` special entry to quickly create an admin account. The value will be set as password. As soon as the account is created, this entry is removed.
112
113
#### Virtual File System (VFS)
src/adminApis.ts
+2
@@ -39,6 +39,7 @@ import { getErrorSections } from './errorPages'
39
import { ip2country } from './geo'
40
import { roots } from './roots'
41
import { SendListReadable } from './SendList'
42
+import { get_dynamic_dns_error } from './ddns'
43
44
export const adminApis: ApiHandlers = {
45
@@ -48,6 +49,7 @@ export const adminApis: ApiHandlers = {
49
...monitorApis,
50
...langApis,
51
...netApis,
52
+ get_dynamic_dns_error,
53
54
async set_config({ values }) {
55
apiAssertTypes({ object: { values } })
src/api.net.ts
+2
-2
@@ -9,7 +9,7 @@ import { getProjectInfo } from './github'
9
import { apiAssertTypes, onlyTruthy, promiseBestEffort } from './misc'
10
import { lookup, Resolver } from 'dns/promises'
11
import { isIPv6 } from 'net'
12
-import { getNatInfo, upnpClient } from './nat'
12
+import { getNatInfo, getPublicIps, upnpClient } from './nat'
13
import { makeCert } from './acme'
14
import { selfCheck } from './selfCheck'
15
@@ -32,7 +32,7 @@ const apis: ApiHandlers = {
32
const domainIps = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat())
33
if (!domainIps.length)
34
throw new ApiError(HTTP_FAILED_DEPENDENCY, "domain not working")
35
- const { publicIps } = await getNatInfo() // do this before stopping the server
35
+ const publicIps = await getPublicIps() // do this before stopping the server
36
for (const v6 of [false, true]) {
37
const domainIpsThisVersion = domainIps.filter(x => isIPv6(x) === v6)
38
const ipsThisVersion = publicIps.filter(x => isIPv6(x) === v6)
src/cross.ts
+15
-4
@@ -22,7 +22,7 @@ export const FRONTEND_OPTIONS = {
22
}
23
export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time']
24
export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
25
-export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown',
25
+export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
26
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua',
27
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'roots_mandatory'])
28
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
@@ -325,9 +325,13 @@ export async function asyncGeneratorToArray<T>(generator: AsyncIterable<T>): Pro
325
return ret
326
}
327
328
-export function repeat(every: number, cb: () => unknown): Promise<ReturnType<typeof setTimeout>>{
329
- return Promise.allSettled([cb()]).then(() =>
330
- setTimeout(() => repeat(every, cb), every) )
328
+export function repeat(everyMs: number, cb: Callback): Callback {
329
+ let stop = false
330
+ setTimeout(async () => {
331
+ while (!stop && await Promise.allSettled([cb()]))
332
+ await wait(everyMs)
333
+ })
334
+ return () => stop = true
335
}
336
337
export function formatTimestamp(x: string) {
@@ -417,6 +421,13 @@ export function matches(s: string, mask: string, emptyMaskReturns=false) {
421
return makeMatcher(mask, emptyMaskReturns)(s) // adding () will allow us to use the pipe at root level
422
}
423
424
+export function replace(s: string, symbols: Dict<string | Callback<string>>, delimiter='') {
425
+ const [open, close] = splitAt(' ', delimiter)
426
+ for (const [k, v] of typedEntries(symbols))
427
+ s = s.replace(open + k + close, _.isFunction(v) ? v() : v)
428
+ return s
429
+}
430
+
431
export function shortenAgent(agent: string) {
432
return _.findKey(BROWSERS, re => re.test(agent))
433
|| /^[^/(]+ ?/.exec(agent)?.[0]
src/ddns.ts
new
+47
@@ -0,0 +1,47 @@
1
+import { defineConfig } from './config'
2
+import { Callback, CFG, HOUR, repeat, replace, splitAt } from './cross'
3
+import _ from 'lodash'
4
+import { httpWithBody } from './util-http'
5
+import { isIPv4 } from 'node:net'
6
+import { isIPv6 } from 'net'
7
+import { VERSION } from './const'
8
+import events from './events'
9
+import { once } from 'stream'
10
+import { getPublicIps } from './nat'
11
+
12
+// optionally you can append '>' and a regular expression to determine what body is considered successful
13
+const dynamicDnsUrl = defineConfig(CFG.dynamic_dns_url, '')
14
+
15
+let stop: Callback | undefined
16
+let last: undefined | { ts: Date, error: string, url: string }
17
+dynamicDnsUrl.sub(v => {
18
+ stop?.()
19
+ if (!v) return
20
+ let lastIps: any
21
+ const [templateUrl, re] = splitAt('>', v)
22
+ stop = repeat(HOUR, async () => {
23
+ const ips = await getPublicIps()
24
+ if (_.isEqual(lastIps, ips)) return
25
+ lastIps = ips
26
+ const url = replace(templateUrl, {
27
+ IPX: ips[0] || '',
28
+ IP4: _.find(ips, isIPv4) || '',
29
+ IP6: _.find(ips, isIPv6) || '',
30
+ }, '$')
31
+ const error = await httpWithBody(url, { httpThrow: false, headers: { 'User-Agent': "HFS/" + VERSION } }) // UA specified as requested by no-ip guidelines
32
+ .then(async res => {
33
+ const str = String(res.body).trim()
34
+ return (re ? str.match(re) : res.ok) ? '' : (str || res.statusMessage)
35
+ }, (err: any) => err.code || err.message || String(err) )
36
+ last = { ts: new Date(), error, url }
37
+ events.emit('dynamicDnsError', last)
38
+ console.log('dynamic dns update', error || 'ok')
39
+ })
40
+})
41
+
42
+export async function* get_dynamic_dns_error() {
43
+ while (1) {
44
+ yield last
45
+ await once(events, 'dynamicDnsError')
46
+ }
47
+}
\ No newline at end of file
src/misc.ts
+3
@@ -105,6 +105,9 @@ export function asyncGeneratorToReadable<T>(generator: AsyncIterable<T>) {
105
const iterator = generator[Symbol.asyncIterator]()
106
return new Readable({
107
objectMode: true,
108
+ destroy() {
109
+ iterator.return?.()
110
+ },
111
read() {
112
iterator.next().then(it =>
113
this.push(it.done ? null : it.value))
src/nat.ts
+2
-6
@@ -4,7 +4,7 @@ import { debounceAsync } from './debounceAsync'
4
import { haveTimeout, HOUR, MINUTE, promiseBestEffort, repeat, wantArray } from './cross'
5
import { getProjectInfo } from './github'
6
import _ from 'lodash'
7
-import { httpString } from './util-http'
7
+import { httpString } from './util-http'
8
import { Resolver } from 'dns/promises'
9
import { isIP } from 'net'
10
import { baseUrl, getIps, getServerStatus } from './listen'
@@ -23,10 +23,6 @@ export const defaultBaseUrl = proxy({
23
}
24
})
25
26
-export function getBaseUrlOrDefault() {
27
- return baseUrl.get() || defaultBaseUrl.get()
28
-}
29
-
26
export const upnpClient = new Client({ timeout: 4_000 })
27
const originalMethod = upnpClient.getGateway
28
// other client methods call getGateway too, so this will ensure they reuse this same result
@@ -42,7 +38,7 @@ repeat(10 * MINUTE, () => upnpClient.getPublicIp().then(v => {
38
return defaultBaseUrl.externalIp = v
39
}))
40
45
-const getPublicIps = debounceAsync(async () => {
41
+export const getPublicIps = debounceAsync(async () => {
42
const res = await getProjectInfo()
43
const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4))
44
const ips = await promiseBestEffort(groupedByVersion.map(singleVersion =>
src/util-http.ts
+14
-8
@@ -4,15 +4,19 @@ import https, { RequestOptions } from 'node:https'
4
import http, { IncomingMessage } from 'node:http'
5
import { Readable } from 'node:stream'
6
import _ from 'lodash'
7
-import { text as stream2string } from 'node:stream/consumers'
7
+import { text as stream2string, buffer } from 'node:stream/consumers'
8
export { stream2string }
9
10
-// in case the response is not 2xx, it will throw and the error object is the Response object
10
export async function httpString(url: string, options?: XRequestOptions): Promise<string> {
12
- const res = await httpStream(url, options)
13
- if (!_.inRange(res.statusCode!, 200, 299))
14
- throw res
15
- return await stream2string(res)
11
+ return await stream2string(await httpStream(url, options))
12
+}
13
+
14
+export async function httpWithBody(url: string, options?: XRequestOptions): Promise<IncomingMessage & { ok: boolean, body: Buffer | undefined }> {
15
+ const req = await httpStream(url, options)
16
+ return Object.assign(req, {
17
+ ok: _.inRange(req.statusCode!, 200, 300),
18
+ body: req.statusCode ? await buffer(req) : undefined,
19
+ })
20
}
21
22
export interface XRequestOptions extends RequestOptions {
@@ -20,9 +24,11 @@ export interface XRequestOptions extends RequestOptions {
24
// basic cookie store
25
jar?: Record<string, string>
26
noRedirect?: boolean
27
+ // throw for http-level errors. Default is true.
28
+ httpThrow?: boolean
29
}
30
25
-export function httpStream(url: string, { body, jar, noRedirect, ...options }: XRequestOptions ={}): Promise<IncomingMessage> {
31
+export function httpStream(url: string, { body, jar, noRedirect, httpThrow, ...options }: XRequestOptions ={}): Promise<IncomingMessage> {
32
return new Promise((resolve, reject) => {
33
options.headers ??= {}
34
if (body) {
@@ -42,7 +48,7 @@ export function httpStream(url: string, { body, jar, noRedirect, ...options }: X
48
if (v) jar[k] = v
49
else delete jar[k]
50
}
45
- if (!res.statusCode || res.statusCode >= 400)
51
+ if (!res.statusCode || (httpThrow ?? true) && res.statusCode >= 400)
52
return reject(new Error(String(res.statusCode), { cause: res }))
53
if (res.headers.location && !noRedirect)
54
return resolve(httpStream(res.headers.location, options))