Node.js 局域网设备发现:mDNS、UDP 广播和子网扫描

场景是这样的:几台机器跑在同一个局域网里,开发服务器、NAS、树莓派都有。我需要它们自动发现彼此。不要中心服务器,不要手动配 IP 列表,不要"改完 YAML 重启一下"。

启动就能找到。

最后用了三层方案叠加:mDNS、一个从 90 年代 Novell NetWare 偷来的 UDP 广播协议、以及暴力子网扫描兜底。全部加起来大概 300 行 TypeScript。

mDNS:理论上很好

第一版只用 mDNS。典型的网络拓扑:

scss 复制代码
笔记本 (Wi-Fi) → 办公室 AP → 路由器 → 跑服务的 server

理论上 mDNS 能搞定。实际上在家里的 MacBook 没问题,小办公室也行,到了真实网络环境就崩了。路由器静默丢弃组播包。AP 开了客户端隔离。有个公司网络直接禁了组播,IT 部门自己都不知道为什么。

Node.js 里用 mDNS 的几个坑:

  • 没有内置支持,得装 multicast-dns 这个 npm 包
  • macOS 有 Bonjour 原生支持,Linux 要装 avahi,Windows 看运气
  • 5353 端口容易跟已经在跑的 avahi-daemon 冲突
  • 失败的时候没有任何提示。查询发出去,石沉大海

从 90 年代 NetWare 偷的思路

Novell NetWare 有个叫 SAP(Service Advertising Protocol)的东西。每个服务器定期往整个子网广播:"我在这儿,我能干这些事"。不需要组播组,不需要特殊基础设施。

我在 UDP/IP 上实现了一样的机制。两种消息类型:

typescript 复制代码
interface DiscoverMessage {
  type: "discover";
  version: 1;
}

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

节点启动后广播 discover,已经在跑的实例回复 announce。同时有个定时器周期性广播 announce,这样后加入的节点也能被发现。

Socket 初始化

typescript 复制代码
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); // bind 阶段失败
      }
    });

    socket.bind(DISCOVERY_PORT, () => {
      socket.setBroadcast(true); // 千万别忘了这行
      resolve(socket);
    });
  });
}

这个地方我浪费了两个小时。跑 tcpdump,盯 Wireshark 抓包,换端口,怀疑防火墙。

最后发现是忘了 socket.setBroadcast(true)。Node.js 不加这个就不能往广播地址发包------不报错,不警告,包直接消失。

广播地址计算

多网卡的机器上不能只用 255.255.255.255,得算每个子网的广播地址:

typescript 复制代码
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;
}

// 广播地址 = IP | ~掩码
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 配上掩码 255.255.255.0,算出来就是 192.168.1.255。IP 跟反转掩码做按位或。

过滤虚拟网卡

Docker 加上 WireGuard VPN 一跑,我的服务就开始"发现"一堆不存在的节点。往 Docker 桥接网络和 VPN 隧道广播,收回来的全是垃圾。

typescript 复制代码
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
  );
}

硬编码前缀列表,没什么好办法。os.networkInterfaces() 不告诉你哪个是物理网卡哪个是虚拟的。Linux 上可以解析 sysfs,但 macOS 和 Windows 上不行。

TCP 探测再注册

UDP 是不可靠的。收到 announce 不能直接信,先探测一下:

typescript 复制代码
async function handleAnnounce(
  data: AnnounceMessage,
  rinfo: dgram.RemoteInfo,
): Promise<void> {
  // 自己的广播回来了------是的这会发生
  if (isSelfIp(rinfo.address)) return;

  // 不在本子网?拒绝
  if (!isInLocalSubnet(rinfo.address)) return;

  // 先 TCP 连一下再说
  const alive = await tcpProbe(rinfo.address, data.port);
  if (!alive) return;

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

可能收到 30 秒前已经挂掉的服务的 announce。TCP 健康检查加 2 秒超时,过滤掉这些幽灵节点。

还有一个问题:自己发的广播会回到自己这里。我发现的时候是因为服务一直在"发现"自己。

广播抖动

不加随机化的话,10 个同时启动的实例会永远在同一秒广播:

typescript 复制代码
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);
}

60 秒基础间隔加 0-10 秒随机抖动。NTP 也是这么干的。

mDNS 还是得用

虽然上面吐了一堆槽,mDNS 我还是保留了,跟广播协议并行跑。家庭网络上 mDNS 没问题,而且别的程序也能通过 mDNS 发现你的服务。

typescript 复制代码
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}`);
  }
});

有个坑:很多服务绑定 127.0.0.1 然后通过 mDNS 广告这个地址。你连上去发现连的是自己。

解决办法------A 记录是 loopback 的时候,用 UDP 包的源 IP:

typescript 复制代码
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;

mDNS 我做成了软依赖------multicast-dns 没装的话就退化成纯广播模式:

typescript 复制代码
let mdns = null;
try {
  const mDNS = require("multicast-dns");
  mdns = mDNS();
} catch {
  console.warn("mDNS 不可用,仅使用广播模式");
}

最后手段:子网扫描

被动发现都不好使的时候,还有暴力扫描:

typescript 复制代码
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 个 worker 并发跑
  const workers = Array.from(
    { length: Math.min(CONCURRENCY, targets.length) },
    () => worker(),
  );
  await Promise.allSettled(workers);
}

50 并发探测,2 秒超时。一个 /24 子网大概 10 秒扫完。我把它做成按需调用的 API 接口。

回头看

虚拟网卡过滤应该第一天就做。这个问题浪费了一整个晚上------我一直以为广播协议有 bug,直到发现 Docker 桥接网络在愉快地回复我的广播包。

UDP 消息应该用二进制头而不是 JSON。有个坏掉的 mDNS 响应者发了非法数据,直接把 JSON.parse 炸了。我知道它可能抛异常,但没包 try-catch。

Windows 上要早点测。防火墙会静默屏蔽自定义端口的 UDP。最后加了 netsh advfirewall 规则创建。

整个方案只依赖 Node 内置的 dgramos 模块,外加一个可选的 multicast-dns

相关推荐
盐焗乳鸽还要砂锅1 小时前
亲手造一只有灵魂的 AI 小龙虾是种什么体验?
前端·llm·agent
YimWu1 小时前
Opencode 核心设计-Session会话机制
前端·agent·ai编程
Mintopia1 小时前
诗词如何影响人:从认知机制到可落地的文本分析技术路线
前端·代码规范
WaywardOne1 小时前
iOS必看!Deepseek给的Runtime实现原理,通俗易懂~
前端·面试
小码哥_常1 小时前
惊!Kotlin集合,你可能只用了40%?
前端
Wect1 小时前
LeetCode 52. N 皇后 II:回溯算法高效求解
前端·算法·typescript
毛骗导演1 小时前
万字解析 OpenClaw 源码架构-跨平台应用之 iOS 应用
前端·ios·架构
刀断青1 小时前
Flutter 开发之第一个Flutter应用
前端
gyx_这个杀手不太冷静1 小时前
OpenCode 进阶使用指南(第五章:最佳实践)
前端·ai编程