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