Skip to content

Commit

Permalink
InterfaceAddress and EndpointAddress (#444)
Browse files Browse the repository at this point in the history
Similar to what was done in the previous PR, this one extracts a new
class `InterfaceAddress` out of the ad-hoc plain(ish) objects which had
been used for that purpose. It also continues the work of rearranging
existing utility functionality to take advantage of the new classes.
  • Loading branch information
danfuzz authored Dec 5, 2024
2 parents db1c13a + 91fa76c commit 7d29438
Show file tree
Hide file tree
Showing 17 changed files with 1,571 additions and 992 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ versioning principles. Unstable releases do not.
Breaking changes:
* `loggy-intf`:
* Moved `FormatUtils` contents to `net-util.EndpointAddress` (see below).
* `net-util`:
* New classes `EndpointAddress` and `InterfaceAddress` which replace use of
ad-hoc plain objects. Use sites updated across all modules.
* Moved IP-address-related bits from `HostUtil` into `EndpointAddress`.
* Moved interface-related bits from `HostUtil` into `InterfaceAddress`.

Other notable changes:
* `net-util`:
* New class `EndpointAddress` which replaces use of ad-hoc plain objects.
* `valvis`:
* `BaseValueVisitor`:
* Added `isInterned` argument to `_impl_visitSymbol()`.
Expand Down
8 changes: 6 additions & 2 deletions doc/configuration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ naming and configuring one of them. Each element has the following bindings:
in the same form as accepted in the `hosts` section of the configuration.
Defaults to `['*']`, which should suffice in most cases.
* `interface` — The network interface to listen on. This is a string which
can take one of two forms:
can take one of three forms:
* `<address>:<port>` &mdash; Specifies a normal network-attached interface.
`<address>` is a DNS name, an IPv4 address, a _bracketed_ IPv6 address, or
the wildcard value `*`. `<port>` is a non-zero (decimal) port number.
Expand All @@ -243,7 +243,11 @@ naming and configuring one of them. Each element has the following bindings:
* `/dev/fd/<fd-num>` &mdash; Specifies a file descriptor which is expected to
already correspond to an open server socket (e.g. set up by `systemd`).
`<fd-num>` is an arbitrary (decimal) number in the range of valid file
descriptors.
descriptors. This form can optionally include a `:<port>` suffix, which is
used for informational (logging) purposes only.
* an instance of `net-util.InterfaceAddress` &mdash; Same as above, but in the
form of a proper class instance, which may be preferable when using this
system as a programmatic framework.
* `maxRequestBodySize` &mdash; Optional limit on the size of a request body,
specified as a byte count as described in
[`ByteCount`](./2-common-configuration.md#bytecount), or `null` not to have
Expand Down
38 changes: 11 additions & 27 deletions src/net-protocol/export/ProtocolWrangler.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { inspect } from 'node:util';
import { Threadlet } from '@this/async';
import { ProductInfo } from '@this/host';
import { IntfLogger } from '@this/loggy-intf';
import { FullResponse, IncomingRequest, IntfRequestHandler, RequestContext,
StatusResponse, TypeNodeRequest, TypeNodeResponse }
import { FullResponse, IncomingRequest, InterfaceAddress, IntfRequestHandler,
RequestContext, StatusResponse, TypeNodeRequest, TypeNodeResponse }
from '@this/net-util';
import { Methods, MustBe } from '@this/typey';

Expand Down Expand Up @@ -73,11 +73,11 @@ export class ProtocolWrangler {
#requestHandler;

/**
* Return value for {@link #interface}.
* Address of the interface to listen on.
*
* @type {object}
* @type {InterfaceAddress}
*/
#interfaceObject;
#interface;

/**
* Maximum request body allowed, in bytes, or `null` if there is no limit.
Expand Down Expand Up @@ -127,15 +127,8 @@ export class ProtocolWrangler {
* use. If not specified, the instance won't do data rate limiting.
* @param {object} options.hostManager Host manager to use. Ignored for
* instances which don't do need to do host-based security (certs, etc.).
* @param {object} options.interface Options to use for creation of and/or
* listening on the low-level server socket. See docs for
* `net.createServer()` and `net.Server.listen()` for details on all the
* available options, though with the following exceptions (done in order to
* harmonize with the rest of this system):
* * `address` is the address of the interface instead of `host`.
* * `*` is treated as the wildcard address, instead of `::` or `0.0.0.0`.
* * The default for `allowHalfOpen` is `true`, which is required in
* practice for HTTP2 (and is at least _useful_ in other contexts).
* @param {InterfaceAddress} options.interface Address of the interface to
* listen on.
* @param {?number} [options.maxRequestBodyBytes] Maximum size allowed for a
* request body, in bytes, or `null` not to have a limit. Note that not
* having a limit is often ill-advised. If non-`null`, must be a
Expand All @@ -152,36 +145,27 @@ export class ProtocolWrangler {
const {
accessLog,
hostManager,
interface: interfaceConfig,
interface: iface,
maxRequestBodyBytes = null,
requestHandler
} = options;

this.#accessLog = accessLog ?? null;
this.#hostManager = hostManager ?? null;
this.#interface = MustBe.instanceOf(iface, InterfaceAddress);
this.#requestHandler = MustBe.object(requestHandler);
this.#serverHeader = ProtocolWrangler.#makeServerHeader();

this.#interfaceObject = Object.freeze({
address: interfaceConfig.address ?? null,
fd: interfaceConfig.fd ?? null,
port: interfaceConfig.port ?? null
});

this.#maxRequestBodyBytes = (maxRequestBodyBytes === null)
? null
: MustBe.number(maxRequestBodyBytes, { safeInteger: true, minInclusive: 0 });
}

/**
* @returns {{ address: ?string, port: ?number, fd: ?number }} The IP address
* and port of the interface, _or_ the file descriptor, which this instance
* listens on. In the case of a file descriptor, `port` might be defined, in
* which case it is the "declared port" to report to clients, e.g. for
* logging.
* @returns {InterfaceAddress} Address which this instance listens on.
*/
get interface() {
return this.#interfaceObject;
return this.#interface;
}

/** @returns {?IntfLogger} The logger for this instance. */
Expand Down
141 changes: 14 additions & 127 deletions src/net-protocol/private/AsyncServerSocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { EventPayload, EventSource, LinkedEvent, PromiseUtil }
from '@this/async';
import { WallClock } from '@this/clocky';
import { IntfLogger } from '@this/loggy-intf';
import { EndpointAddress } from '@this/net-util';
import { InterfaceAddress } from '@this/net-util';
import { MustBe } from '@this/typey';


Expand All @@ -24,9 +24,9 @@ export class AsyncServerSocket {
#logger;

/**
* Parsed server socket `interface` specification.
* Address of the interface to listen on.
*
* @type {object}
* @type {InterfaceAddress}
*/
#interface;

Expand Down Expand Up @@ -68,13 +68,13 @@ export class AsyncServerSocket {
/**
* Constructs an instance.
*
* @param {object} iface Parsed server socket `interface` specification.
* @param {InterfaceAddress} iface Address of the interface to listen on.
* @param {string} protocol The protocol name; just used for logging.
* @param {?IntfLogger} logger Logger to use, if any.
*/
constructor(iface, protocol, logger) {
// Note: `interface` is a reserved word.
this.#interface = MustBe.plainObject(iface);
this.#interface = MustBe.instanceOf(iface, InterfaceAddress);
this.#protocol = MustBe.string(protocol);
this.#logger = logger;
}
Expand All @@ -84,15 +84,12 @@ export class AsyncServerSocket {
* address and current-listening info.
*/
get infoForLog() {
const address = this.#serverSocket?.address();
const iface = EndpointAddress.networkInterfaceString(this.#interface);
const address = InterfaceAddress.fromNodeServerOrNull(this.#serverSocket);

return {
protocol: this.#protocol,
interface: iface,
...(address
? { listening: EndpointAddress.networkInterfaceString(address) }
: {})
interface: this.#interface,
...(address ? { listening: address } : {})
};
}

Expand Down Expand Up @@ -158,8 +155,7 @@ export class AsyncServerSocket {
// Either this isn't a reload, or it's a reload with an endpoint that
// isn't configured the same way as one of the pre-reload ones, or it's a
// reload but the found instance didn't actually have a socket.
this.#serverSocket = netCreateServer(
AsyncServerSocket.#extractConstructorOptions(this.#interface));
this.#serverSocket = netCreateServer(this.#interface.nodeServerCreateOptions);
}

const onConnection = (...args) => {
Expand Down Expand Up @@ -294,42 +290,15 @@ export class AsyncServerSocket {
serverSocket.on('listening', handleListening);
serverSocket.on('error', handleError);

serverSocket.listen(AsyncServerSocket.#extractListenOptions(this.#interface));
serverSocket.listen(this.#interface.nodeServerListenOptions);
});
}


//
// Static members
//

/**
* "Prototype" of server socket creation options. See `ProtocolWrangler` class
* doc for details.
*
* @type {object}
*/
static #CREATE_PROTO = Object.freeze({
allowHalfOpen: { default: true },
keepAlive: null,
keepAliveInitialDelay: null,
noDelay: null,
pauseOnConnect: null
});

/**
* "Prototype" of server listen options. See `ProtocolWrangler` class doc for
* details.
*
* @type {object}
*/
static #LISTEN_PROTO = Object.freeze({
address: { map: (v) => ({ host: (v === '*') ? '::' : v }) },
backlog: null,
exclusive: null,
fd: null,
port: null
});

/**
* How long in msec to allow a stashed instance to remain stashed.
*
Expand All @@ -345,88 +314,6 @@ export class AsyncServerSocket {
*/
static #stashedInstances = new Set();

/**
* Gets the options for a `Server` constructor(ish) call, given the full
* server socket `interface` options.
*
* @param {object} options The interface options.
* @returns {object} The constructor-specific options.
*/
static #extractConstructorOptions(options) {
return this.#fixOptions(options, this.#CREATE_PROTO);
}

/**
* Gets the options for a `listen()` call, given the full server socket
* `interface` options.
*
* @param {object} options The interface options.
* @returns {object} The `listen()`-specific options.
*/
static #extractListenOptions(options) {
return this.#fixOptions(options, this.#LISTEN_PROTO);
}

/**
* Trims down and "fixes" `options` using the given prototype. This is used to
* convert from our incoming `interface` form to what's expected by Node's
* `Server` construction and `listen()` methods.
*
* @param {object} options Original options.
* @param {object} proto The "prototype" for what bindings to keep.
* @returns {object} Pared down version.
*/
static #fixOptions(options, proto) {
const result = {};

for (const [name, mod] of Object.entries(proto)) {
const value = options[name];
if (value === undefined) {
if (mod?.default !== undefined) {
result[name] = mod.default;
}
} else if (mod?.map) {
Object.assign(result, (mod.map)(options[name]));
} else {
result[name] = options[name];
}
}

return result;
}

/**
* Checks to see if two "interface" specification objects are the same. This
* is just a shallow `isEqual()`ish check of the two objects, with `===` for
* value comparison.
*
* @param {object} iface1 Interface specification to compare.
* @param {object} iface2 Interface specification to compare.
* @returns {boolean} `true` if they represent the same interface, or `false`
* if not.
*/
static #sameInterface(iface1, iface2) {
let entries1 = Object.entries(iface1);
let entries2 = Object.entries(iface2);

if (entries1.length !== entries2.length) {
return false;
}

entries1 = entries1.sort(([key1], [key2]) => (key1 < key2) ? -1 : 1);
entries2 = entries2.sort(([key1], [key2]) => (key1 < key2) ? -1 : 1);

for (let i = 0; i < entries1.length; i++) {
const [key1, value1] = entries1[i];
const [key2, value2] = entries2[i];
if ((key1 !== key2) || (value1 !== value2)) {
return false;
}
}

return true;
}

/**
* Stashes an instance for possible reuse during a reload.
*
Expand All @@ -438,7 +325,7 @@ export class AsyncServerSocket {
this.#unstashInstance(instance.#interface);

this.#stashedInstances.add(instance);
instance.#logger?.stashed();
instance.#logger?.stashed(instance.#interface);

(async () => {
await WallClock.waitForMsec(this.#STASH_TIMEOUT_MSEC);
Expand All @@ -453,13 +340,13 @@ export class AsyncServerSocket {
* Finds a matching instance of this class, if any, which was stashed away
* during a reload. If found, it is removed from the stash.
*
* @param {object} iface The interface specification for the instance.
* @param {InterfaceAddress} iface The interface to look for.
* @returns {?AsyncServerSocket} The found instance, if any.
*/
static #unstashInstance(iface) {
let found = null;
for (const si of this.#stashedInstances) {
if (this.#sameInterface(iface, si.#interface)) {
if (iface.equals(si.#interface)) {
found = si;
break;
}
Expand Down
Loading

0 comments on commit 7d29438

Please sign in to comment.