better ipv6 support
Massimo Melina committed
Sep 29, 2023 at 18:32 UTC
4381fbaf5c4af8aa2ff8375caad11dc78966e4a0
8 files changed
+92
-39
README.md
+1
-1
@@ -49,7 +49,7 @@ This is a full rewrite of [the Delphi version](https://github.com/rejetto/hfs2).
49
50
## Installation
51
52
-NB: minimum Windows version required is 8.1 , Windows Server 2012 R2 (because of Node.js 16)
52
+NB: minimum Windows version required is 8.1 , Windows Server 2012 R2 (because of Node.js 18)
53
54
1. go to https://github.com/rejetto/hfs/releases
55
2. click on `Assets`
admin/src/InternetPage.ts
+18
-14
@@ -2,7 +2,7 @@ import { createElement as h, useEffect, useState } from 'react'
2
import { Alert, Box, Button, Card, CardContent, CircularProgress, Divider, LinearProgress, Link } from '@mui/material'
3
import { CardMembership, HomeWorkTwoTone, Lock, PublicTwoTone, RouterTwoTone, Send } from '@mui/icons-material'
4
import { apiCall, useApiEx } from './api'
5
-import { closeDialog, DAY, formatTimestamp, with_ } from '@hfs/shared'
5
+import { closeDialog, DAY, formatTimestamp, GetNat, onlyTruthy, wantArray, with_ } from '@hfs/shared'
6
import { Flex, LinkBtn, manipulateConfig, isIP, Btn } from './misc'
7
import { alertDialog, confirmDialog, promptDialog, toast } from './dialog'
8
import { BoolField, Form, NumberField } from '@hfs/mui-grid-form'
@@ -22,10 +22,11 @@ export default function InternetPage() {
22
const status = useApiEx('get_status')
23
const localColor = with_([status.data?.http?.error, status.data?.https?.error], ([h, s]) =>
24
h && s ? 'error' : h || s ? 'warning' : 'success')
25
- const { data: nat, reload: reloadNat, error, loading, element } = useApiEx('get_nat')
25
+ const { data: nat, reload: reloadNat, error, loading, element } = useApiEx<GetNat>('get_nat')
26
const port = nat?.internalPort
27
- const wrongMap = nat?.mapped && nat.mapped.private.port !== port
28
- const doubleNat = nat?.externalIp && nat.externalIp !== nat.publicIp
27
+ const publicIps = onlyTruthy([nat?.publicIp4, nat?.publicIp6])
28
+ const wrongMap = nat?.mapped && nat.mapped.private.port !== port && nat.mapped.private.port
29
+ const doubleNat = nat?.externalIp && !publicIps.includes(nat.externalIp)
30
useEffect(() => {
31
if (!verifyAgain || !nat || loading) return
32
verify().then()
@@ -133,19 +134,20 @@ export default function InternetPage() {
134
"port ", wrongMap ? 'is wrong' : nat?.externalPort || "unknown"),
135
}),
136
h(Sep),
136
- h(Device, { name: "Internet", icon: PublicTwoTone, ip: nat?.publicIp,
137
+ h(Device, { name: "Internet", icon: PublicTwoTone, ip: publicIps,
138
color: checkResult ? 'success' : checkResult === false ? 'error' : doubleNat ? 'warning' : undefined,
139
below: checking ? h(LinearProgress, { sx: { height: '1em' } }) : h(Box, { fontSize: 'smaller' },
140
doubleNat && h(LinkBtn, { display: 'block', onClick: () => alertDialog(MSG_ISP, 'warning') }, "Double NAT"),
141
checkResult ? "Working!" : checkResult === false ? "Failed!" : '',
142
' ',
142
- nat?.publicIp && h(LinkBtn, { onClick: verify }, "Verify")
143
+ publicIps.length && nat.internalPort && h(LinkBtn, { onClick: verify }, "Verify")
144
)
145
}),
146
)
147
}
148
149
async function verify(): Promise<any> {
150
+ if (!nat) return // shut up ts
151
setCheckResult(undefined)
152
if (!verifyAgain && !await confirmDialog("This test will check if your server is working properly on the Internet")) return
153
setChecking(true)
@@ -162,7 +164,7 @@ export default function InternetPage() {
164
if (nat.upnp && !nat.mapped)
165
return confirmDialog(msg + "Try port-forwarding on your router", { confirmText: "Fix it" }).then(go => {
166
if (!go) return
165
- try { mapPort(nat.internalPort, '', '') }
167
+ try { mapPort(nat.internalPort!, '', '') }
168
catch { mapPort(HIGHER_PORT, '') }
169
toast("Port forwarded, now verify again", 'success')
170
retry()
@@ -170,7 +172,7 @@ export default function InternetPage() {
172
const { close } = alertDialog(h(Box, {}, msg + "Possible causes:", h('ul', {},
173
!nat.upnp && h('li', {}, "Your router may need to be configured. ", h(Link, { href: PORT_FORWARD_URL, target: 'help' }, "How?")),
174
h('li', {}, "There could be a firewall, try configuring or disabling it."),
173
- nat.externalPort <= 1024 && h('li', {},
175
+ (nat.externalPort || nat.internalPort!) <= 1024 && h('li', {},
176
"Your Internet Provider may be blocking ports under 1024. ",
177
nat.upnp && h(Button, { size: 'small', onClick() { close(); mapPort(HIGHER_PORT).then(retry) } }, "Try " + HIGHER_PORT) ),
178
nat.mapped && h('li', {}, "A bug in your modem/router, try rebooting it."),
@@ -190,15 +192,16 @@ export default function InternetPage() {
192
}
193
194
async function configure() {
195
+ if (!nat) return // shut up ts
196
if (wrongMap)
194
- return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${nat.mapped.private.port})`, { confirmText: "Fix it" })
197
+ return await confirmDialog(`There is a port-forwarding but it is pointing to the wrong port (${wrongMap})`, { confirmText: "Fix it" })
198
&& fixPort()
196
- if (!nat?.upnp)
199
+ if (!nat.upnp)
200
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')
201
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.`), {
199
- value: nat?.externalPort || port,
202
+ value: nat.externalPort || port,
203
field: { label: "Port seen from the Internet", comp: NumberField },
201
- addToBar: nat?.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")],
204
+ addToBar: nat.mapped && [h(Button, { color: 'warning', onClick: remove }, "Remove")],
205
dialogProps: { sx: { maxWidth: '20em' } },
206
})
207
if (res)
@@ -211,6 +214,7 @@ export default function InternetPage() {
214
}
215
216
function fixPort() {
217
+ if (!nat?.externalPort) return alertDialog("externalPort not found", 'error')
218
return mapPort(nat.externalPort, "Forwarding corrected")
219
}
220
@@ -223,7 +227,7 @@ export default function InternetPage() {
227
}
228
catch(e) {
229
if (errMsg) {
226
- const msg = errMsg + (external && Math.min(external, nat.internalPort) ? ". Some routers refuse to work with ports under 1024." : '')
230
+ const msg = errMsg + (external && Math.min(external, nat!.internalPort!) ? ". Some routers refuse to work with ports under 1024." : '')
231
await alertDialog(msg, 'error')
232
}
233
throw e
@@ -243,7 +247,7 @@ function Device({ name, icon, color, ip, below }: any) {
247
return h(Box, { display: 'inline-block', textAlign: 'center' },
248
h(icon, { color, sx: { fontSize, mb: '-0.1em' } }),
249
h(Box, { fontSize: 'larger' }, name),
246
- h(Box, { fontSize: 'smaller' }, ip || "unknown"),
250
+ h(Box, { fontSize: 'smaller', whiteSpace: 'pre-wrap' }, wantArray(ip).join('\n') || "unknown"),
251
below,
252
)
253
}
\ No newline at end of file
central.json
+9
-3
@@ -21,8 +21,14 @@
21
}
22
],
23
"publicIpServices": [
24
- "https://checkip.amazonaws.com",
25
- "https://ipinfo.io/ip",
26
- "https://ifconfig.io/ip"
24
+ "http://checkip.amazonaws.com",
25
+ "http://ipinfo.io/ip"
26
+ ],
27
+ "publicIpServices_049": [
28
+ { "url": "http://ipv6.icanhazip.com", "v": 6 },
29
+ { "url": "http://ifconfig.io/ip", "v": 6 },
30
+ { "url": "http://checkip.amazonaws.com" },
31
+ { "url": "http://ipinfo.io/ip" },
32
+ { "url": "http://ipv4.icanhazip.com", "v": 4 }
33
]
34
}
src/api.net.ts
+31
-15
@@ -13,7 +13,7 @@ import { cert, getCertObject, getIps, getServerStatus, privateKey } from './list
13
import { getProjectInfo } from './github'
14
import { httpString } from './util-http'
15
import { exec } from 'child_process'
16
-import { apiAssertTypes, DAY, debounceAsync, haveTimeout, HOUR, MINUTE, objSameKeys, onlyTruthy, repeat, Dict} from './misc'
16
+import { apiAssertTypes, DAY, debounceAsync, haveTimeout, HOUR, MINUTE, objSameKeys, onlyTruthy, repeat, Dict, isIp6, GetNat } from './misc'
17
import acme from 'acme-client'
18
import fs from 'fs/promises'
19
import { createServer, RequestListener } from 'http'
@@ -35,35 +35,46 @@ repeat(10 * MINUTE, () => {
35
})
36
37
const getNatInfo = debounceAsync(async () => {
38
- const gettingIp = getPublicIp() // don't wait, do it in parallel
38
+ const gettingIp4 = getPublicIp(4) // don't wait, do it in parallel
39
+ const gettingIp6 = getPublicIp(6)
40
const res = await upnpClient.getGateway().catch(() => null)
41
const status = await getServerStatus()
42
const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
43
console.debug('mappings found', mappings)
43
- const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => null)
44
+ const gatewayIp = res ? new URL(res.gateway.description).hostname : await findGateway().catch(() => undefined)
45
const localIp = res?.address || (await getIps())[0]
45
- const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port
46
+ const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined
47
const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
47
- return {
48
+ const ret: GetNat = { // i'd like to use 'satisfies' but my ide is not supporting it
49
upnp: Boolean(res),
50
localIp,
51
gatewayIp,
51
- publicIp: await gettingIp || await externalIp,
52
+ publicIp4: await gettingIp4,
53
+ publicIp6: await gettingIp6,
54
externalIp: await externalIp,
55
mapped,
56
internalPort,
57
externalPort: mapped?.public.port,
58
}
59
+ return ret
60
})
61
59
-async function getPublicIp() {
62
+async function getPublicIp(version?: number) {
63
const prjInfo = await getProjectInfo()
61
- for (const urls of _.chunk(_.shuffle(prjInfo.publicIpServices), 2)) // small parallelization
64
+ const services = prjInfo.publicIpServices_049?.filter((x: any) => !x.v || version === x.v)
65
+ for (const chunk of _.chunk(_.shuffle(services), 2)) // small parallelization
66
try {
63
- return await Promise.any(urls.map(url => httpString(url).then(res => {
67
+ return await Promise.any(chunk.map(service => httpString(service.url).then(res => {
68
+ if (service.after) {
69
+ const i = res.match(new RegExp(service.after, 'i'))?.index
70
+ if (i == null) throw Error("cannot match: " + res)
71
+ res = res.slice(i)
72
+ }
73
const ip = res.trim()
74
if (!/[.:0-9a-fA-F]/.test(ip))
75
throw Error("bad result: " + ip)
76
+ if (version && version === 6 !== isIp6(ip)) // enforce result format
77
+ throw Error("bad version: " + ip)
78
return ip
79
})))
80
}
@@ -105,12 +116,16 @@ async function checkDomain(domain: string) {
116
lookup(domain).then(x => [x.address]),
117
])
118
// merge all results
108
- const ips = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat())
109
- if (!ips.length)
119
+ const domainIps = _.uniq(onlyTruthy(settled.map(x => x.status === 'fulfilled' && x.value)).flat())
120
+ if (!domainIps.length)
121
throw new ApiError(HTTP_FAILED_DEPENDENCY, "domain not working")
111
- const { publicIp } = await getNatInfo() // do this before stopping the server
112
- if (!ips.includes(publicIp))
113
- throw new ApiError(HTTP_FAILED_DEPENDENCY, `please configure your domain to point to ${publicIp} (currently on ${ips[0]}) --- a change can take hours to be effective`)
122
+ const { publicIp4, publicIp6 } = await getNatInfo() // do this before stopping the server
123
+ for (const v6 of [false, true]) {
124
+ const domainIpsThisVersion = domainIps.filter(x => isIp6(x) === v6)
125
+ const ipThisVersion = v6 ? publicIp6 : publicIp4
126
+ if (domainIpsThisVersion.length && ipThisVersion && !domainIpsThisVersion.includes(ipThisVersion))
127
+ throw new ApiError(HTTP_FAILED_DEPENDENCY, `please configure your domain to point to ${publicIp4} or (currently on ${domainIps[0]}) --- a change can take hours to be effective`)
128
+ }
129
}
130
131
async function generateSSLCert(domain: string, email?: string) {
@@ -221,7 +236,8 @@ const apis: ApiHandlers = {
236
},
237
238
async check_server({ port }) {
224
- const { publicIp, internalPort, externalPort } = await getNatInfo()
239
+ const { publicIp4, publicIp6, internalPort, externalPort } = await getNatInfo()
240
+ const publicIp = publicIp4 || publicIp6
241
if (!publicIp)
242
return new ApiError(HTTP_SERVICE_UNAVAILABLE, 'cannot detect public ip')
243
if (!internalPort)
src/cross.ts
+26
-1
@@ -15,6 +15,27 @@ type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
15
export type Callback<IN=void, OUT=void> = (x:IN) => OUT
16
export type Promisable<T> = T | Promise<T>
17
18
+interface Mapping {
19
+ public: { host: string; port: number }
20
+ private: { host: string; port: number }
21
+ protocol: string
22
+ enabled: boolean
23
+ description: string
24
+ ttl: number
25
+ local: boolean
26
+}
27
+export interface GetNat {
28
+ upnp: boolean,
29
+ localIp?: string
30
+ gatewayIp?: string
31
+ publicIp4?: string
32
+ publicIp6?: string
33
+ externalIp: string,
34
+ mapped?: Mapping
35
+ internalPort?: number
36
+ externalPort?: number
37
+}
38
+
39
const MULTIPLIERS = ['', 'K', 'M', 'G', 'T']
40
export function formatBytes(n: number, { post='B', k=1024, digits=NaN }={}) {
41
if (isNaN(Number(n)) || n < 0)
@@ -265,8 +286,12 @@ export function ipLocalHost(ip: string) {
286
return ip === '::1' || ip.endsWith('127.0.0.1')
287
}
288
289
+export function isIp6(ip: string) {
290
+ return ip.includes(':')
291
+}
292
+
293
export function ipForUrl(ip: string) {
269
- return ip.includes(':') ? '[' + ip + ']' : ip
294
+ return isIp6(ip) ? '[' + ip + ']' : ip
295
}
296
297
export function escapeHTML(text: string) {
src/github.ts
+3
-1
@@ -188,5 +188,7 @@ export async function searchPlugins(text='') {
188
const FN = 'central.json'
189
let builtIn = JSON.parse(readFileSync(join(__dirname, '..', FN), 'utf8'))
190
export const getProjectInfo = debounceAsync(
191
- () => readGithubFile(`${HFS_REPO}/${HFS_REPO_BRANCH}/${FN}`).then(JSON.parse, () => builtIn), // fall back to latest
191
+ () => readGithubFile(`${HFS_REPO}/${HFS_REPO_BRANCH}/${FN}`)
192
+ .then(JSON.parse, () => null)
193
+ .then(x => Object.assign(Object.create(builtIn), x) ), // fall back to built-in
194
0, { retain: DAY, retainFailure: 60_000 } )
\ No newline at end of file
src/listen.ts
+2
-2
@@ -8,7 +8,7 @@ import { watchLoad } from './watchLoad'
8
import { networkInterfaces } from 'os';
9
import { newConnection } from './connections'
10
import open from 'open'
11
-import { debounceAsync, ipForUrl, objSameKeys, onlyTruthy, wait, waitFor } from './misc'
11
+import { debounceAsync, ipForUrl, isIp6, objSameKeys, onlyTruthy, wait, waitFor } from './misc'
12
import { ADMIN_URI, argv, DEV, IS_WINDOWS } from './const'
13
import findProcess from 'find-process'
14
import { anyAccountCanLoginAdmin } from './adminApis'
@@ -249,7 +249,7 @@ export async function getIps(external=true) {
249
.filter((x,i,a) => a.length > 1 || !x.startsWith('169.254')) // 169.254 = dhcp failure on the interface, but keep it if it's our only one
250
251
function v4first(a: string[]) {
252
- return _.sortBy(a, x => x.includes(':'))
252
+ return _.sortBy(a, isIp6) // works because `false` comes first
253
}
254
}
255
src/misc.ts
+2
-2
@@ -15,7 +15,7 @@ import { matcher } from 'micromatch'
15
import { SocketAddress, BlockList } from 'node:net'
16
import { ApiError } from './apiMiddleware'
17
import { HTTP_BAD_REQUEST } from './const'
18
-import { ipLocalHost } from './cross'
18
+import { ipLocalHost, isIp6 } from './cross'
19
20
type ProcessExitHandler = (signal:string) => any
21
const cbs = new Set<ProcessExitHandler>()
@@ -89,7 +89,7 @@ export function makeNetMatcher(mask: string, emptyMaskReturns=false) {
89
}
90
91
function parseAddress(s: string) {
92
- return new SocketAddress({ address: s, family: s.includes(':') ? 'ipv6' : 'ipv4' })
92
+ return new SocketAddress({ address: s, family: isIp6(s) ? 'ipv6' : 'ipv4' })
93
}
94
95
export function makeMatcher(mask: string, emptyMaskReturns=false) {