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"); }); });