diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 6caa13d8f86e..234c61e2019d 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -4520,26 +4520,45 @@ Server.prototype.listen = function listen(this: $Node.NetServer, port, hostname, queryAddressType = isIP(clusterHost) || 4; } - listenInCluster( - this, - queryAddress, - queryPort, - queryAddressType, - backlog, - fd, - exclusive, - ipv6Only, - reusePort, - readableAll, - writableAll, - flags, - undefined, - path, - hostname, - tls, - contexts, - onListen, - ); + if (typeof clusterHost === "string" && queryAddress === clusterHost && isIP(clusterHost) === 0) { + lookupAndListen( + this, + port, + clusterHost, + backlog, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + hostname, + tls, + contexts, + onListen, + ); + } else { + listenInCluster( + this, + queryAddress, + queryPort, + queryAddressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + undefined, + path, + hostname, + tls, + contexts, + onListen, + ); + } } catch (err) { const isUnix = path != null; process.nextTick( @@ -4691,81 +4710,123 @@ function emitListeningNextTick(self: $Node.NetServer) { const { isPrimary, loadChild } = require("internal/cluster/isPrimary"); let cluster: typeof import("internal/cluster/child").default | undefined; -function listenInCluster( + +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2227-L2257 +function isIpv6LinkLocal(ip: string): boolean { + if (isIP(ip) !== 6) return false; + // fe80::/10. A valid IPv6 string that starts with "::" has a zero first hextet. + const firstHextet = ip.startsWith("::") ? 0 : parseInt(ip.slice(0, ip.indexOf(":")), 16); + return (firstHextet & 0xffc0) === 0xfe80; +} + +function filterOnlyValidAddress(addresses: LookupAddress[]): LookupAddress { + // Return the first non IPV6 link-local address if present + for (const address of addresses) { + if (!isIpv6LinkLocal(address.address)) { + return address; + } + } + + // Otherwise return the first address + return addresses[0]; +} + +// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2259-L2278 +// Node looks up IP literals too; Bun binds them directly so that listen(port, ip) stays synchronous. +function lookupAndListen( server: $Node.NetServer, - address: string | null, port: number | null | undefined, - addressType: number | null, + address: string, backlog: number | undefined, - fd: number | undefined, exclusive: boolean, ipv6Only: boolean, reusePort: boolean, readableAll: boolean, writableAll: boolean, flags: number, - options: object | undefined, - path: string | undefined, hostname: string | undefined, tls: ServerTLSOptions | undefined, contexts: Map | null, onListen: unknown, ) { - exclusive = !!exclusive; - - // A worker's first require of node:cluster runs its bootstrap (IPC handlers, 'online'); listen() has always been one - // of the places that happens, exclusive or not. + // As in listenInCluster: a worker's listen() runs the cluster bootstrap, even when the lookup fails. if (!isPrimary && cluster === undefined) { cluster = loadChild(); } - - if ( - !isPrimary && - !exclusive && - typeof address === "string" && - address.length > 0 && - typeof port === "number" && - port >= 0 && - isIP(address) === 0 - ) { - const lookupListeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2259-L2278 - require("node:dns").lookup(address, (err, ip, family) => { - if (lookupListeningId !== server[kClusterListeningId]) return; - if (err) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2268-L2269 - server.emit("error", err); - return; + const listeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); + (require("node:dns").lookup as LookupFunction)(address, { all: true } as LookupOptions, (err, addresses) => { + if (listeningId !== server[kClusterListeningId]) return; + if (err) { + server.emit("error", err); + return; + } + const validAddress = filterOnlyValidAddress(addresses); + try { + // A setSecureContext() made during the lookup counts. + if (tls) { + tls = server[bunTlsSymbol]!(port, hostname, false)[0]; + contexts = tls.contexts; } listenInCluster( server, - ip as string, + validAddress.address, port, - family === 6 ? 6 : 4, + validAddress?.family || 4, backlog, - fd, + undefined, exclusive, ipv6Only, reusePort, readableAll, writableAll, flags, - options, - path, + undefined, + undefined, hostname, tls, contexts, onListen, ); - }); - return; + } catch (err) { + process.nextTick(emitErrorNextTick, server, formatListenError(err, validAddress.address, port)); + } + }); +} + +function listenInCluster( + server: $Node.NetServer, + address: string | null, + port: number | null | undefined, + addressType: number | null, + backlog: number | undefined, + fd: number | undefined, + exclusive: boolean, + ipv6Only: boolean, + reusePort: boolean, + readableAll: boolean, + writableAll: boolean, + flags: number, + options: object | undefined, + path: string | undefined, + hostname: string | undefined, + tls: ServerTLSOptions | undefined, + contexts: Map | null, + onListen: unknown, +) { + exclusive = !!exclusive; + + // A worker's first require of node:cluster runs its bootstrap (IPC handlers, 'online'); listen() has always been one + // of the places that happens, exclusive or not. + if (!isPrimary && cluster === undefined) { + cluster = loadChild(); } if (isPrimary || exclusive) { server[kRealListen]( path, port, - hostname, + // The address dns.lookup resolved; hostname stays the name the TLS options were built from. + typeof address === "string" && isIP(address) !== 0 ? address : hostname, exclusive, ipv6Only, reusePort, diff --git a/test/js/node/net/server-listen-dns-lookup.test.js b/test/js/node/net/server-listen-dns-lookup.test.js new file mode 100644 index 000000000000..5d608baec736 --- /dev/null +++ b/test/js/node/net/server-listen-dns-lookup.test.js @@ -0,0 +1,137 @@ +// server.listen(port, host) resolves host through dns.lookup(host, { all: true }) in every process, +// as Node's lookupAndListen does: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2259-L2278 +// Written with node:test so the same file runs under Node. +const { test, mock } = require("node:test"); +const assert = require("node:assert"); +const dns = require("node:dns"); +const net = require("node:net"); +const tls = require("node:tls"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const fixture = name => readFileSync(join(__dirname, "..", "tls", "fixtures", name), "utf8"); + +const realLookup = dns.lookup; + +function mockLookup(table) { + const calls = []; + mock.method(dns, "lookup", (hostname, options, callback) => { + calls.push({ hostname, options }); + if (hostname in table) { + const result = table[hostname]; + process.nextTick(() => (result instanceof Error ? callback(result) : callback(null, result))); + } else { + realLookup(hostname, options, callback); + } + }); + return calls; +} + +function listenOutcome(server, ...args) { + return new Promise(resolve => { + server.once("error", error => resolve({ error })); + server.listen(...args, () => resolve({ address: server.address() })); + }); +} + +test("the host is resolved with dns.lookup and the server binds the resolved address", async t => { + t.after(() => mock.restoreAll()); + const calls = mockLookup({ "my-alias.test": [{ address: "127.0.0.1", family: 4 }] }); + const server = net.createServer(); + try { + const { error, address } = await listenOutcome(server, 0, "my-alias.test"); + assert.strictEqual(error, undefined); + assert.strictEqual(address.address, "127.0.0.1"); + assert.strictEqual(address.family, "IPv4"); + assert.deepStrictEqual( + calls.map(c => [c.hostname, c.options.all]), + [["my-alias.test", true]], + ); + } finally { + server.close(); + } +}); + +test("a lookup error is emitted as the server's 'error' unchanged", async t => { + t.after(() => mock.restoreAll()); + const lookupError = Object.assign(new Error("getaddrinfo ENOTFOUND nowhere.test"), { + code: "ENOTFOUND", + syscall: "getaddrinfo", + hostname: "nowhere.test", + }); + mockLookup({ "nowhere.test": lookupError }); + const server = net.createServer(); + const { error } = await listenOutcome(server, 0, "nowhere.test"); + assert.strictEqual(error, lookupError); + assert.strictEqual(server.listening, false); +}); + +test("a bind failure on the resolved address is an 'error' event with the resolved address", async t => { + t.after(() => mock.restoreAll()); + // TEST-NET-1 (RFC 5737) is never assigned to a local interface. + mockLookup({ "unbindable.test": [{ address: "192.0.2.1", family: 4 }] }); + const server = net.createServer(); + const { error } = await listenOutcome(server, 0, "unbindable.test"); + assert.strictEqual(error.code, "EADDRNOTAVAIL"); + assert.strictEqual(error.syscall, "listen"); + assert.strictEqual(error.address, "192.0.2.1"); + assert.strictEqual(server.listening, false); +}); + +test("close() before the lookup answers cancels the listen", async t => { + t.after(() => mock.restoreAll()); + mockLookup({ "slow.test": [{ address: "127.0.0.1", family: 4 }] }); + const server = net.createServer(); + const onListening = mock.fn(); + const onError = mock.fn(); + server.on("listening", onListening); + server.on("error", onError); + server.listen(0, "slow.test"); + server.close(); + // The mocked lookup answers on the next tick; give the loop a few turns after it. + await new Promise(resolve => setImmediate(() => setImmediate(resolve))); + assert.strictEqual(onListening.mock.callCount(), 0); + assert.strictEqual(onError.mock.callCount(), 0); + assert.strictEqual(server.listening, false); + assert.strictEqual(server.address(), null); +}); + +test("a tls.Server resolves its host the same way", async t => { + t.after(() => mock.restoreAll()); + const calls = mockLookup({ "tls-alias.test": [{ address: "127.0.0.1", family: 4 }] }); + const server = tls.createServer(); + try { + const { error, address } = await listenOutcome(server, 0, "tls-alias.test"); + assert.strictEqual(error, undefined); + assert.strictEqual(address.address, "127.0.0.1"); + assert.deepStrictEqual( + calls.map(c => c.hostname), + ["tls-alias.test"], + ); + } finally { + server.close(); + } +}); + +test("a setSecureContext() made while the host is being resolved is the one served", async t => { + t.after(() => mock.restoreAll()); + mockLookup({ "tls-switch.test": [{ address: "127.0.0.1", family: 4 }] }); + const server = tls.createServer({ key: fixture("agent1-key.pem"), cert: fixture("agent1-cert.pem") }); + try { + const listening = listenOutcome(server, 0, "tls-switch.test"); + server.setSecureContext({ key: fixture("agent2-key.pem"), cert: fixture("agent2-cert.pem") }); + const { error, address } = await listening; + assert.strictEqual(error, undefined); + const subjectCN = await new Promise((resolve, reject) => { + const client = tls.connect({ port: address.port, host: "127.0.0.1", rejectUnauthorized: false }, () => { + const cn = client.getPeerCertificate().subject.CN; + client.destroy(); + resolve(cn); + }); + client.on("error", reject); + }); + assert.strictEqual(subjectCN, "agent2"); + } finally { + server.close(); + } +}); diff --git a/test/js/node/test/sequential/test-net-server-listen-ipv6-link-local.js b/test/js/node/test/sequential/test-net-server-listen-ipv6-link-local.js new file mode 100644 index 000000000000..284fb1bdec17 --- /dev/null +++ b/test/js/node/test/sequential/test-net-server-listen-ipv6-link-local.js @@ -0,0 +1,131 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const dns = require('dns'); +const { mock } = require('node:test'); + +if (!common.hasIPv6) { + common.printSkipMessage('IPv6 support is required for this test'); + return; +} + +// Test on IPv6 Server, dns.lookup throws an error +{ + mock.method(dns, 'lookup', (hostname, options, callback) => { + callback(new Error('Mocked error')); + }); + const host = 'ipv6-link-local'; + + const server = net.createServer(); + + server.on('error', common.mustCall((e) => { + assert.strictEqual(e.message, 'Mocked error'); + })); + + server.listen(common.PORT + 2, host); +} + + +// Test on IPv6 Server, server.listen throws an error +{ + mock.method(dns, 'lookup', (hostname, options, callback) => { + if (hostname === 'ipv6-link-local') { + callback(null, [{ address: 'fe80::1', family: 6 }]); + } else { + dns.lookup.wrappedMethod(hostname, options, callback); + } + }); + const host = 'ipv6-link-local'; + + const server = net.createServer(); + + server.on('error', common.mustCall((e) => { + assert.strictEqual(e.address, 'fe80::1'); + assert.strictEqual(e.syscall, 'listen'); + })); + + server.listen(common.PORT + 2, host); +} + +// Test on IPv6 Server, picks 127.0.0.1 between that and a bunch of link-local addresses +{ + + mock.method(dns, 'lookup', (hostname, options, callback) => { + if (hostname === 'ipv6-link-local-with-many-entries') { + callback(null, [ + { address: 'fe80::1', family: 6 }, + { address: 'fe80::abcd:1234', family: 6 }, + { address: 'fe80::1ff:fe23:4567:890a', family: 6 }, + { address: 'fe80::200:5aee:feaa:20a2', family: 6 }, + { address: 'fe80::f2de:f1ff:fe2b:3c4b', family: 6 }, + { address: 'fe81::1', family: 6 }, + { address: 'fe82::abcd:1234', family: 6 }, + { address: 'fe83::1ff:fe23:4567:890a', family: 6 }, + { address: 'fe84::200:5aee:feaa:20a2', family: 6 }, + { address: 'fe85::f2de:f1ff:fe2b:3c4b', family: 6 }, + { address: 'fe86::1', family: 6 }, + { address: 'fe87::abcd:1234', family: 6 }, + { address: 'fe88::1ff:fe23:4567:890a', family: 6 }, + { address: 'fe89::200:5aee:feaa:20a2', family: 6 }, + { address: 'fe8a::f2de:f1ff:fe2b:3c4b', family: 6 }, + { address: 'fe8b::1', family: 6 }, + { address: 'fe8c::abcd:1234', family: 6 }, + { address: 'fe8d::1ff:fe23:4567:890a', family: 6 }, + { address: 'fe8e::200:5aee:feaa:20a2', family: 6 }, + { address: 'fe8f::f2de:f1ff:fe2b:3c4b', family: 6 }, + { address: 'fea0::1', family: 6 }, + { address: 'febf::abcd:1234', family: 6 }, + { address: 'febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff', family: 6 }, + { address: '127.0.0.1', family: 4 }, + ]); + } else { + dns.lookup.wrappedMethod(hostname, options, callback); + } + }); + + const host = 'ipv6-link-local-with-many-entries'; + + const server = net.createServer(); + + server.on('error', common.mustNotCall()); + + server.listen(common.PORT + 3, host, common.mustCall(() => { + const address = server.address(); + assert.strictEqual(address.address, '127.0.0.1'); + assert.strictEqual(address.port, common.PORT + 3); + assert.strictEqual(address.family, 'IPv4'); + server.close(); + })); +} + + +// Test on IPv6 Server, picks ::1 because the other address is a link-local address +{ + + const host = 'ipv6-link-local-with-double-entry'; + const validIpv6Address = '::1'; + + mock.method(dns, 'lookup', (hostname, options, callback) => { + if (hostname === 'ipv6-link-local-with-double-entry') { + callback(null, [ + { address: 'fe80::1', family: 6 }, + { address: validIpv6Address, family: 6 }, + ]); + } else { + dns.lookup.wrappedMethod(hostname, options, callback); + } + }); + + const server = net.createServer(); + + server.on('error', common.mustNotCall()); + + server.listen(common.PORT + 4, host, common.mustCall(() => { + const address = server.address(); + assert.strictEqual(address.address, validIpv6Address); + assert.strictEqual(address.port, common.PORT + 4); + assert.strictEqual(address.family, 'IPv6'); + server.close(); + })); +}