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) {