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