admin/options: upnp can be disabled
Massimo Melina committed
Apr 30, 2026 at 12:20 UTC
35283c8af927cb1e88262b5b148e42309ed69c0a
7 files changed
+75
-43
admin/src/OptionsPage.ts
+5
-3
@@ -113,14 +113,17 @@ export default function OptionsPage() {
113
},
114
fields: [
115
h(Section, { title: "Networking" }),
116
- { k: 'port', comp: PortField, xs: 12, sm: 6, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
117
- { k: 'https_port', comp: PortField, xs: 12, sm: 6, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
116
+ { k: 'port', comp: PortField, xs: 12, sm: 4, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
117
+ { k: 'https_port', comp: PortField, xs: 12, sm: 4, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
118
onChange(v: number) {
119
if (v >= 0 && !httpsEnabled && !values.cert)
120
void suggestMakingCert()
121
return v
122
}
123
},
124
+ { k: CFG.upnp_enabled, comp: BoolField, xs: 12, sm: 4, label: "UPnP/SSDP",
125
+ helperText: "Port forwarding and double-NAT detection" },
126
+
127
httpsEnabled && { k: 'cert', comp: FileField, sm: 4, label: "HTTPS certificate file",
128
helperText: wikiLink('HTTPS#certificate', "What is this?"),
129
error: with_(status?.https.error, e => isCertError(e) && (
@@ -130,7 +133,6 @@ export default function OptionsPage() {
133
httpsEnabled && { k: 'private_key', comp: FileField, sm: 4, label: "HTTPS private key file",
134
...with_(status?.https.error, e => isKeyError(e) ? { error: true, helperText: e } : null)
135
},
133
-
136
httpsEnabled && { k: 'force_https', comp: BoolField, label: "Force HTTPS", sm: 4, disabled: !httpsEnabled || values.port < 0,
137
helperText: "Not applied to localhost. Doesn't work with proxies."
138
},
src/acme.ts
+10
-7
@@ -3,7 +3,7 @@ import {
3
} from './misc'
4
import { createServer, IncomingMessage, ServerResponse } from 'http'
5
import { Middleware } from 'koa'
6
-import { getNatInfo, upnpClient, upnpMappingParam } from './nat'
6
+import { getNatInfo, getUpnpClient, upnpEnabled, upnpMappingParam } from './nat'
7
import { cert, getCertObject, getServerStatus, privateKey } from './listen'
8
import { ApiError } from './apiMiddleware'
9
import acme from 'acme-client'
@@ -33,13 +33,16 @@ const TEMP_MAP = upnpMappingParam(80, 80, 'hfs temporary', 5000) // from my test
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()
36
+ if (!await upnpEnabled.getWhenReady())
37
+ return stop()
38
+ const client = getUpnpClient()
39
+ await client.getGateway() // without this, the next call will break upnp support
40
+ const res = await client.getMappings()
41
const leftover = res.find(x => x.description === TEMP_MAP.description) // in case the process is interrupted
42
if (!leftover) return void stop() // we are good
43
if (acmeOngoing) return // it doesn't count, as we are in the middle of something. Retry later
44
stop()
42
- return upnpClient.removeMapping(TEMP_MAP)
45
+ return client.removeMapping(TEMP_MAP)
46
})
47
48
async function generateSSLCert(domain: string, email?: string, altNames?: string[]) {
@@ -62,7 +65,7 @@ async function generateSSLCert(domain: string, email?: string, altNames?: string
65
let check = await selfCheck(checkUrl) // some check services may not consider the domain, but we already verified that
66
if (check?.success === false && nat.upnp && !nat.mapped80) {
67
console.debug("Setting temporary port forward")
65
- tempMap = await haveTimeout(10_000, upnpClient.createMapping(TEMP_MAP)).catch(() => {})
68
+ tempMap = await haveTimeout(10_000, getUpnpClient().createMapping(TEMP_MAP)).catch(() => {})
69
check = await selfCheck(checkUrl) // repeat test
70
}
71
//if (!check) throw new ApiError(HTTP_FAILED_DEPENDENCY, "couldn't test port 80")
@@ -87,9 +90,9 @@ async function generateSSLCert(domain: string, email?: string, altNames?: string
90
return { key, cert }
91
}
92
finally {
90
- if (tempMap) {
93
+ if (tempMap && upnpEnabled.get()) {
94
console.debug("Removing temporary port forward")
92
- upnpClient.removeMapping(TEMP_MAP).catch(() => {}) // clean after ourselves
95
+ getUpnpClient().removeMapping(TEMP_MAP).catch(() => {}) // clean after ourselves
96
}
97
acmeOngoing = false
98
if (tempSrv) await new Promise(res => tempSrv.close(res))
src/api.net.ts
+3
-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 { createUpnpMapping, getNatInfo, getPublicIps, mappedPort, upnpClient } from './nat'
13
+import { getNatInfo, getPublicIps, getUpnpClient, mappedPort, upnpMappingParam } from './nat'
14
import { makeCert } from './acme'
15
import { selfCheck } from './selfCheck'
16
@@ -52,10 +52,10 @@ export default {
52
if (!internalPort)
53
return new ApiError(HTTP_FAILED_DEPENDENCY, "no internal port")
54
if (externalPort)
55
- try { await upnpClient.removeMapping({ public: { host: '', port: externalPort } }) }
55
+ try { await getUpnpClient().removeMapping({ public: { host: '', port: externalPort } }) }
56
catch (e: any) { return new ApiError(HTTP_SERVER_ERROR, "removeMapping failed: " + String(e) ) }
57
if (external)
58
- await createUpnpMapping(internal || internalPort, external)
58
+ await getUpnpClient().createMapping(upnpMappingParam(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
})
src/config.ts
+5
-1
@@ -66,6 +66,10 @@ export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler
66
get(): T {
67
return getConfig(k)
68
},
69
+ async getWhenReady() {
70
+ await configReady
71
+ return this.get()
72
+ },
73
sub(cb: Subscriber<T>) {
74
if (started) // initial event already passed, we'll make the first call
75
cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled(), object })
@@ -224,7 +228,7 @@ export function subMultipleConfigs(cb: () => any, configs: Array<ReturnType<type
228
}
229
230
export const showHelp = argv.help
227
-export const configReady = events.once('configReady') // the boolean value means startedWithoutConfig
231
+export const configReady = events.once('configReady').then(x => x[0] as Boolean) // the value is startedWithoutConfig. The .then also avoids exposing the cancel-subscription function.
232
configReady.then(() => {
233
if (!showHelp) return
234
console.log(`HELP
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', 'mapped_port'])
36
+ 'force_webdav_login', 'webdav_initial_auth', 'outbound_proxy', 'mapped_port', 'upnp_enabled'])
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
+50
-27
@@ -1,7 +1,7 @@
1
import { proxy } from 'valtio'
2
import { Client } from '@rejetto/nat-upnp'
3
import { debounceAsync } from './debounceAsync'
4
-import { haveTimeout, HOUR, inCommon, ipForUrl, MINUTE, promiseBestEffort, repeat, wantArray } from './cross'
4
+import { CFG, haveTimeout, HOUR, inCommon, ipForUrl, MINUTE, promiseBestEffort, repeat, wantArray } from './cross'
5
import { getProjectInfo } from './github'
6
import _ from 'lodash'
7
import { httpStream, httpString } from './util-http'
@@ -10,7 +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'
13
+import { configReady, defineConfig } from './config'
14
15
export const defaultBaseUrl = proxy({
16
proto: 'http',
@@ -28,31 +28,24 @@ export const defaultBaseUrl = proxy({
28
})
29
30
export const mappedPort = defineConfig('mapped_port', 0)
31
+export const upnpEnabled = defineConfig(CFG.upnp_enabled, true)
32
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
35
-upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), { retain: HOUR, retainFailure: 30_000 })
36
-upnpClient.getGateway().then(res => {
37
- console.log("UPnP found", res.gateway.description)
38
-}, e => console.debug('UPnP failed:', e.message || String(e)))
33
+let upnpClient: Client | undefined
34
40
-// poll external ip – asking the modem is cheap, so it can be done often
41
-repeat(MINUTE, () => upnpClient.getPublicIp().then(v => {
42
- if (v === defaultBaseUrl.externalIp) return
35
+// poll external ip – when UPnP is enabled, asking the modem is inexpensive, so it can be done often
36
+repeat(MINUTE, async () => {
37
+ if (!await upnpEnabled.getWhenReady()) return
38
+ const v = await getUpnpClient().getPublicIp().catch(() => '')
39
+ if (!v || v === defaultBaseUrl.externalIp) return
40
getPublicIps.clearRetain()
44
- return defaultBaseUrl.externalIp = v
45
-}, () => {}))
41
+ defaultBaseUrl.externalIp = v
42
+})
43
44
export function upnpMappingParam(privatePort: number, publicPort: number, description='hfs', ttl=0) {
45
// nat-upnp-rejetto requires the object form of `public` to preserve the host field correctly
46
return { private: privatePort, public: { host: '', port: publicPort }, description, ttl }
47
}
48
52
-export function createUpnpMapping(...args: Parameters<typeof upnpMappingParam>) {
53
- return upnpClient.createMapping(upnpMappingParam(...args))
54
-}
55
-
49
export const getPublicIps = debounceAsync(async () => {
50
const res = await getProjectInfo()
51
const groupedByVersion = Object.values(_.groupBy(res.publicIpServices, x => x.v ?? 4))
@@ -82,20 +75,23 @@ export const getPublicIps = debounceAsync(async () => {
75
}, { retain: 10 * MINUTE })
76
77
export const getNatInfo = debounceAsync(async () => {
78
+ const upnp = await upnpEnabled.getWhenReady() ? getUpnpClient() : null
79
const gatewayIpPromise = findGateway().catch(() => undefined)
86
- const res = await haveTimeout(10_000, upnpClient.getGateway()).catch(() => null)
80
+ const gw = upnp && await haveTimeout(10_000, upnp.getGateway()).catch(() => null)
81
const status = await getServerStatus()
88
- let mappings = res && await haveTimeout(5_000, upnpClient.getMappings()).catch(() => null)
89
- console.debug("Mappings found:", mappings?.map(x => x.description).join(', ') || "none")
82
+ let mappings = gw && await haveTimeout(5_000, upnp.getMappings())?.catch(() => null)
83
+ console.debug(gw ? "Mappings found:" : "Mappings not queried:",
84
+ mappings?.map(x => x.description).join(', ') || (gw ? "none" : upnp ? "gateway not found" : "UPnP disabled") )
85
const localIps = await getIps(false)
86
const gatewayIp = await gatewayIpPromise
92
- const localIp = res?.address || (gatewayIp ? _.maxBy(localIps, x => inCommon(x, gatewayIp)) : localIps[0])
87
+ const localIp = gw?.address || (gatewayIp ? _.maxBy(localIps, x => inCommon(x, gatewayIp)) : localIps[0])
88
const internalPort = status?.https?.listening && status.https.port || status?.http?.listening && status.http.port || undefined
89
let mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
95
- if (mappings && localIp && internalPort && !mapped && mappedPort.get())
90
+ if (upnp && mappings && localIp && internalPort && !mapped && mappedPort.get())
91
// 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
92
+ await haveTimeout(5_000, upnp!.createMapping(upnpMappingParam(internalPort, mappedPort.get()))).then(async () => {
93
+ // confirm router state after restore instead of trusting the AddPortMapping result
94
+ mappings = await haveTimeout(5_000, upnp.getMappings())
95
mapped = _.find(mappings, x => x.private.host === localIp && x.private.port === internalPort)
96
}).catch(e => console.warn('UPnP mapping restore failed:', e?.message || String(e)))
97
const externalPort = mapped?.public.port
@@ -103,7 +99,7 @@ export const getNatInfo = debounceAsync(async () => {
99
defaultBaseUrl.localIp = localIp
100
defaultBaseUrl.port = externalPort || internalPort || 0
101
return {
106
- upnp: Boolean(res),
102
+ upnp: Boolean(gw),
103
localIp,
104
gatewayIp,
105
externalIp: defaultBaseUrl.externalIp,
@@ -114,7 +110,34 @@ export const getNatInfo = debounceAsync(async () => {
110
proto: status?.https?.listening ? 'https' : status?.http?.listening ? 'http' : '',
111
}
112
}, { reuseRunning: true })
117
-getNatInfo()
113
+
114
+upnpEnabled.sub(v => {
115
+ getNatInfo.clearRetain()
116
+ if (v) {
117
+ getUpnpClient().getGateway().then(res => console.log("UPnP found", res.gateway.description),
118
+ e => console.debug('UPnP failed:', e.message || String(e)))
119
+ return
120
+ }
121
+ // closing the client guarantees the disabled setting also stops any existing SSDP socket
122
+ upnpClient?.close()
123
+ upnpClient = undefined
124
+ defaultBaseUrl.externalIp = ''
125
+ getPublicIps.clearRetain()
126
+})
127
+
128
+configReady.then(getNatInfo).catch(() => {})
129
+
130
+export function getUpnpClient() {
131
+ if (!upnpEnabled.get()) // keep this guard upstream so disabled UPnP cannot leak SSDP traffic through callers
132
+ throw Error("UPnP disabled")
133
+ if (!upnpClient) {
134
+ upnpClient = new Client({ timeout: 4_000 })
135
+ const originalMethod = upnpClient.getGateway
136
+ // other client methods call getGateway too, so this will ensure they reuse this same result
137
+ upnpClient.getGateway = debounceAsync(() => originalMethod.apply(upnpClient), { retain: HOUR, retainFailure: 30_000 })
138
+ }
139
+ return upnpClient
140
+}
141
142
function findGateway(): Promise<string | undefined> {
143
return new Promise((resolve, reject) =>
src/outboundProxy.ts
+1
-1
@@ -21,7 +21,7 @@ const outboundProxy = defineConfig(CFG.outbound_proxy, '', v => {
21
}
22
})
23
24
-configReady.then(async ([startedWithoutConfig]) => {
24
+configReady.then(async (startedWithoutConfig) => {
25
if (!IS_WINDOWS || !startedWithoutConfig) return
26
// try to read Windows system setting for proxy
27
const out = await reg('query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings')