🔧 Programmierung 🕛 vor 6 Monaten 6 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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:




CODE
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:




CODE
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






CODE
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:




CODE
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.




CODE
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:




CODE
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:




CODE
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.




CODE
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:




CODE
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:




CODE
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:




CODE
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?

Vollständiger Original-Artikel
Den kompletten Beitrag mit allen Details direkt auf dev.to lesen.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
9 Quellen
CVE-2022-44169 | Tenda AC15 15.03.05.18 formSetVirtualSer buffer overflow (EUVD-2022-47119)
1 Quelle
Best early October Prime Day deals: Save on TVs, smartwatches, and more tech
1 Quelle
I gave Claude Code $100 and 30 days to make a profit. Day 1, it built a product. Here's the pattern it used.
Ä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...

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 ...