automatically restore upnp port mapping #1034
Massimo Melina committed
Apr 29, 2026 at 11:30 UTC
ce3e335287f4904c05d2e085560895e1f945c9a5
4 files changed
+29
-10
src/acme.ts
+4
-4
@@ -3,7 +3,7 @@ import {
3
} from './misc'
4
import { createServer, IncomingMessage, ServerResponse } from 'http'
5
import { Middleware } from 'koa'
6
-import { getNatInfo, upnpClient } from './nat'
6
+import { getNatInfo, upnpClient, upnpMappingParam } from './nat'
7
import { cert, getCertObject, getServerStatus, privateKey } from './listen'
8
import { ApiError } from './apiMiddleware'
9
import acme from 'acme-client'
@@ -29,8 +29,9 @@ export const acmeMiddleware: Middleware = (ctx, next) => { // koa format
29
return next()
30
}
31
32
-const TEMP_MAP = { private: 80, public: { host: '', port: 80 }, description: 'hfs temporary', ttl: 5000 } // from my tests (zyxel VMG8825), lower values won't make a working mapping
32
+const TEMP_MAP = upnpMappingParam(80, 80, 'hfs temporary', 5000) // from my tests (zyxel VMG8825), lower values won't make a working mapping
33
34
+// remove temporary port mapping, if any is left from previous execution
35
repeat(MINUTE, async stop => {
36
await upnpClient.getGateway() // without this, the next call will break upnp support
37
const res = await upnpClient.getMappings()
@@ -61,7 +62,7 @@ async function generateSSLCert(domain: string, email?: string, altNames?: string
62
let check = await selfCheck(checkUrl) // some check services may not consider the domain, but we already verified that
63
if (check?.success === false && nat.upnp && !nat.mapped80) {
64
console.debug("Setting temporary port forward")
64
- tempMap = await haveTimeout(10_000, upnpClient.createMapping(TEMP_MAP).catch(() => {})).catch(() => {})
65
+ tempMap = await haveTimeout(10_000, upnpClient.createMapping(TEMP_MAP)).catch(() => {})
66
check = await selfCheck(checkUrl) // repeat test
67
}
68
//if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
@@ -131,4 +132,3 @@ const renewCert = debounceAsync(async () => {
132
await makeCert(domain, undefined, altNames)
133
.catch(e => console.log(acmeRenewError = `Error renewing certificate, expiring ${formatDate(validTo)}: ${String(e.message || e)}`))
134
}, { retain: DAY, retainFailure: HOUR })
134
-
src/api.net.ts
+4
-3
@@ -10,7 +10,7 @@ import { getProjectInfo } from './github'
10
import { apiAssertTypes, onlyTruthy, promiseBestEffort } from './misc'
11
import { lookup, Resolver } from 'dns/promises'
12
import { isIPv6 } from 'net'
13
-import { getNatInfo, getPublicIps, upnpClient } from './nat'
13
+import { createUpnpMapping, getNatInfo, getPublicIps, mappedPort, upnpClient } from './nat'
14
import { makeCert } from './acme'
15
import { selfCheck } from './selfCheck'
16
@@ -54,11 +54,12 @@ export default {
54
if (externalPort)
55
try { await upnpClient.removeMapping({ public: { host: '', port: externalPort } }) }
56
catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, "removeMapping failed: " + String(e) ) }
57
- if (external) // must use the object form of 'public' to work around a bug of the library
58
- await upnpClient.createMapping({ private: internal || internalPort, public: { host: '', port: external }, description: 'hfs', ttl: 0 })
57
+ if (external)
58
+ await createUpnpMapping(internal || internalPort, external)
59
.catch(res => {
60
throw new ApiError(res.errorCode || HTTP_SERVER_ERROR, res.errorCode === 718 ? "Port not available" : res.errorDescription || res.message || "unknown error")
61
})
62
+ mappedPort.set(external || 0) // remember only successful HFS mappings
63
return {}
64
},
65
src/cross.ts
+1
-1
@@ -33,7 +33,7 @@ export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_u
33
'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
34
'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
35
'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html', 'comments_storage',
36
- 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy'])
36
+ 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy', 'mapped_port'])
37
export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
38
export type Dict<T=any> = Record<string, T>
39
export type Falsy = false | null | undefined | '' | 0
src/nat.ts
+20
-2
@@ -10,6 +10,7 @@ import { isIP } from 'net'
10
import { getIps, getServerStatus } from './listen'
11
import { exec } from 'child_process'
12
import { IS_MAC, IS_WINDOWS } from './const'
13
+import { defineConfig } from './config'
14
15
export const defaultBaseUrl = proxy({
16
proto: 'http',
@@ -26,6 +27,8 @@ export const defaultBaseUrl = proxy({
27
}
28
})
29
30
+export const mappedPort = defineConfig('mapped_port', 0)
31
+
32
export const upnpClient = new Client({ timeout: 4_000 })
33
const originalMethod = upnpClient.getGateway
34
// other client methods call getGateway too, so this will ensure they reuse this same result
@@ -41,6 +44,15 @@ repeat(MINUTE, () => upnpClient.getPublicIp().then(v => {
44
return defaultBaseUrl.externalIp = v
45
}, () => {}))
46
47
+export function upnpMappingParam(privatePort: number, publicPort: number, description='hfs', ttl=0) {
48
+ // nat-upnp-rejetto requires the object form of `public` to preserve the host field correctly
49
+ return { private: privatePort, public: { host: '', port: publicPort }, description, ttl }
50
+}
51
+
52
+export function createUpnpMapping(...args: Parameters<typeof upnpMappingParam>) {
53
+ return upnpClient.createMapping(upnpMappingParam(...args))
54
+}
55
+
56
export const getPublicIps = debounceAsync(async () => {
57
const res = await getProjectInfo()
58
const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4))
@@ -73,13 +85,19 @@ export const getNatInfo = debounceAsync(async () => {
85
const gatewayIpPromise = findGateway().catch(() => undefined)
86
const res = await haveTimeout(10_000, upnpClient.getGateway()).catch(() => null)
87
const status = await getServerStatus()
76
- const mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
88
+ let mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
89
console.debug("Mappings found:", mappings?.map(x => x.description).join(', ') || "none")
90
const localIps = await getIps(false)
91
const gatewayIp = await gatewayIpPromise
92
const localIp = res?.address || (gatewayIp ? _.maxBy(localIps, x => inCommon(x, gatewayIp)) : localIps[0])
93
const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined
82
- const mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
94
+ let mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
95
+ if (mappings && localIp && internalPort && !mapped && mappedPort.get())
96
+ // restore the HFS-created mapping after routers that forget UPnP state across reboots
97
+ await haveTimeout(5_000, createUpnpMapping(internalPort, mappedPort.get())).then(async () => {
98
+ mappings = await haveTimeout(5_000, upnpClient.getMappings()) // confirm router state after restore
99
+ mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
100
+ }).catch(e => console.warn('UPnP mapping restore failed:', e?.message || String(e)))
101
const externalPort = mapped?.public.port
102
if (localIp)
103
defaultBaseUrl.localIp = localIp