mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
net: support AF_UNIX paths in net.BoundSocket
Signed-off-by: Guy Bedford <guybedford@gmail.com> PR-URL: https://github.com/nodejs/node/pull/64399 Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
1 parent
e8be132e31
commit
b420cb649d
3 files changed
+323
-13
No files matched your search
+41
-3
@@ -1705,9 +1705,20 @@ to `listen()` or `new net.Socket()` later on. For `listen()` this enables
|
||||
synchronous port reservation, while for `new net.Socket()`, it allows control
|
||||
over the local egress port/IP, via `bind(2)` semantics.
|
||||
|
||||
A `BoundSocket` binds either a TCP endpoint (`host` or `port`) or a
|
||||
Unix domain/named-pipe endpoint (`path`); the two are mutually exclusive. For a
|
||||
`path`, the file system entry is reserved in the constructor, so conflicts such
|
||||
as `EADDRINUSE` throw synchronously exactly as a TCP bind does. On Linux a
|
||||
leading `'\0'` in `path` selects the abstract namespace (no file system entry);
|
||||
an abstract path on any other platform throws [`ERR_INVALID_ARG_VALUE`][].
|
||||
|
||||
Adoption transfers ownership of the socket; afterwards `address()` and `close()`
|
||||
throw [`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be
|
||||
closed to avoid leaking the socket.
|
||||
closed to avoid leaking the socket. Closing a pipe `BoundSocket` removes its
|
||||
file system entry; abstract and TCP binds have none to remove.
|
||||
|
||||
When a pipe `BoundSocket` bound to a source `path` is adopted as a client, that
|
||||
path is reported as the socket's `localAddress` once it connects.
|
||||
|
||||
When an adopted `BoundSocket` connects to a numeric IP literal, `connect(2)` is
|
||||
issued synchronously, so [`socket.localAddress`][] is resolved once
|
||||
@@ -1729,6 +1740,10 @@ server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
|
||||
|
||||
<!-- YAML
|
||||
added: v26.4.0
|
||||
changes:
|
||||
- version: REPLACEME
|
||||
pr-url: https://github.com/nodejs/node/pull/64399
|
||||
description: The `path` option is supported.
|
||||
-->
|
||||
|
||||
* `options` {Object}
|
||||
@@ -1743,19 +1758,41 @@ added: v26.4.0
|
||||
* `reusePort` {boolean} Sets `SO_REUSEPORT`, allowing multiple sockets to bind
|
||||
the same address and port for kernel-level load balancing. Support is
|
||||
platform-dependent. **Default:** `false`.
|
||||
* `path` {string} Binds a Unix domain socket (or Windows named pipe) at the
|
||||
given path instead of a TCP endpoint. A leading `'\0'` selects the Linux
|
||||
abstract namespace. Mutually exclusive with `host`, `port`, `ipv6Only`, and
|
||||
`reusePort`; combining them throws [`ERR_INVALID_ARG_VALUE`][].
|
||||
|
||||
### `boundSocket.address()`
|
||||
|
||||
<!-- YAML
|
||||
added: v26.4.0
|
||||
changes:
|
||||
- version: REPLACEME
|
||||
pr-url: https://github.com/nodejs/node/pull/64399
|
||||
description: The bound path is returned for a pipe bind.
|
||||
-->
|
||||
|
||||
* Returns: {Object} An object with `address`, `family`, and `port` properties,
|
||||
as [`server.address()`][] returns.
|
||||
* Returns: {Object|string} For a TCP bind, an object with `address`, `family`,
|
||||
and `port` properties, as [`server.address()`][] returns. For a pipe bind, the
|
||||
bound path string, as [`server.address()`][] returns for a pipe server.
|
||||
|
||||
Returns the bound local address. When bound with `port: 0`, `port` is the
|
||||
OS-assigned ephemeral port.
|
||||
|
||||
### `boundSocket.isPipe`
|
||||
|
||||
<!-- YAML
|
||||
added: REPLACEME
|
||||
-->
|
||||
|
||||
* {boolean}
|
||||
|
||||
`true` when the socket was bound with a `path` (a Unix domain socket or Windows
|
||||
named pipe), `false` for a TCP bind. The getter's presence on
|
||||
`net.BoundSocket.prototype` also serves as a capability probe for `path`
|
||||
support.
|
||||
|
||||
### `boundSocket.fd()`
|
||||
|
||||
<!-- YAML
|
||||
@@ -2262,6 +2299,7 @@ net.isIPv6('fhqwhgads'); // returns false
|
||||
[`'listening'`]: #event-listening
|
||||
[`'timeout'`]: #event-timeout
|
||||
[`BoundSocket`]: #class-netboundsocket
|
||||
[`ERR_INVALID_ARG_VALUE`]: errors.md#err_invalid_arg_value
|
||||
[`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted
|
||||
[`EventEmitter`]: events.md#class-eventemitter
|
||||
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options
|
||||
|
||||
+98
-10
@@ -380,19 +380,35 @@ const kBoundSource = Symbol('kBoundSource');
|
||||
// Server/Socket.
|
||||
const kBoundSocketConsume = Symbol('kBoundSocketConsume');
|
||||
|
||||
// A role-neutral wrapper over a synchronously bound libuv TCP handle: bound to
|
||||
// a local address but neither listening nor connecting until adopted by exactly
|
||||
// one Server (server.listen) or Socket (new net.Socket({ handle })). Adoption
|
||||
// transfers ownership; an un-adopted handle must be closed by the caller.
|
||||
// bind(2) is non-blocking, so binding happens inline and errors throw
|
||||
// synchronously. host must be a numeric IP literal; no DNS is performed.
|
||||
// Internal: read a pipe BoundSocket's bound path before adoption (undefined for
|
||||
// a TCP BoundSocket).
|
||||
const kBoundSocketPath = Symbol('kBoundSocketPath');
|
||||
|
||||
// The source path of an adopted, bound client pipe, surfaced as localAddress.
|
||||
const kBoundPath = Symbol('kBoundPath');
|
||||
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
// A role-neutral wrapper over a synchronously bound libuv handle: bound to a
|
||||
// local address (a numeric IP literal for TCP, or a filesystem/abstract path
|
||||
// for a unix-domain socket via { path }) but neither listening nor connecting
|
||||
// until adopted by exactly one Server (server.listen) or Socket
|
||||
// (new net.Socket({ handle })). Adoption transfers ownership; an un-adopted
|
||||
// handle must be closed by the caller. bind(2) is non-blocking, so binding
|
||||
// happens inline and errors throw synchronously. No DNS is performed.
|
||||
class BoundSocket {
|
||||
#handle;
|
||||
#address = {};
|
||||
#path;
|
||||
|
||||
constructor(options = kEmptyObject) {
|
||||
validateObject(options, 'options');
|
||||
|
||||
if (options.path !== undefined) {
|
||||
this.#bindPipe(options);
|
||||
return;
|
||||
}
|
||||
|
||||
const port = validatePort(options.port ?? 0, 'options.port');
|
||||
|
||||
const ipv6Only = options.ipv6Only ?? false;
|
||||
@@ -443,13 +459,47 @@ class BoundSocket {
|
||||
this.#handle = handle;
|
||||
}
|
||||
|
||||
// The kernel-assigned local address, resolved at construction; reflects the
|
||||
// OS-assigned ephemeral port when the bind requested port 0.
|
||||
// Bind a named unix-domain socket (or Windows named pipe). A leading '\0'
|
||||
// selects the Linux abstract namespace. path is mutually exclusive with the
|
||||
// TCP options; uv_pipe_bind is synchronous so conflicts throw here.
|
||||
#bindPipe(options) {
|
||||
const { path, host, port, ipv6Only, reusePort } = options;
|
||||
if (host !== undefined || port !== undefined ||
|
||||
ipv6Only !== undefined || reusePort !== undefined) {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
'options', options,
|
||||
'path is mutually exclusive with host, port, ipv6Only, and reusePort');
|
||||
}
|
||||
validateString(path, 'options.path');
|
||||
if (path[0] === '\0' && !isLinux) {
|
||||
throw new ERR_INVALID_ARG_VALUE(
|
||||
'options.path', path,
|
||||
'abstract socket paths are only supported on Linux');
|
||||
}
|
||||
|
||||
const handle = new Pipe(PipeConstants.SOCKET);
|
||||
const err = handle.bind(path);
|
||||
if (err) {
|
||||
handle.close();
|
||||
throw new ErrnoException(err, 'bind');
|
||||
}
|
||||
|
||||
this.#handle = handle;
|
||||
this.#path = path;
|
||||
}
|
||||
|
||||
// The bound local endpoint: an { address, family, port } object for TCP, or
|
||||
// the path string for a pipe (matching net.Server.address()). Resolved at
|
||||
// construction; a TCP bind of port 0 reflects the OS-assigned port.
|
||||
address() {
|
||||
if (this.#handle === null) {
|
||||
throw new ERR_SOCKET_HANDLE_ADOPTED();
|
||||
}
|
||||
return this.#address;
|
||||
return this.#path ?? this.#address;
|
||||
}
|
||||
|
||||
get [kBoundSocketPath]() {
|
||||
return this.#path;
|
||||
}
|
||||
|
||||
// The underlying OS file descriptor, or -1 where sockets have none (Windows).
|
||||
@@ -488,6 +538,13 @@ class BoundSocket {
|
||||
this.#handle = null;
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Reports whether this is a pipe (unix-domain) bind rather than TCP. Its mere
|
||||
// presence on the prototype ('isPipe' in net.BoundSocket.prototype) is the
|
||||
// capability signal that this build honors { path } instead of a TCP port.
|
||||
get isPipe() {
|
||||
return this.#path !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function Socket(options) {
|
||||
@@ -555,9 +612,24 @@ function Socket(options) {
|
||||
let boundNotConnected = false;
|
||||
if (options.handle) {
|
||||
if (options.handle instanceof BoundSocket) {
|
||||
const boundPath = options.handle[kBoundSocketPath];
|
||||
this._handle = options.handle[kBoundSocketConsume]();
|
||||
this[kBoundSource] = true;
|
||||
boundNotConnected = true;
|
||||
// A bound client pipe owns a source path; surface it as localAddress.
|
||||
if (boundPath !== undefined) {
|
||||
this[kBoundPath] = boundPath;
|
||||
// uv_pipe_bind() only assigns the fd; it does not open the stream, so a
|
||||
// later connect() would leave the handle without its readable/writable
|
||||
// flags and unusable. Re-open the already-bound fd (idempotent, sets the
|
||||
// flags) so the adopted client pipe connects to a working stream.
|
||||
if (this._handle.fd >= 0) {
|
||||
const err = this._handle.open(this._handle.fd);
|
||||
if (err) {
|
||||
throw new ErrnoException(err, 'open');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._handle = options.handle; // private
|
||||
}
|
||||
@@ -1120,6 +1192,11 @@ protoGetter('remotePort', function remotePort() {
|
||||
|
||||
|
||||
Socket.prototype._getsockname = function() {
|
||||
// An adopted bound client pipe has no handle getsockname; its source path was
|
||||
// captured at adoption and stays authoritative across the connect reset.
|
||||
if (this[kBoundPath] !== undefined) {
|
||||
return { address: this[kBoundPath] };
|
||||
}
|
||||
if (!this._handle || !this._handle.getsockname) {
|
||||
return {};
|
||||
} else if (!this._sockname) {
|
||||
@@ -1478,7 +1555,13 @@ Socket.prototype.connect = function(...args) {
|
||||
}
|
||||
|
||||
const { path } = options;
|
||||
const pipe = !!path;
|
||||
// An adopted BoundSocket handle already fixes the transport; trust its type
|
||||
// rather than inferring pipe-ness from a path option on the connect call.
|
||||
// Once destroyed the adopted handle is gone (its reservation released), so
|
||||
// fall back to the path option, as for other pre-existing handles (e.g. a
|
||||
// TLSWrap) that are not transport handles.
|
||||
const pipe = this[kBoundSource] && this._handle ?
|
||||
this._handle instanceof Pipe : !!path;
|
||||
debug('pipe', pipe, path);
|
||||
|
||||
if (!this._handle) {
|
||||
@@ -2425,7 +2508,12 @@ Server.prototype.listen = function(...args) {
|
||||
boundSocket = options.handle;
|
||||
}
|
||||
if (boundSocket !== null) {
|
||||
const boundPath = boundSocket[kBoundSocketPath];
|
||||
this._handle = boundSocket[kBoundSocketConsume]();
|
||||
// A pipe-backed handle reports its path via Server.address().
|
||||
if (boundPath !== undefined) {
|
||||
this._pipeName = boundPath;
|
||||
}
|
||||
this[async_id_symbol] = this._handle.getAsyncId();
|
||||
this._listeningId++;
|
||||
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
'use strict';
|
||||
const common = require('../common');
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const tmpdir = require('../common/tmpdir');
|
||||
tmpdir.refresh();
|
||||
|
||||
const isLinux = process.platform === 'linux';
|
||||
|
||||
// Constructing a BoundSocket binds synchronously and address() reports the
|
||||
// resolved address, including the OS-assigned ephemeral port when port is 0.
|
||||
@@ -240,6 +245,185 @@ if (!common.isWindows && process.getuid() !== 0) {
|
||||
}
|
||||
}
|
||||
|
||||
// The isPipe getter's presence on the prototype is the capability signal that
|
||||
// this build honors { path }; the value discriminates pipe from TCP binds.
|
||||
assert.ok('isPipe' in net.BoundSocket.prototype);
|
||||
{
|
||||
const tcpBound = new net.BoundSocket({ port: 0 });
|
||||
assert.strictEqual(tcpBound.isPipe, false);
|
||||
tcpBound.close();
|
||||
}
|
||||
|
||||
// The path option is mutually exclusive with the TCP options.
|
||||
for (const extra of [{ host: '127.0.0.1' }, { port: 0 }, { ipv6Only: true },
|
||||
{ reusePort: true }]) {
|
||||
assert.throws(
|
||||
() => new net.BoundSocket({ path: common.PIPE, ...extra }),
|
||||
{ code: 'ERR_INVALID_ARG_VALUE' });
|
||||
}
|
||||
|
||||
// A non-string path is rejected.
|
||||
assert.throws(() => new net.BoundSocket({ path: 1234 }),
|
||||
{ code: 'ERR_INVALID_ARG_TYPE' });
|
||||
|
||||
// Pipe bind is synchronous: address() returns the bound path, and a second
|
||||
// bind to the same path throws EADDRINUSE in the constructor.
|
||||
{
|
||||
const path = `${common.PIPE}-addr`;
|
||||
const bound = new net.BoundSocket({ path });
|
||||
assert.strictEqual(bound.isPipe, true);
|
||||
assert.strictEqual(bound.address(), path);
|
||||
assert.throws(() => new net.BoundSocket({ path }),
|
||||
{ code: 'EADDRINUSE', syscall: 'bind' });
|
||||
bound.close();
|
||||
}
|
||||
|
||||
// Server adoption: server.listen(boundSocket) over a path, client
|
||||
// net.connect({ path }) round-trips, and the connection is a pipe.
|
||||
{
|
||||
const path = `${common.PIPE}-server`;
|
||||
const bound = new net.BoundSocket({ path });
|
||||
|
||||
const server = net.createServer(common.mustCall((socket) => {
|
||||
assert.strictEqual(socket.remoteFamily, undefined);
|
||||
socket.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ping');
|
||||
socket.end('pong');
|
||||
}));
|
||||
}));
|
||||
|
||||
server.listen(bound, common.mustCall(() => {
|
||||
assert.strictEqual(server.address(), path);
|
||||
assert.throws(() => bound.address(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
|
||||
|
||||
const client = net.connect({ path }, () => {
|
||||
client.end('ping');
|
||||
});
|
||||
client.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'pong');
|
||||
}));
|
||||
client.on('close', common.mustCall(() => server.close()));
|
||||
}));
|
||||
}
|
||||
|
||||
// Client adoption of a bound source pipe: after connect(), localAddress
|
||||
// reflects the bound source path. Binding a client to a source path is a
|
||||
// POSIX unix-domain concept.
|
||||
if (!common.isWindows) {
|
||||
const srcPath = `${common.PIPE}-src`;
|
||||
const dstPath = `${common.PIPE}-dst`;
|
||||
|
||||
const server = net.createServer(common.mustCall((socket) => {
|
||||
socket.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ping');
|
||||
socket.end('pong');
|
||||
}));
|
||||
}));
|
||||
|
||||
const dst = new net.BoundSocket({ path: dstPath });
|
||||
server.listen(dst, common.mustCall(() => {
|
||||
const src = new net.BoundSocket({ path: srcPath });
|
||||
assert.strictEqual(src.address(), srcPath);
|
||||
|
||||
const client = new net.Socket({ handle: src });
|
||||
client.connect({ path: dstPath });
|
||||
|
||||
assert.strictEqual(client.localAddress, srcPath);
|
||||
|
||||
client.on('connect', common.mustCall(() => {
|
||||
assert.strictEqual(client.localAddress, srcPath);
|
||||
client.end('ping');
|
||||
}));
|
||||
client.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'pong');
|
||||
}));
|
||||
client.on('close', common.mustCall(() => server.close()));
|
||||
}));
|
||||
}
|
||||
|
||||
// Reconnecting an adopted pipe BoundSocket after destroy: the adopted handle is
|
||||
// gone, so pipe-ness must come from the path option rather than the (now null)
|
||||
// handle, otherwise connect() would wrongly attempt a TCP connect.
|
||||
if (!common.isWindows) {
|
||||
const path = `${common.PIPE}-reconnect`;
|
||||
|
||||
const server = net.createServer(common.mustCall((socket) => {
|
||||
socket.on('data', (data) => socket.end(data));
|
||||
}, 2));
|
||||
|
||||
const bound = new net.BoundSocket({ path: `${path}-src` });
|
||||
server.listen(path, common.mustCall(() => {
|
||||
const client = new net.Socket({ handle: bound });
|
||||
client.connect({ path });
|
||||
client.once('connect', common.mustCall(() => {
|
||||
client.end('ping');
|
||||
client.once('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ping');
|
||||
}));
|
||||
client.once('close', common.mustCall(() => {
|
||||
// The adopted handle is gone; reconnect must still be a pipe.
|
||||
client.connect({ path });
|
||||
client.once('connect', common.mustCall(() => {
|
||||
client.end('pong');
|
||||
client.once('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'pong');
|
||||
}));
|
||||
client.once('close', common.mustCall(() => server.close()));
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
}
|
||||
|
||||
// Linux abstract namespace: a leading '\0' binds without creating a filesystem
|
||||
// entry, and still listens/connects.
|
||||
if (isLinux) {
|
||||
const abstractPath = `\0node-test-boundsocket.${process.pid}`;
|
||||
const bound = new net.BoundSocket({ path: abstractPath });
|
||||
assert.strictEqual(bound.address(), abstractPath);
|
||||
|
||||
const server = net.createServer(common.mustCall((socket) => {
|
||||
socket.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'ping');
|
||||
socket.end('pong');
|
||||
}));
|
||||
}));
|
||||
|
||||
server.listen(bound, common.mustCall(() => {
|
||||
// No filesystem entry is created for an abstract socket.
|
||||
assert.ok(!fs.existsSync(abstractPath.slice(1)));
|
||||
|
||||
const client = net.connect({ path: abstractPath }, () => {
|
||||
client.end('ping');
|
||||
});
|
||||
client.on('data', common.mustCall((data) => {
|
||||
assert.strictEqual(data.toString(), 'pong');
|
||||
}));
|
||||
client.on('close', common.mustCall(() => server.close()));
|
||||
}));
|
||||
}
|
||||
|
||||
// An abstract path on a non-Linux platform is rejected synchronously.
|
||||
if (!isLinux) {
|
||||
assert.throws(() => new net.BoundSocket({ path: '\0abstract' }),
|
||||
{ code: 'ERR_INVALID_ARG_VALUE' });
|
||||
}
|
||||
|
||||
// Filesystem bind errors surface synchronously in the constructor. A missing
|
||||
// parent directory yields EACCES (libuv maps the kernel's ENOENT to EACCES for
|
||||
// cross-platform parity), and an over-long path yields EINVAL (uv_pipe_bind is
|
||||
// called with UV_PIPE_NO_TRUNCATE). The path must exceed sun_path on every
|
||||
// platform, which is 1023 bytes on AIX.
|
||||
if (!common.isWindows) {
|
||||
assert.throws(
|
||||
() => new net.BoundSocket({ path: `${common.PIPE}-nope/child.sock` }),
|
||||
{ code: 'EACCES', syscall: 'bind' });
|
||||
|
||||
assert.throws(
|
||||
() => new net.BoundSocket({ path: `${common.PIPE}-${'x'.repeat(2000)}` }),
|
||||
{ code: 'EINVAL', syscall: 'bind' });
|
||||
}
|
||||
|
||||
// IPv6 binds: loopback, and ipv6Only dual-stack control.
|
||||
if (common.hasIPv6) {
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user