Files
meta-fuse/packages/meta-fuse-core/src/discovery/meshdisco.ts
T
2026-10-04 17:19:28 +00:00

667 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Beacon v2 — local resource advertise / discover over UDP multicast
* (239.255.99.1:9099).
*
* Every node advertises resources tagged with capability strings
* (`metamesh.core`, `metamesh.service/meta-sort`, `metamesh.transport/nzb@1`)
* and keeps a live view of everyone else's. This service uses it to locate
* meta-core (the `metamesh.core` resource carries its /urls block) and to feed
* the nav menu (`metamesh.service/*`), with no shared volume.
*
* The normative spec is docs/project-architecture/beacon-v2.md.
*
* ⚠ MIRRORED FILE. Byte-identical copies live in meta-sort-core, meta-fuse-core
* and meta-dup-core. They cannot share a package: meta-fuse-core and
* meta-dup-core do not depend on @metazla/meta-interface at all, and each
* submodule is its own Docker build context, so a workspace dep resolves
* outside the build. Edit one copy → edit all three, and
* `scripts/check-mirrors.sh` will tell you if you forgot.
*/
import dgram from 'dgram';
import { hostname, networkInterfaces } from 'os';
export const PROTO = 'beacon';
export const VERSION = 2;
export const DEFAULT_GROUP = '239.255.99.1';
export const DEFAULT_PORT = 9099;
export const DEFAULT_INTERVAL_MS = 10_000;
/** A node unheard for interval × this is dropped (evaluated at read time). */
export const LIVENESS_FACTOR = 3;
/** Senders stay under this so nothing fragments. */
export const MAX_DATAGRAM = 1400;
export const TYPE_PROBE = 'probe';
export const TYPE_ADVERTISE = 'advertise';
export const TYPE_BYE = 'bye';
export const CAP_CORE = 'metamesh.core';
export const CAP_SERVICE_PREFIX = 'metamesh.service/';
export const CAP_ANY_SERVICE = 'metamesh.service/*';
/** Mirrors meta-core's GET /urls response minus redisUrl (retired). */
export interface MeshUrls {
hostname: string;
baseUrl: string;
apiUrl: string;
webdavUrl: string;
webdavUrlInternal: string;
}
export interface BeaconNodeInfo {
name: string;
instance: string;
version?: string;
/** 'starting' | 'running'; absent reads as running. */
status?: string;
}
export interface BeaconResource {
id: string;
caps: string[];
/** Well-known: http, ui, manifest, mcp. A '/…' value is relative to http. */
endpoints?: Record<string, string>;
rev?: string;
/** The one consumer instance this resource belongs to. */
binds?: string;
data?: Record<string, unknown>;
}
export interface BeaconMessage {
proto: string;
v: number;
type: string;
node?: BeaconNodeInfo;
resources?: BeaconResource[];
from?: string;
want?: string[];
}
/** A node as served by /api/neighbors. */
export interface MeshNeighbor extends BeaconNodeInfo {
resources: BeaconResource[];
/** Every resource's caps, flattened. */
caps: string[];
/** The metamesh.service/* resource's endpoints.ui (v1-compatible field). */
baseUrl?: string;
/** Source address taken from the packet, never from the payload. */
addr: string;
/** Unix seconds. */
lastSeen: number;
}
export interface MeshNodeConfig {
name: string;
instance?: string;
version?: string;
group?: string;
port?: number;
intervalMs?: number;
enabled?: boolean;
/** Rebuilt on every advertise so the payload never goes stale. */
payload?: () => { status?: string; resources: BeaconResource[] };
}
/** How a discovered resource relates to this consumer (Scan card). */
export type ScanState = 'configured' | 'addable' | 'bound-elsewhere';
export interface ScanCandidate {
instance: string;
name: string;
version?: string;
resourceId: string;
caps: string[];
/** endpoints.http — what the consumer would register. */
url: string;
binds?: string;
state: ScanState;
/** The list entry that already holds this URL (state `configured`). */
configuredAs?: string;
/** A list-safe name to register it under. */
suggestedName: string;
}
/** Same JSON as meta-feeder-sdk `beacon::ScanReport`. */
export interface ScanReport {
cap: string;
self: string;
durationMs: number;
summary: { nodes: number; capable: number; configured: number; boundElsewhere: number; addable: number };
candidates: ScanCandidate[];
}
/** Lower-case, `[a-z0-9._-]` only — accepted by every consumer's name rules. */
export function suggestedName(instance: string): string {
const s = instance.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-');
return s || 'plugin';
}
const normUrl = (u: string) => u.trim().replace(/\/+$/, '');
/**
* Classify every resource matching `pattern` against the consumer's
* `(name, url)` list. Mirrors meta-feeder-sdk `beacon::classify`.
*/
export function classifyScan(
pattern: string,
self: string,
neighbors: MeshNeighbor[],
configured: Array<[string, string]>,
): ScanReport {
const summary = { nodes: neighbors.length, capable: 0, configured: 0, boundElsewhere: 0, addable: 0 };
const candidates: ScanCandidate[] = [];
for (const n of neighbors) {
for (const r of n.resources) {
if (!resourceMatches(r, pattern)) continue;
const http = resourceEndpoint(r, 'http');
if (!http) continue; // nothing a consumer could register
const url = normUrl(http);
summary.capable++;
const hit = configured.find(([, u]) => normUrl(u) === url);
let state: ScanState;
if (hit) { state = 'configured'; summary.configured++; }
else if (r.binds && r.binds !== self) { state = 'bound-elsewhere'; summary.boundElsewhere++; }
else { state = 'addable'; summary.addable++; }
candidates.push({
instance: n.instance,
name: n.name,
...(n.version ? { version: n.version } : {}),
resourceId: r.id,
caps: r.caps ?? [],
url,
...(r.binds ? { binds: r.binds } : {}),
state,
...(hit ? { configuredAs: hit[0] } : {}),
suggestedName: suggestedName(n.instance),
});
}
}
const rank: Record<ScanState, number> = { addable: 0, configured: 1, 'bound-elsewhere': 2 };
candidates.sort((a, b) => rank[a.state] - rank[b.state] || a.instance.localeCompare(b.instance));
return { cap: pattern, self, durationMs: 0, summary, candidates };
}
/**
* Does capability `cap` satisfy `pattern`? `*` matches everything; an `@N` on
* the pattern must equal the cap's contract (none ignores it); `x/*` matches
* any variant of `x`; otherwise the bases must be equal.
*/
export function capMatches(pattern: string, cap: string): boolean {
if (pattern === '*') return true;
const split = (s: string): [string, string | null] => {
const i = s.lastIndexOf('@');
return i >= 0 ? [s.slice(0, i), s.slice(i + 1)] : [s, null];
};
const [pbase, pc] = split(pattern);
const [cbase, cc] = split(cap);
if (pc !== null && cc !== pc) return false;
if (pbase.endsWith('/*')) {
const prefix = pbase.slice(0, -2);
if (!cbase.startsWith(prefix)) return false;
const rest = cbase.slice(prefix.length);
return rest.startsWith('/') && rest.length > 1;
}
return pbase === cbase;
}
export function resourceMatches(r: BeaconResource, pattern: string): boolean {
return (r.caps ?? []).some((c) => capMatches(pattern, c));
}
/** The named endpoint as an absolute URL ('/…' joins onto endpoints.http). */
export function resourceEndpoint(r: BeaconResource, name: string): string | undefined {
const v = r.endpoints?.[name];
if (!v) return undefined;
if (!v.startsWith('/')) return v;
const base = r.endpoints?.http;
return base ? base.replace(/\/+$/, '') + v : undefined;
}
/**
* Decode a datagram; null for anything that is not a well-formed beacon v2
* message (beacon v1, meta-discovery v1, other versions, junk).
*/
export function parseMessage(buf: Buffer): BeaconMessage | null {
let m: BeaconMessage;
try {
m = JSON.parse(buf.toString('utf8')) as BeaconMessage;
} catch {
return null;
}
if (!m || m.proto !== PROTO || m.v !== VERSION) return null;
if (m.type === TYPE_PROBE) return m;
if (m.type === TYPE_ADVERTISE || m.type === TYPE_BYE) {
if (!m.node?.name || !m.node?.instance) return null;
return m;
}
return null;
}
/** IPv4 addresses of every up, non-internal interface. */
function localInterfaceAddresses(): string[] {
const out: string[] = [];
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const ni of nets[name] ?? []) {
// Node has flip-flopped between 'IPv4' and 4 for `family`.
const isV4 = (ni as { family: string | number }).family === 'IPv4' ||
(ni as { family: string | number }).family === 4;
if (isV4 && !ni.internal && ni.address) out.push(ni.address);
}
}
return out;
}
/** First non-internal IPv4 address, or the hostname. Matches the Go helper. */
export function localIPv4(): string {
return localInterfaceAddresses()[0] ?? hostname();
}
function envNum(key: string): number | undefined {
const n = Number(process.env[key]);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
interface Seen {
node: BeaconNodeInfo;
resources: BeaconResource[];
addr: string;
lastSeen: number; // ms
}
function toNeighbor(s: Seen): MeshNeighbor {
const caps: string[] = [];
let baseUrl: string | undefined;
for (const r of s.resources) {
for (const c of r.caps ?? []) if (!caps.includes(c)) caps.push(c);
if (!baseUrl && resourceMatches(r, CAP_ANY_SERVICE)) baseUrl = resourceEndpoint(r, 'ui');
}
return {
...s.node,
resources: s.resources,
caps,
...(baseUrl ? { baseUrl } : {}),
addr: s.addr,
lastSeen: s.lastSeen / 1000,
};
}
/**
* One beacon v2 participant: advertises its resources, answers probes, and
* keeps a TTL map of every other node.
*/
export class MeshNode {
private cfg: Required<Omit<MeshNodeConfig, 'payload'>> & Pick<MeshNodeConfig, 'payload'>;
private socket: dgram.Socket | null = null;
private timer: NodeJS.Timeout | null = null;
private nodes = new Map<string, Seen>();
private ifaceAddrs: string[] = [];
private listeners: ((n: MeshNeighbor) => void)[] = [];
private started = false;
constructor(config: MeshNodeConfig) {
this.cfg = {
name: config.name,
instance: config.instance ?? hostname(),
version: config.version ?? '',
group: config.group ?? process.env.BEACON_GROUP ?? DEFAULT_GROUP,
port: config.port ?? envNum('BEACON_PORT') ?? DEFAULT_PORT,
intervalMs: config.intervalMs ?? envNum('BEACON_INTERVAL_MS') ?? DEFAULT_INTERVAL_MS,
enabled: config.enabled ?? true,
payload: config.payload,
};
}
async start(): Promise<void> {
if (this.started || !this.cfg.enabled) {
if (!this.cfg.enabled) console.log('[beacon] Disabled by configuration');
return;
}
this.started = true;
await new Promise<void>((resolve) => {
// reuseAddr sets SO_REUSEADDR (+ SO_REUSEPORT on Linux for udp4),
// so several listeners can share 9099 on one host.
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
this.socket = socket;
socket.on('message', (buf, rinfo) => this.onMessage(buf, rinfo));
socket.on('error', (err) => {
// Never fatal: a host that blocks multicast must degrade, not
// take the service down.
console.error('[beacon] Socket error:', err.message);
});
socket.bind(this.cfg.port, '0.0.0.0', () => {
try {
socket.setMulticastTTL(1); // link-local only
} catch (e) {
console.warn('[beacon] Could not set multicast TTL:', e);
}
// Join on EVERY interface. A container on two docker networks
// that joins only the default route silently sees one network.
this.ifaceAddrs = localInterfaceAddresses();
let joined = 0;
for (const addr of this.ifaceAddrs) {
try {
socket.addMembership(this.cfg.group, addr);
joined++;
} catch (e) {
console.warn(`[beacon] Join ${this.cfg.group} on ${addr} failed:`, e);
}
}
console.log(
`[beacon] Listening on ${this.cfg.group}:${this.cfg.port} as ` +
`${this.cfg.name}/${this.cfg.instance} (joined ${joined} of ${this.ifaceAddrs.length} interfaces)`
);
resolve();
});
});
this.advertise();
this.probe();
this.timer = setInterval(() => this.advertise(), this.cfg.intervalMs);
// Don't hold the event loop open on shutdown.
this.timer.unref?.();
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
if (this.timer) { clearInterval(this.timer); this.timer = null; }
// Bye, so neighbours drop us immediately rather than waiting out the
// staleness window.
this.multicast({ proto: PROTO, v: VERSION, type: TYPE_BYE, node: this.nodeInfo() });
await new Promise<void>((resolve) => {
if (!this.socket) return resolve();
try { this.socket.close(() => resolve()); } catch { resolve(); }
});
this.socket = null;
this.listeners = [];
}
/**
* Multicast a probe. Every node owning a resource matching one of `want`
* (every node, when empty) replies immediately.
*/
probe(want: string[] = []): void {
const msg: BeaconMessage = { proto: PROTO, v: VERSION, type: TYPE_PROBE, from: this.cfg.instance };
if (want.length) msg.want = want;
this.multicast(msg);
}
/** Every live node, sorted by (name, instance). */
getNeighbors(): MeshNeighbor[] {
const cutoff = Date.now() - this.cfg.intervalMs * LIVENESS_FACTOR;
const out: MeshNeighbor[] = [];
for (const [key, s] of this.nodes) {
if (s.lastSeen < cutoff) { this.nodes.delete(key); continue; }
out.push(toNeighbor(s));
}
return out.sort((a, b) => a.name.localeCompare(b.name) || a.instance.localeCompare(b.instance));
}
/** One row per node name, most recently seen instance wins. */
getNeighborsByName(): MeshNeighbor[] {
const best = new Map<string, MeshNeighbor>();
for (const nb of this.getNeighbors()) {
const cur = best.get(nb.name);
if (!cur || nb.lastSeen > cur.lastSeen) best.set(nb.name, nb);
}
return [...best.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The Scan card: probe everyone, wait `waitMs` for the replies, then
* classify what matches `pattern` against `configured` ([name, url]).
*/
async scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise<ScanReport> {
const started = Date.now();
this.probe();
await new Promise((r) => setTimeout(r, waitMs));
const report = classifyScan(pattern, this.cfg.instance, this.getNeighbors(), configured);
report.durationMs = Date.now() - started;
return report;
}
/** This node as a row, so a UI can render itself without waiting. */
self(): MeshNeighbor {
const { status, resources } = this.payload();
return toNeighbor({ node: this.nodeInfo(status), resources, addr: '', lastSeen: Date.now() });
}
/** Fires for every advertise received (after the self-echo filter). */
onAdvertise(cb: (n: MeshNeighbor) => void): void {
this.listeners.push(cb);
}
private payload(): { status?: string; resources: BeaconResource[] } {
return this.cfg.payload ? this.cfg.payload() : { resources: [] };
}
private nodeInfo(status?: string): BeaconNodeInfo {
const n: BeaconNodeInfo = { name: this.cfg.name, instance: this.cfg.instance };
if (this.cfg.version) n.version = this.cfg.version;
if (status) n.status = status;
return n;
}
private advertiseMsg(): BeaconMessage {
const { status, resources } = this.payload();
return { proto: PROTO, v: VERSION, type: TYPE_ADVERTISE, node: this.nodeInfo(status), resources };
}
private onMessage(buf: Buffer, rinfo: dgram.RemoteInfo): void {
const msg = parseMessage(buf);
if (!msg) return; // v1 or foreign traffic on the shared group
if (msg.type === TYPE_PROBE) {
if (msg.from === this.cfg.instance) return;
const ad = this.advertiseMsg();
const want = msg.want ?? [];
const wanted = want.length === 0 ||
(ad.resources ?? []).some((r) => want.some((p) => resourceMatches(r, p)));
if (wanted) this.replyTo(ad, rinfo);
return;
}
const node = msg.node!;
if (node.instance === this.cfg.instance) return; // our own echo
if (msg.type === TYPE_BYE) { this.nodes.delete(node.instance); return; }
const seen: Seen = { node, resources: msg.resources ?? [], addr: rinfo.address, lastSeen: Date.now() };
this.nodes.set(node.instance, seen);
const nb = toNeighbor(seen);
for (const cb of this.listeners) {
try { cb(nb); } catch (e) { console.error('[beacon] Listener threw:', e); }
}
}
private encode(msg: BeaconMessage): Buffer {
const body = Buffer.from(JSON.stringify(msg));
if (body.length > MAX_DATAGRAM) {
console.warn(`[beacon] ${body.length}-byte datagram exceeds the fragmentation-safe ${MAX_DATAGRAM}`);
}
return body;
}
private advertise(): void {
this.multicast(this.advertiseMsg());
}
private replyTo(msg: BeaconMessage, rinfo: dgram.RemoteInfo): void {
try {
this.socket?.send(this.encode(msg), rinfo.port, rinfo.address);
} catch (e) {
console.warn('[beacon] Reply failed:', e);
}
}
/** Writes once per interface — the default route alone reaches one network. */
private multicast(msg: BeaconMessage): void {
const socket = this.socket;
if (!socket) return;
const body = this.encode(msg);
for (const addr of this.ifaceAddrs) {
try {
socket.setMulticastInterface(addr);
socket.send(body, this.cfg.port, this.cfg.group);
} catch (e) {
console.warn(`[beacon] Send on ${addr} failed:`, e);
}
}
}
}
export interface MetaCoreLocatorConfig {
/** This service's name, for its own advertise. */
serviceName: string;
/** Browser-facing URL for the nav menu. */
baseUrl?: string;
version?: string;
/**
* Explicit meta-core API URL. When set this ALWAYS wins and the wire is
* never consulted for core selection — see the spec's "the
* metamesh.core pin". This is what stops a client box from latching onto a
* gateway box's core when both are reachable on a shared network.
*/
metaCoreUrl?: string;
group?: string;
port?: number;
intervalMs?: number;
enabled?: boolean;
}
/**
* Locates meta-core over beacon v2 (the first `metamesh.core` resource that
* carries `data.urls`), and advertises this service as
* `metamesh.service/<serviceName>`.
*/
export class MetaCoreLocator {
private node: MeshNode;
private cfg: MetaCoreLocatorConfig;
private current: MeshUrls | null = null;
private changeCallbacks: (() => void)[] = [];
constructor(config: MetaCoreLocatorConfig) {
this.cfg = config;
this.node = new MeshNode({
name: config.serviceName,
version: config.version,
group: config.group,
port: config.port,
intervalMs: config.intervalMs,
enabled: config.enabled,
payload: () => ({
resources: [{
id: 'service',
caps: [CAP_SERVICE_PREFIX + config.serviceName],
endpoints: { ui: config.baseUrl ?? `http://${localIPv4()}` },
}],
}),
});
this.node.onAdvertise((nb) => {
if (this.cfg.metaCoreUrl) return; // pinned: the wire cannot move us
const core = nb.resources.find((r) => resourceMatches(r, CAP_CORE));
const urls = core?.data?.urls as MeshUrls | undefined;
if (!urls?.apiUrl) return;
const prev = this.current;
if (!prev) {
this.current = urls;
console.log(`[beacon] meta-core discovered at ${urls.apiUrl} (${nb.addr})`);
this.notifyChange();
return;
}
if (prev.apiUrl === urls.apiUrl) {
this.current = urls; // refresh the rest of the fields
return;
}
// A second, different core is advertising. Do not flap between
// them — keep the first and say so loudly, because on a PCS box
// this means a client container can see the gateway's core.
console.warn(
`[beacon] Ignoring a second meta-core at ${urls.apiUrl} (${nb.addr}); ` +
`staying with ${prev.apiUrl}. Set META_CORE_URL to pin this explicitly.`
);
});
}
async start(): Promise<void> {
if (this.cfg.metaCoreUrl) {
console.log(`[beacon] meta-core pinned to ${this.cfg.metaCoreUrl}; discovery is advisory`);
}
await this.node.start();
}
async stop(): Promise<void> {
await this.node.stop();
this.changeCallbacks = [];
}
/** The pin if set, else whatever discovery elected. */
getApiUrl(): string | null {
if (this.cfg.metaCoreUrl) return this.cfg.metaCoreUrl;
return this.current?.apiUrl ?? null;
}
/** Full URLs block. null when pinned and /urls has not been fetched yet. */
getUrls(): MeshUrls | null {
return this.current;
}
/**
* Block until meta-core is located. Probes immediately and re-probes every
* 500ms — the same loop shape as the old LeaderClient.waitForLeader, so the
* 30s boot timeout semantics are preserved exactly.
*/
async waitForCore(timeoutMs = 30_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
let logged = false;
while (Date.now() < deadline) {
const url = this.getApiUrl();
if (url) return url;
if (!logged) { console.log('[beacon] Waiting for meta-core...'); logged = true; }
this.node.probe([CAP_CORE]);
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`[beacon] No meta-core found within ${timeoutMs}ms`);
}
/** Fires when the elected core's apiUrl changes. Replaces the fs.watch. */
onChange(cb: () => void): void {
this.changeCallbacks.push(cb);
}
/**
* Neighbours for /api/neighbors: one row per name (or every instance with
* `all`), optionally only nodes with a resource matching `cap`.
*/
getNeighbors(opts: { all?: boolean; cap?: string } = {}): MeshNeighbor[] {
const list = opts.all ? this.node.getNeighbors() : this.node.getNeighborsByName();
const cap = opts.cap;
return cap ? list.filter((n) => n.resources.some((r) => resourceMatches(r, cap))) : list;
}
self(): MeshNeighbor {
return this.node.self();
}
/** The Scan card — see {@link MeshNode.scan}. */
scan(pattern: string, configured: Array<[string, string]>, waitMs = 1500): Promise<ScanReport> {
return this.node.scan(pattern, configured, waitMs);
}
probe(want: string[] = []): void {
this.node.probe(want);
}
private notifyChange(): void {
for (const cb of this.changeCallbacks) {
try { cb(); } catch (e) { console.error('[beacon] Change callback threw:', e); }
}
}
}