Zum Hauptinhalt springen
tsecurity.de LIVE
Echtzeit-Radar & Feeds
Alle RSS Feeds
👥 Community & Social
Intelligence View
⚡ tsecurity.de Intelligence

Building Zero-Config LAN Discovery in Node.js (mDNS + UDP Broadcast)

I was building a small daemon that runs on multiple machines in a LAN — think a couple of dev servers, a NAS, maybe a Raspberry Pi. I wanted them to discover each other automatically. No central server, no config files listing IP a…

0
↗ Quelle (dev.to)
Reagiere als Erste:r — dein Feedback zählt!

I was building a small daemon that runs on multiple machines in a LAN — think a couple of dev servers, a NAS, maybe a Raspberry Pi. I wanted them to discover each other automatically. No central server, no config files listing IP addresses, no "go edit this YAML and restart."



Just start it and it should find its peers.



I ended up layering three approaches: mDNS, a custom UDP broadcast protocol I basically stole from 1990s Novell NetWare, and a brute-force subnet scanner as the last resort. The whole thing is ~300 lines of TypeScript.






mDNS: great on paper



First attempt was pure mDNS. Typical setup:




Laptop (Wi-Fi) → office AP → corporate router → server running the daemon






In theory mDNS handles this fine. In practice it worked on my MacBook at home, worked in a small office, then fell apart on real networks. Routers silently dropping multicast. Access points with client isolation. One corporate network had multicast disabled and nobody in IT could tell me why.



Node.js mDNS annoyances:




  • No built-in support — you need the multicast-dns npm package

  • macOS handles it natively (Bonjour), Linux needs avahi, Windows is a coin flip

  • Port 5353 conflicts if avahi-daemon is already running

  • Fails silently. Your queries just go into the void.






Stealing from 1990s NetWare



Novell NetWare had SAP — Service Advertising Protocol. Every server periodically broadcasts "hey, I exist, here's what I do" to the entire subnet. No multicast groups, no special infrastructure.



I built the same thing on UDP/IP. Two message types:




interface DiscoverMessage {
type: "discover";
version: 1;
}

interface AnnounceMessage {
type: "announce";
version: 1;
instance_id: string;
display_name: string;
port: number;
tls: boolean;
}






Node starts up, broadcasts discover. Running instances reply with announce. Periodic announce on a timer so late joiners get found too.






Socket setup






import * as dgram from "node:dgram";
import * as os from "node:os";

const DISCOVERY_PORT = 17891;

async function startDiscovery(): Promise<dgram.Socket> {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket("udp4");

socket.on("error", (err) => {
if (!socket.address()) {
reject(err); // failed during bind
}
});

socket.bind(DISCOVERY_PORT, () => {
socket.setBroadcast(true); // DO NOT FORGET THIS
resolve(socket);
});
});
}






I wasted two hours on this. Ran tcpdump, stared at Wireshark captures, swapped ports, blamed the firewall.



Turns out I forgot socket.setBroadcast(true). Node.js won't send to broadcast addresses without it — doesn't throw, doesn't warn. The packets just vanish.






Broadcast addresses



On a machine with multiple NICs you need the per-subnet broadcast address, not just 255.255.255.255:




function getBroadcastTargets(): string[] {
const targets: string[] = [];
const ifaces = os.networkInterfaces();

for (const [name, addrs] of Object.entries(ifaces)) {
if (!addrs || isVirtualInterface(name)) continue;
for (const addr of addrs) {
if (addr.family === "IPv4" && !addr.internal) {
targets.push(calcBroadcast(addr.address, addr.netmask));
}
}
}
return targets;
}

// broadcast = IP | ~mask, nothing fancy
function calcBroadcast(ip: string, mask: string): string {
const ipParts = ip.split(".").map(Number);
const maskParts = mask.split(".").map(Number);
return ipParts
.map((b, i) => (b | (~maskParts[i]! & 0xff)))
.join(".");
}






192.168.1.42 with mask 255.255.255.0192.168.1.255. Bitwise OR with inverted netmask.






Filtering virtual interfaces



Docker running + WireGuard VPN = my daemon "discovering" phantom peers. Broadcasting over Docker bridges and VPN tunnels, getting garbage back.




function isVirtualInterface(name: string): boolean {
const n = name.toLowerCase();
return (
n.startsWith("wg") || // WireGuard
n.startsWith("tun") || // TUN (OpenVPN)
n.startsWith("tap") || // TAP
n.startsWith("docker") || // Docker
n.startsWith("br-") || // Docker bridge
n.startsWith("veth") || // Docker veth
n.startsWith("virbr") || // KVM
n.startsWith("vmnet") || // VMware
n.startsWith("vboxnet") // VirtualBox
);
}






Hardcoded prefix list. os.networkInterfaces() gives you zero info about physical vs virtual. On Linux you could parse sysfs but that doesn't help on macOS or Windows.






TCP probe before registering



UDP is unverified. Before registering a peer:




