Aller au contenu
Français

DNS seed provider

Ce contenu n’est pas encore disponible dans votre langue.

DnsSeedProvider resolves seeds via DNS at startup. Two modes:

ModeLookupResult
SRV records_actor-ts._tcp.example.comService + port from records
A recordsactor-ts.example.comIPs only; port comes from config

SRV is the more flexible choice (service-discovery-style); A records work for simpler setups.

import { Cluster, ClusterOptions, DnsSeedProvider, DnsSeedProviderOptions } from 'actor-ts';
const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv();
const provider = new DnsSeedProvider(
dnsSeedProviderOptions,
);
const seeds = await provider.lookup();
const clusterOptions = ClusterOptions.create()
.withHost(host)
.withPort(port)
.withSeeds(seeds);
await Cluster.join(system, clusterOptions);
type DnsSeedProviderOptionsType = {
hostname: string; // SRV record name (or A-record hostname)
systemName: string; // stamped on discovered node addresses
port: number; // paired with each IP in A-record mode
useSrv?: boolean; // prefer SRV records (which carry a port) over A
cacheTtlMs?: number; // in-process TTL cache; default 60_000 ms, 0 disables
pinnedAddresses?: readonly string[]; // accept only these; unset = accept all
log?: (message: string, error?: unknown) => void; // reports pinned-out addresses
// Override the DNS functions (default: node:dns/promises) — mainly for tests:
resolve?: (hostname: string) => Promise<string[]>;
resolveSrv?: (hostname: string) => Promise<Array<{ name: string; port: number }>>;
};
const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.my-app.example.com')
.withSystemName('my-app')
.withUseSrv();
new DnsSeedProvider(
dnsSeedProviderOptions,
);

SRV records carry host, port, weight, priority per entry. The provider returns host:port for every entry the DNS query returned.

To create the SRV records:

_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-1.my-app.example.com.
_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-2.my-app.example.com.
_actor-ts._tcp.my-app.example.com. IN SRV 10 100 2552 node-3.my-app.example.com.

Plus matching A records for the hostnames. Most DNS-SD service-registration tools (Consul, Eureka with DNS plugin) publish SRV records automatically.

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('actor-ts.example.com')
.withSystemName('my-app')
.withPort(2552);
new DnsSeedProvider(
dnsSeedProviderOptions,
);

For setups without SRV — DNS only carries IPs. The provider queries A records and pairs each with the configured port.

A records typically point at a load balancer or a round-robin DNS list; multiple A entries mean multiple seed candidates.

Whatever DNS returns is an answer from a party the node never authenticated. pinnedAddresses bounds what that answer is allowed to say: addresses outside the list are discarded before they are offered to the cluster as seeds.

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('actor-ts.example.com')
.withSystemName('my-app')
.withPort(2552)
.withPinnedAddresses(['10.0.0.0/8'])
.withLog((message) => logger.warn(message));

Entry shape depends on the mode, because the two modes resolve to different things:

ModeResolves toPin with
A recordsIP addressesCIDRs — '10.0.0.0/8', '2001:db8::/32'
SRV recordsTarget hostnamesHost suffixes — 'svc.cluster.local'

A suffix matches on a label boundary, so svc.cluster.local admits pod-1.svc.cluster.local and the apex itself, but not evilsvc.cluster.local. A CIDR is never consulted for a hostname and a suffix is never consulted for an IP, so mixing both shapes in one list is fine — useful when the same config serves both modes.

A list that cannot match anything in the configured mode is rejected at construction rather than silently discarding every record:

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv()
.withPinnedAddresses(['10.0.0.0/8']);
// Throws OptionsError — SRV targets are hostnames, so this list
// would drop every record and look like an empty DNS answer.
new DnsSeedProvider(dnsSeedProviderOptions);

The same guard rejects a bare '10.0.0.1' (write '10.0.0.1/32'), an empty list, and a malformed CIDR.

Addresses are compared in canonical form only

Section titled “Addresses are compared in canonical form only”

Both sides of the comparison — the CIDRs you pin and the addresses the resolver hands back — must be canonical: decimal octets with no leading zero, no 0x, no exponent, no whitespace, and a plain decimal prefix length.

This is not pedantry. 1e1.0.0.1, 010.0.0.1 and 0x0a.0.0.1 all read as 10.0.0.1 to a lenient number parser, but the socket layer scores them as not an IP and resolves them through DNS instead — so an answer in one of those spellings would clear a 10.0.0.0/8 pin and then connect wherever the attacker’s resolver pointed. Anything non-canonical is now treated as a hostname, and a hostname is never matched against a CIDR pin.

Two consequences worth knowing when upgrading:

  • A pin entry written non-canonically ('010.0.0.0/8') throws at construction instead of pinning a network you did not write.
  • A trailing-slash typo ('10.0.0.0/') throws as well. It used to parse as /0 — a pin meant to admit one network admitting the entire address space.

An all-numeric host suffix ('0.1') is rejected too: suffix matching is string comparison, so such an entry would match the tail of unrelated addresses. Pin a CIDR instead.

Dropped addresses are filtered, not fatal — one stale record costs that record, not the whole bootstrap. Each drop goes to log, which is worth wiring up: an over-tight pin list and an empty DNS answer look identical from the outside, and an empty seed list means the node forms its own single-node cluster.

EnvironmentUse
Consul-managed deploymentsSRV mode — Consul writes them automatically.
Eureka with DNS pluginSRV mode.
Manual deployments with DNS serverEither mode; SRV preferred.
Cloud with DNS-based service discoverySRV mode.
K8sUse KubernetesApiSeedProvider — K8s’s headless services do produce A records but the K8s API provider is more reliable.

lookup() isn’t one-shot — the bootstrap layer calls it at startup, and higher-level retry logic may call it again. To keep repeated calls from hammering DNS, the provider keeps a per-instance in-memory TTL cache:

const dnsSeedProviderOptions = DnsSeedProviderOptions.create()
.withHostname('_actor-ts._tcp.example.com')
.withSystemName('my-app')
.withUseSrv()
.withCacheTtlMs(30_000); // default 60_000 ms; 0 disables the cache
  • Calls within the cacheTtlMs window (default 60 000 ms) are served from cache without touching DNS.
  • The first call after the TTL expires re-queries DNS.
  • A failed lookup is not cached — it retries on the next call.

Once Cluster.join completes, the cluster’s gossip layer takes over membership tracking, so DNS is off the hot path. This means:

  • A node added after startup isn’t visible to existing peers via DNS. It joins via its own DNS lookup, contacts an existing seed, gossip propagates.
  • DNS record TTLs matter only when a lookup actually hits DNS (startup and post-expiry re-queries), not for the cluster’s steady-state runtime.