From 77e492bd36beaf991dbf5006f5616740058a9fa4 Mon Sep 17 00:00:00 2001 From: David White Date: Mon, 19 Oct 2026 07:36:11 +0100 Subject: [PATCH] device: the local address is resolved on every call (2.0.2) Device.getLocalAddress asks the kernel which address reaches the router each time it is called, and keeps no answer. Every mapping request sends the host's address of that moment as NewInternalClient, and a listing judges which entries are this host's against it. The host's address belongs to its route to the router, not to the router: a DHCP lease, an interface or a default route can change it while the gateway stays at the same location. Under cacheGateway the Device lives as long as the client, so a stored address would outlive such a change. The route query sends no packet, so it is asked again rather than stored. A client given a localAddress keeps using it. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- src/nat-upnp/client.ts | 3 ++- src/nat-upnp/device.ts | 23 ++++++++--------------- test/unit.test.ts | 23 ++++++++++++++++++++--- 6 files changed, 36 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bbde22f..b30c276 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ await client.removeMapping({ public: 8080 }); `getGateway()` discovers the UPnP gateway via SSDP and returns an `UpnpInfo` object. The SSDP socket is created for discovery and automatically closed when done — no cleanup needed. -Device info, capabilities, and local address are fetched lazily on first access, or all at once via `getAll()`: +Device info and capabilities are fetched lazily on first access and cached; the local address is resolved on every call. All three come at once via `getAll()`: ```typescript const info = await client.getGateway(); @@ -53,7 +53,7 @@ const info = await client.getGateway(); // Option A: resolve everything in one call const { device, capabilities, localAddress } = await info.getAll(); -// Option B: fetch individually (lazy, cached after first call) +// Option B: fetch individually (device info and capabilities cached after first call) const device = await info.getDevice(); const caps = await info.getCapabilities(); const addr = await info.getLocalAddress(); @@ -90,7 +90,7 @@ console.log(caps?.supportsGetSpecificPortMappingEntry); // true ### Local Address -Resolved via UDP connect (zero-packet kernel route query) — the standard technique used by miniupnpc, Python, Go, Docker, and Kubernetes: +Resolved via UDP connect (zero-packet kernel route query) — the standard technique used by miniupnpc, Python, Go, Docker, and Kubernetes. It is resolved again on every call, and every mapping request uses the address of that moment, so a host whose address changes is mapped at its new address on the next request: ```typescript const addr = await info.getLocalAddress(); // "192.168.1.100" diff --git a/package-lock.json b/package-lock.json index 1e6f669..f899417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@runonflux/nat-upnp", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@runonflux/nat-upnp", - "version": "2.0.1", + "version": "2.0.2", "license": "BlueOak-1.0.0", "dependencies": { "axios": "^1.6.5", diff --git a/package.json b/package.json index fc6edd7..3cb5f1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@runonflux/nat-upnp", - "version": "2.0.1", + "version": "2.0.2", "main": "build/src/index.js", "types": "build/src/index.d.ts", "files": [ diff --git a/src/nat-upnp/client.ts b/src/nat-upnp/client.ts index d037e5f..8fc358d 100644 --- a/src/nat-upnp/client.ts +++ b/src/nat-upnp/client.ts @@ -16,7 +16,8 @@ import Ssdp from "./ssdp"; /** * Holds the resolved gateway and provides lazy access to device info, - * capabilities, and local address. All are fetched on first access and cached. + * capabilities, and local address. Device info and capabilities are fetched on + * first access and cached; the local address is asked for on every call. */ export class UpnpInfo { readonly gateway: Device; diff --git a/src/nat-upnp/device.ts b/src/nat-upnp/device.ts index 175119c..438549e 100644 --- a/src/nat-upnp/device.ts +++ b/src/nat-upnp/device.ts @@ -146,7 +146,6 @@ export class Device implements IDevice { private servicePromise: Promise | null = null; private deviceInfoPromise: Promise | null = null; private capabilitiesPromise: Promise | null = null; - private localAddressPromise: Promise | null = null; constructor(url: string) { if (!url.startsWith("http")) { @@ -182,23 +181,17 @@ export class Device implements IDevice { * Determine the local interface address used to reach this device. * Uses UDP connect — a zero-packet kernel route query. The standard technique * used by miniupnpc (C), Python, Go, Docker, and Kubernetes. - * No data is sent on the wire. Cached after first success; cleared on failure. + * No data is sent on the wire. Asked on every call: the host's address can + * change while the device stays where it is, and the query costs nothing. */ public getLocalAddress(): Promise { - if (!this.localAddressPromise) { - const hostname = new URL(this.description).hostname; - if (!hostname || !net.isIPv4(hostname)) { - // If hostname is IPv6 or a DNS name, fall back to resolving via the description - // URL host directly. For IPv6, we'd need a udp6 socket. - this.localAddressPromise = Promise.resolve(""); - } else { - this.localAddressPromise = resolveLocalAddress(hostname).catch(() => { - this.localAddressPromise = null; - return ""; - }); - } + const hostname = new URL(this.description).hostname; + if (!hostname || !net.isIPv4(hostname)) { + // If hostname is IPv6 or a DNS name, fall back to resolving via the description + // URL host directly. For IPv6, we'd need a udp6 socket. + return Promise.resolve(""); } - return this.localAddressPromise; + return resolveLocalAddress(hostname).catch(() => ""); } /** diff --git a/test/unit.test.ts b/test/unit.test.ts index a35ea26..1e957eb 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -2940,6 +2940,22 @@ function getSoapFaultCode(xml: string): number | null { }); }); + await test("a host whose address changes is mapped at its new address, from the same cached gateway", async () => { + await withMovingGateway(async (g) => { + FakeSocket.localAddress = "192.168.1.50"; + await g.client.createMapping({ public: 16127, ttl: 0, description: "first" }); + FakeSocket.localAddress = "192.168.1.77"; + + await g.client.createMapping({ public: 16127, ttl: 0, description: "second" }); + + const adds = g.reached.filter((r) => r.action === "AddPortMapping"); + assertEqual(adds.length, 2, "two mappings sent"); + assert(adds[0].body.includes("192.168.1.50"), "the first goes to the old address"); + assert(adds[1].body.includes("192.168.1.77"), "the second goes to the new address"); + assertEqual(g.searches(), 1, "the gateway is kept"); + }); + }); + await test("a UPnP fault is the router answering, so the gateway is kept", async () => { await withMovingGateway(async (g) => { await g.client.getPublicIp(); @@ -3498,13 +3514,14 @@ function getSoapFaultCode(xml: string): number | null { }); }); - await test("the resolved address is cached, so the query runs once", async () => { + await test("every call asks the kernel again, so a changed address is seen", async () => { await withDgram(async (sockets) => { FakeSocket.localAddress = "10.9.9.9"; const device = new Device("http://192.168.7.1:5000/rootDesc.xml"); assertEqual(await device.getLocalAddress(), "10.9.9.9", "first call"); - assertEqual(await device.getLocalAddress(), "10.9.9.9", "second call"); - assertEqual(sockets.length, 1, "the second call reuses the cached answer"); + FakeSocket.localAddress = "10.9.9.10"; + assertEqual(await device.getLocalAddress(), "10.9.9.10", "second call"); + assertEqual(sockets.length, 2, "one route query per call"); }); });