async function handleAnnounce(
data: AnnounceMessage,
rinfo: dgram.RemoteInfo,
): Promise<void> {
// own broadcast echoing back — yes this happens
if (isSelfIp(rinfo.address)) return;

// not on our subnet? reject
if (!isInLocalSubnet(rinfo.address)) return;

// actually try to connect before believing it
const alive = await tcpProbe(rinfo.address, data.port);
if (!alive) return;

registerPeer({
id: data.instance_id,
address: rinfo.address,
port: data.port,
source: "broadcast",
});
}






You might get an announce from a service that crashed 30 seconds ago. TCP health check with 2-second timeout filters out the ghosts.



Your own broadcasts also echo back. Found that when my daemon kept discovering itself.






Announce jitter



Without randomization, 10 simultaneous instances all broadcast at the exact same second forever:




const ANNOUNCE_INTERVAL = 60_000;
const ANNOUNCE_JITTER = 10_000;

function scheduleNextAnnounce(socket: dgram.Socket): void {
const delay = ANNOUNCE_INTERVAL + Math.random() * ANNOUNCE_JITTER;
setTimeout(() => {
broadcastAnnounce(socket);
scheduleNextAnnounce(socket);
}, delay);
}






60s base + 0-10s random jitter. Same thing NTP does.






I still use mDNS though



Despite everything above, I run mDNS alongside the broadcast protocol. On home networks it works fine, and other programs can discover your service too.




const mDNS = require("multicast-dns");
const mdns = mDNS();

const SERVICE_TYPE = "_my-service._tcp.local";

setInterval(() => {
mdns.query({
questions: [{ name: SERVICE_TYPE, type: "PTR" }],
});
}, 30_000);

mdns.on("response", (response, rinfo) => {
const allRecords = [
...response.answers,
...response.additionals,
];

const srvRecords = allRecords.filter((r) => r.type === "SRV");
const aRecords = allRecords.filter((r) => r.type === "A");

for (const srv of srvRecords) {
const port = srv.data.port;
const target = srv.data.target;

const aRecord = aRecords.find((r) => r.name === target);
const ip = aRecord ? String(aRecord.data) : rinfo.address;

console.log(`Found peer: ${ip}:${port}`);
}
});






One gotcha: lots of services bind to 127.0.0.1 and advertise that via mDNS. You connect to the A record and hit yourself.



Fix — use the UDP packet's source IP when the A record is loopback:




const aRecordIp = aRecord ? String(aRecord.data) : "";
const isLoopback =
aRecordIp === "127.0.0.1" ||
aRecordIp === "::1" ||
aRecordIp.startsWith("127.");

const address = aRecordIp && !isLoopback
? aRecordIp
: rinfo.address;






I made mDNS a soft dependency — if multicast-dns isn't installed, broadcast-only mode:




let mdns = null;
try {
const mDNS = require("multicast-dns");
mdns = mDNS();
} catch {
console.warn("mDNS unavailable, broadcast-only mode");
}









Last resort: subnet scan



When passive discovery doesn't cut it:




const CONCURRENCY = 50;
const TIMEOUT = 2_000;

async function scanSubnet(subnet: string, port: number) {
const targets = [];
for (let i = 1; i <= 254; i++) {
targets.push({ host: `${subnet}.${i}`, port });
}

const queue = [...targets];

const worker = async () => {
while (queue.length > 0) {
const target = queue.shift()!;
const alive = await probeHost(target.host, target.port);
if (alive) console.log(`Found: ${target.host}:${target.port}`);
}
};

// 50 workers chewing through the queue
const workers = Array.from(
{ length: Math.min(CONCURRENCY, targets.length) },
() => worker(),
);
await Promise.allSettled(workers);
}






50 concurrent probes, 2-second timeout. Full /24 in about 10 seconds. I expose it as an on-demand API endpoint.






What I'd change



Should have filtered virtual interfaces on day one. That one wasted an entire evening — I kept thinking my broadcast protocol was broken until I noticed Docker bridges were happily replying to my packets.



Should have used a binary header for UDP instead of raw JSON. Would have prevented the JSON.parse crash when a broken mDNS responder sent invalid data. I knew it could throw. Didn't wrap it. Classic.



Test on Windows earlier. The firewall silently blocks UDP on custom ports. Ended up adding netsh advfirewall rule creation.



Only dependencies are dgram and os from Node, plus multicast-dns if you want it.






Fought with mDNS on corporate networks? What ended up working?

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Building Zero-Config LAN Discovery in Node.js (mDNS + UDP Broadcast)

Thematisch verwandte Begriffe: Building, ZeroConfig, Discovery, Nodejs · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-17636 | IBM Financial Transaction Manager (FTM) for RedHat OpenShift could allow…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen

tsecurity.de Live Threat Radar

🔴 LIVE RADAR
MONITORING
AKTIV
CVE-DATENBANK
LIVE
🔍
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Bearbeitungsmodus — Senden überschreibt deine Nachricht
Community-Puls — was gerade passiert
lädt…
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
Zurück Ziehen Vor
Links: vorheriger Artikel Rechts: nächster Artikel unten: schließen
News NIS-2 Frühwarnung Tier-1 Intel ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

Zurück: vorheriger Vor: nächster
↗ Original-Quelle
Social Reaktionen Deine Reaktion zählt
Einstufung & Relevanz-Poll 0 Stimmen
In sozialen Netzwerken teilen 1-Klick