@bun-win32/netdiag 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AI.md ADDED
@@ -0,0 +1,99 @@
1
+ # @bun-win32/netdiag — AI contract
2
+
3
+ Syscall-grade Windows network diagnostics for Bun, decoded from binary Win32 structs via `bun:ffi`. **No child process, no node-gyp, no Administrator** (ICMP echo + table reads are unprivileged), locale-immune by construction. Windows-only, Bun-only.
4
+
5
+ This file is the complete surface contract: an agent never needs to read the source. Every function below is a named export of `@bun-win32/netdiag` (and the unscoped `bun-netdiag`).
6
+
7
+ ```ts
8
+ import { adapters, defaultGateway, ping, tcpConnections, throughput } from '@bun-win32/netdiag';
9
+ ```
10
+
11
+ ---
12
+
13
+ ## Capability map
14
+
15
+ | function | returns | underlying Win32 API | admin? |
16
+ |---|---|---|---|
17
+ | `adapters(family?)` | `Adapter[]` (ipv4/ipv6, gateways, dns, mac, speed, mtu) | `GetAdaptersAddresses` | no |
18
+ | `routes(family?)` | `Route[]` (destinationPrefix, nextHop, metric, protocol) | `GetIpForwardTable2` + `FreeMibTable` | no |
19
+ | `defaultGateway(family?)` | `string \| undefined` (next hop of the lowest-metric default route) | `GetIpForwardTable2` | no |
20
+ | `neighbors(family?)` / `arpTable()` | `Neighbor[]` (address, mac, state, isRouter) | `GetIpNetTable2` + `FreeMibTable` | no |
21
+ | `tcpConnections(opts?)` | `TcpConnection[]` (socket→PID + optional process name) | `GetExtendedTcpTable` (+ `GetOwnerModuleFromTcpEntry`) | no |
22
+ | `udpListeners(opts?)` | `UdpEndpoint[]` | `GetExtendedUdpTable` | no |
23
+ | `ping(host, opts?)` | `Promise<PingReply>` (alive, roundTripMs, ttl, status) | `IcmpCreateFile` + `IcmpSendEcho` | **no** |
24
+ | `pingMany(hosts, opts?)` | `Promise<PingReply[]>` | `IcmpSendEcho` | no |
25
+ | `pingSweep(prefix, opts?)` | `Promise<SweepReply[]>` (async /24 sweep) | `IcmpSendEcho2` + Win32 Event | no |
26
+ | `traceroute(host, opts?)` | `Promise<TraceHop[]>` (TTL ramp) | `IcmpSendEcho` + `IP_OPTION_INFORMATION32` | **no** |
27
+ | `resolve(name, type?)` | `DnsRecord[]` (typed, discriminated on `type`) | `DnsQuery_W` + `DnsRecordListFree` | no |
28
+ | `reverse(ip)` | `string[]` (PTR names) | `DnsQuery_W` | no |
29
+ | `lookup(name)` | `{ ipv4: string[]; ipv6: string[] }` (system resolver) | `getaddrinfo` | no |
30
+ | `tcpStatistics(family?)` / `ipStatistics()` / `udpStatistics()` | typed counter structs | `GetTcp/Ip/UdpStatisticsEx` | no |
31
+ | `interfaceCounters()` | `InterfaceCounters[]` (octets, errors, discards) | `GetIfTable2` + `FreeMibTable` | no |
32
+ | `throughput(intervalMs?)` | `Promise<ThroughputSample[]>` (rx/tx bytes/sec) | two `GetIfTable2` samples | no |
33
+ | `bestRoute(host)` | `BestRoute` (source IP, next hop, interface the kernel would use) | `GetBestRoute2` | no |
34
+ | `pathMtu(host, opts?)` | `PathMtuResult` (DF-probe binary search) | `IcmpSendEcho` + `IP_FLAG_DF` | no |
35
+ | `wifiInterfaces()` | `WifiInterface[]` | `WlanEnumInterfaces` | no |
36
+ | `wifiScan(opts?)` | `Promise<WifiNetwork[]>` (`triggerScan` for a fresh ~4 s scan) | `WlanGetAvailableNetworkList` (+ `WlanScan`) | no¹ |
37
+ | `wifiConnection(guid?)` | `WifiConnection \| null` (signal, rate, bssid, auth) | `WlanQueryInterface` | no¹ |
38
+ | `wifiBssList(guid?)` | `WifiBss[]` (signed-dBm RSSI, channel) | `WlanGetNetworkBssList` | no¹ |
39
+ | `wifiConnect(profile, guid, {beta})` **beta** / `wifiDisconnect(guid, {beta})` **beta** | `WifiConnectResult` | `WlanConnect` / `WlanDisconnect` | no |
40
+
41
+ ¹ Windows 11 may require a one-time **Location** permission for WiFi SSID visibility (an OS gate — see gotchas).
42
+
43
+ ### Codecs (pure, no FFI) — `addr`
44
+ `ipv4FromU32(value)`, `ipv6FromBytes(buffer, offset)` (RFC 5952), `portFromNetworkOrder(value)`, `macFromBytes(buffer, offset, length)`, `decodeSockaddr(buffer, offset)` → `SocketAddress`.
45
+
46
+ ### Constants / helpers
47
+ `tcpStateName(state)`, `icmpStatusName(status)`, `addressFamilyValue(family)`, `DnsType`, `AF_INET`, `AF_INET6`, `TCP_TABLE_OWNER_PID_ALL`, `ICMP_SUCCESS`, …
48
+
49
+ ### Engine primitives — `win32`
50
+ `SizedBufferState` (the reusable sizing-call buffer), `mibTable(invoke, firstRow, rowSize, decode)` (self-allocating Table2 decode + `FreeMibTable`), `walkList(base, head, nextOffset)` (in-buffer linked-list walk), `readWideAt` / `readAnsiAt`, `Win32Error` / `win32ErrorMessage`. Low-level ICMP: `sendEcho(destination, ttl, timeoutMs, payloadSize, flags?)`, `resolveIPv4(host)`.
51
+
52
+ ---
53
+
54
+ ## Layer 3 — the raw escape hatch
55
+
56
+ Any IP Helper / wlanapi / dnsapi / winsock call netdiag didn't wrap is one import away — the binding packages are re-exported, and `SizedBufferState` / `mibTable` give you the sizing-call and self-alloc patterns:
57
+
58
+ ```ts
59
+ import { Iphlpapi, SizedBufferState } from '@bun-win32/netdiag';
60
+
61
+ const state = new SizedBufferState();
62
+ const view = state.fill((dataPointer, sizePointer) => Iphlpapi.GetNetworkParams(dataPointer, sizePointer));
63
+ const hostname = state.buffer.toString('ascii', 0, state.buffer.indexOf(0));
64
+ ```
65
+
66
+ `Iphlpapi`, `Wlanapi`, `Dnsapi`, `Ws2_32`, `Kernel32` are all re-exported.
67
+
68
+ ---
69
+
70
+ ## Examples
71
+
72
+ ```ts
73
+ import { adapters, defaultGateway, ping, resolve, tcpConnections, throughput, traceroute } from '@bun-win32/netdiag';
74
+
75
+ defaultGateway('ipv4'); // '192.168.0.1' — no wmic, no spawn
76
+ (await ping('1.1.1.1')).roundTripMs; // 11 — ICMP_ECHO_REPLY.RoundTripTime, no admin
77
+ tcpConnections({ resolveNames: 'module' }) // [{ remoteAddress, remotePort, pid, processName: 'opera.exe' }, ...]
78
+ .filter((c) => c.state === 'established');
79
+ await traceroute('8.8.8.8'); // [{ ttl: 1, address: '192.168.0.1', status }, ...]
80
+ resolve('google.com', 'MX'); // [{ type: 'MX', preference: 10, exchange: 'smtp.google.com' }]
81
+ (await throughput(1000))[0]; // { name: 'Wi-Fi', rxBytesPerSec, txBytesPerSec }
82
+ adapters().find((a) => a.gateways.length > 0)?.mac; // '84:14:4d:b0:7d:e0'
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Gotchas
88
+
89
+ - **No admin, scoped honestly.** ICMP (`ping`/`traceroute`/`pingSweep`/`pathMtu`), every table read (routes/neighbors/sockets/adapters/stats), DNS, and WiFi scan/query are all unprivileged. Raw `SIO_RCVALL` packet capture and ARP/ND table *writes* would need Administrator — those are deliberately **not** in netdiag.
90
+ - **`ping().status` on timeout.** Bun's FFI does not reliably preserve `GetLastError` across the boundary, so a 0-reply timeout is reported as `IP_REQ_TIMED_OUT` (11010) rather than guessing another `IP_STATUS`. `alive` is exactly `replied && status === IP_SUCCESS` — never an exit-code or TTL-string heuristic.
91
+ - **Ports are network byte order.** All decoders byteswap via `portFromNetworkOrder`; 443 reads as 443, not 47873.
92
+ - **Wide strings.** Decoded with `buffer.toString('utf16le')` up to NUL (Bun's `TextDecoder` rejects `'utf-16le'`). WiFi SSIDs are read from raw `DOT11_SSID` bytes as UTF-8 — Unicode/emoji-proof.
93
+ - **WiFi connect is a beta.** `wifiConnect`/`wifiDisconnect` are signature-correct but UNEXERCISED against a live AP; they require `{ beta: true }`. Verify by polling `wifiConnection()`, never by callback.
94
+ - **WiFi scan latency.** `wifiScan()` returns the instant OS-cached list; `wifiScan({ triggerScan: true })` runs a fresh `WlanScan` and waits ~4 s (which may flush the prior list).
95
+ - **Win11 WiFi location consent.** `WlanGetAvailableNetworkList`/`WlanGetNetworkBssList` can return `ERROR_ACCESS_DENIED` until precise-location permission is granted (Settings → Privacy → Location). netdiag surfaces this as a distinct error, not an empty list — it is an OS gate, not a netdiag bug.
96
+ - **Never silent.** Table APIs that fail throw `Win32Error` (code + message), never a silent empty array.
97
+ - **`resolve()` vs `lookup()`.** `resolve()` (DnsQuery_W) queries DNS directly and honors the hosts file + OS resolver cache; `lookup()` (getaddrinfo) is the system resolver and may filter AAAA on IPv6-less hosts. They can return different answers — by design.
98
+ - **IPv6 path-MTU / ping** are IPv4 today (roadmap: `Icmp6SendEcho2`).
99
+ - **`throughput` rate uses the measured elapsed time**, so async-sleep quantization can't skew it.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @bun-win32/netdiag
2
+
3
+ **Syscall-grade network diagnostics for Bun on Windows.** Routing table, socket→PID(+module) map, no-admin ICMP ping/traceroute, ARP/neighbors, DNS, live throughput, and WiFi — every result decoded from a **binary Win32 struct** via `bun:ffi`, never scraped from `netsh`/`ping.exe`/`wmic`/`arp`/`netstat` text.
4
+
5
+ - **Zero child processes.** No `wmic`, no `ping.exe`, no `netsh`, no `tracert`, no PowerShell, no CMD-window flash.
6
+ - **Zero native build.** Pure `bun:ffi` — no `node-gyp`, no NAN addon, no Node-version drift.
7
+ - **Zero Administrator.** ICMP echo and every table read are unprivileged. The integration selftest runs in a non-elevated shell.
8
+ - **Zero dependencies** outside the workspace. Every DLL it calls already ships in `C:\Windows\System32`.
9
+ - **Locale-immune by construction.** Struct fields don't translate; a French or German Windows can't break a `DataView.getUint32`.
10
+
11
+ > Windows-only, Bun-only — stated proudly. `bun add bun-netdiag` pulls a few kilobytes of TypeScript and zero native binaries.
12
+
13
+ ## 10-line wow
14
+
15
+ ```ts
16
+ import { defaultGateway, ping, tcpConnections, throughput } from 'bun-netdiag';
17
+
18
+ defaultGateway(); // '192.168.0.1' — GetIpForwardTable2, no wmic
19
+ (await ping('1.1.1.1')).roundTripMs; // 11 — ICMP reply struct, no admin
20
+ tcpConnections({ resolveNames: 'module' }) // socket → PID → owning module, one syscall
21
+ .filter((c) => c.state === 'established')
22
+ .map((c) => `${c.remoteAddress}:${c.remotePort} ${c.pid} ${c.processName}`);
23
+ // 160.79.104.10:443 20408 claude.exe
24
+ // 52.96.157.162:443 24320 opera.exe
25
+ (await throughput(1000))[0]; // { name: 'Wi-Fi', rxBytesPerSec: 82_700_000, txBytesPerSec }
26
+ ```
27
+
28
+ ## Why this exists
29
+
30
+ Every Windows networking package on npm either **shells out** to a localized command and regex-scrapes its stdout, or builds a **node-gyp native addon**. Both break. netdiag reads the same IP Helper / wlanapi / dnsapi structs the OS reads.
31
+
32
+ | incumbent | weekly downloads | what's wrong (receipt) | netdiag |
33
+ |---|---|---|---|
34
+ | `default-gateway` | 8.1M | **GitHub-archived 2026-02-05**; spawns `wmic`, which Microsoft is removing from Windows 11 ([#25](https://github.com/silverwind/default-gateway/issues/25)); maintainer: *"parsing command output is a wrong approach"* ([#27](https://github.com/silverwind/default-gateway/issues/27)) | `defaultGateway()` reads `GetIpForwardTable2` directly |
35
+ | `systeminformation` | 5.87M | spawns PowerShell/`netstat`/`netsh`; sockets are **PID-only** (hardcoded `process:''`); `networkStats` has a **500 ms throttle**; CVE-2021-21315 shell-injection class | `tcpConnections()` adds the **module name**; `throughput()` is sub-ms; no shell to inject |
36
+ | `node-ping` | 190k | spawns `ping.exe` + regex-parses localized stdout — crashes on French Windows (`temps`, [#64](https://github.com/danielzzz/node-ping/issues/64)); `alive` always false ([#26](https://github.com/danielzzz/node-ping/issues/26)) | `ping()` reads `ICMP_ECHO_REPLY.RoundTripTime` (u32) — no locale, ever |
37
+ | `node-wifi` | 2.9k | stale since 2021; `netsh` field-reorder on Win11 → `ssid='connected'` ([#184](https://github.com/friedrith/node-wifi/issues/184)); Unicode SSID mangling ([#198](https://github.com/friedrith/node-wifi/issues/198)) | `wifiScan()`/`wifiConnection()` read wlanapi structs by offset; SSID is raw UTF-8 |
38
+ | `local-devices` / arp scrapers | 3.4k | spawn `arp -a` with a fixed-index parser; `arp -n` Unix flag fails on Windows ([#75](https://github.com/DylanPiercey/local-devices/issues/75)); device name always `?` ([#21](https://github.com/DylanPiercey/local-devices/issues/21)) | `neighbors()` reads `GetIpNetTable2` — typed, with reachability **state** and IPv6 ND; `reverse()` for names |
39
+ | `net-ping` / `raw-socket` | 1.4k+1.8k | `node-gyp` build fails on Node 20/24 ([#91](https://github.com/nospaceships/node-raw-socket/issues/91)); raw ICMP socket needs **Administrator** ([#88](https://github.com/nospaceships/node-net-ping/issues/88)) | `ping()`/`traceroute()` over `IcmpSendEcho` — no node-gyp, no admin, no raw socket |
40
+ | `node-netstat` / `windows-netstat` | 2.7k+6 | spawn `netstat` (LISTEN/LISTENING drift, [#42](https://github.com/danielkrainas/node-netstat/issues/42)) or node-gyp IPv4-only | `tcpConnections('all')` + `udpListeners('all')` — TCP4/6 + UDP4/6, canonical state strings |
41
+
42
+ **No zero-install, pure-FFI Windows network-diagnostics competitor exists on npm** (verified 2026-06-13). The niche is unoccupied.
43
+
44
+ ## Benchmarks
45
+
46
+ Every table is one syscall, not a process spawn. Measured on the host below (`bun run example/benchmark.ts` — `Bun.nanoseconds`, warm-up, DFG-verified):
47
+
48
+ | operation | ns/op | samples/sec |
49
+ |---|---|---|
50
+ | `routes()` poll + decode | ~31,700 | ~31,500 |
51
+ | `tcpConnections()` poll (v4+v6) | ~194,000 | ~5,100 |
52
+ | `IcmpSendEcho` to 127.0.0.1 | ~79,800 | ~12,500 |
53
+ | `interfaceCounters()` poll | ~606,000 | ~1,650 |
54
+
55
+ Spawning `route print` once costs **~70 ms** (~14/sec). `routes()` polls **~2,200× faster** — the difference between a live dashboard and a stuttering one. (Numbers are host-specific; re-run the benchmark.)
56
+
57
+ ## API
58
+
59
+ `adapters` · `routes` / `defaultGateway` · `neighbors` / `arpTable` · `tcpConnections` / `udpListeners` · `ping` / `pingMany` / `pingSweep` / `traceroute` · `resolve` / `reverse` / `lookup` · `tcpStatistics` / `ipStatistics` / `udpStatistics` / `interfaceCounters` / `throughput` · `bestRoute` / `pathMtu` · `wifiInterfaces` / `wifiScan` / `wifiConnection` / `wifiBssList` / `wifiConnect`(beta) / `wifiDisconnect`(beta) · the raw escape hatch (`Iphlpapi`, `Wlanapi`, `Dnsapi`, `Ws2_32`, `Kernel32`).
60
+
61
+ Full surface contract in [`AI.md`](./AI.md). Examples in [`example/`](./example): `net-report`, `netwatch` (live dashboard), `ping`, `wifi-scan`, `benchmark`, `netdiag.selftest`.
62
+
63
+ ## Verified against the OS's own tools
64
+
65
+ `netdiag.selftest.ts` passes 19 assertions in a non-elevated shell, and the cross-validation matches the OS tools exactly: `defaultGateway()` = `ipconfig`, `neighbors()` = `arp -a`, `tcpConnections()` PIDs = `netstat -ano`, `routes()` = `route print`, `resolve()` = `nslookup`, `ping()` TTL = `ping.exe`, `adapters()` MAC = `ipconfig /all`, `wifiConnection()` = `netsh wlan show interfaces`.
66
+
67
+ ## What this does NOT do
68
+
69
+ - **Cross-platform.** Windows-only by design — the Windows backend done right. For Linux/macOS/Node, `systeminformation` / `default-gateway` are the answer.
70
+ - **HTTP reachability.** netdiag is layer 3/4; use Bun's built-in `fetch()` for HTTP-level checks.
71
+ - **Raw packet capture / ARP-table writes.** Those need Administrator and are out of the no-admin scope (the raw escape hatch is there if you need them).
72
+ - **OUI/vendor lookup.** Out of the zero-dependency core.
73
+ - **Bullet-proof WiFi connect.** `wifiConnect`/`wifiDisconnect` ship as a flagged beta, unexercised against a live AP.
74
+
75
+ Roadmap: IPv6 ping/path-MTU (`Icmp6SendEcho2`), ping count/loss statistics + `deadlineMs`, per-connection ESTATS RTT (admin), CIDR/range `discover()`.
76
+
77
+ ## License
78
+
79
+ MIT. Part of [bun-win32](https://github.com/ObscuritySRL/bun-win32).
package/adapters.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { decodeSockaddr, macFromBytes } from './addr';
2
+ import { addressFamilyValue, type AddressFamilyName } from './constants';
3
+ import { Iphlpapi, readAnsiAt, readWideAt, SizedBufferState, walkList } from './win32';
4
+
5
+ // IP_ADAPTER_ADDRESSES_LH (x64) field offsets — verified vs iptypes.h + ipconfig /all (S3.3).
6
+ const NODE_IF_INDEX = 4;
7
+ const NODE_NEXT = 8;
8
+ const NODE_ADAPTER_NAME = 16; // PCHAR (ANSI GUID)
9
+ const NODE_FIRST_UNICAST = 24;
10
+ const NODE_FIRST_DNS_SERVER = 48;
11
+ const NODE_DNS_SUFFIX = 56; // PWCHAR
12
+ const NODE_DESCRIPTION = 64; // PWCHAR
13
+ const NODE_FRIENDLY_NAME = 72; // PWCHAR
14
+ const NODE_PHYSICAL_ADDRESS = 80;
15
+ const NODE_PHYSICAL_ADDRESS_LENGTH = 88;
16
+ const NODE_MTU = 96;
17
+ const NODE_IF_TYPE = 100;
18
+ const NODE_OPER_STATUS = 104;
19
+ const NODE_TRANSMIT_LINK_SPEED = 184; // ULONG64 bits/sec
20
+ const NODE_FIRST_GATEWAY = 208;
21
+
22
+ // Sub-list node (IP_ADAPTER_{UNICAST,GATEWAY,DNS_SERVER}_ADDRESS) shared shape.
23
+ const SUB_NEXT = 8;
24
+ const SUB_SOCKET_ADDRESS_POINTER = 16; // SOCKET_ADDRESS.lpSockaddr
25
+
26
+ const GAA_FLAG_INCLUDE_GATEWAYS = 0x0000_0080;
27
+ const GAA_FLAG_SKIP_ANYCAST = 0x0000_0002;
28
+ const GAA_FLAG_SKIP_MULTICAST = 0x0000_0004;
29
+ const LINK_SPEED_UNKNOWN = 0xffff_ffff_ffff_ffffn;
30
+
31
+ const OPER_STATUS_NAMES: ReadonlyMap<number, string> = new Map([
32
+ [1, 'up'],
33
+ [2, 'down'],
34
+ [3, 'testing'],
35
+ [4, 'unknown'],
36
+ [5, 'dormant'],
37
+ [6, 'not-present'],
38
+ [7, 'lower-layer-down'],
39
+ ]);
40
+
41
+ export interface Adapter {
42
+ index: number;
43
+ name: string;
44
+ friendlyName: string;
45
+ description: string;
46
+ mac: string;
47
+ type: number;
48
+ operStatus: string;
49
+ linkSpeedMbps: number;
50
+ mtu: number;
51
+ ipv4: string[];
52
+ ipv6: string[];
53
+ gateways: string[];
54
+ dnsServers: string[];
55
+ dnsSuffix: string;
56
+ }
57
+
58
+ const adapterState = new SizedBufferState(0x0000_8000);
59
+
60
+ function pointerToOffset(base: Buffer, baseAddress: number, fieldOffset: number): number {
61
+ const pointer = Number(base.readBigUInt64LE(fieldOffset));
62
+ return pointer === 0 ? -1 : pointer - baseAddress;
63
+ }
64
+
65
+ function collectUnicast(base: Buffer, baseAddress: number, headFieldOffset: number, ipv4: string[], ipv6: string[]): void {
66
+ const head = Number(base.readBigUInt64LE(headFieldOffset));
67
+ for (const node of walkList(base, head, SUB_NEXT)) {
68
+ const sockaddrPointer = Number(base.readBigUInt64LE(node + SUB_SOCKET_ADDRESS_POINTER));
69
+ if (sockaddrPointer === 0) continue;
70
+ const address = decodeSockaddr(base, sockaddrPointer - baseAddress);
71
+ if (address.family === 'ipv4') ipv4.push(address.address);
72
+ else if (address.family === 'ipv6') ipv6.push(address.address);
73
+ }
74
+ }
75
+
76
+ function collectAddresses(base: Buffer, baseAddress: number, headFieldOffset: number, out: string[]): void {
77
+ const head = Number(base.readBigUInt64LE(headFieldOffset));
78
+ for (const node of walkList(base, head, SUB_NEXT)) {
79
+ const sockaddrPointer = Number(base.readBigUInt64LE(node + SUB_SOCKET_ADDRESS_POINTER));
80
+ if (sockaddrPointer === 0) continue;
81
+ out.push(decodeSockaddr(base, sockaddrPointer - baseAddress).address);
82
+ }
83
+ }
84
+
85
+ /** Typed adapter inventory (IPv4 + IPv6) over GetAdaptersAddresses — the modern replacement for GetAdaptersInfo. */
86
+ export function adapters(family: AddressFamilyName = 'all'): Adapter[] {
87
+ const familyValue = addressFamilyValue(family);
88
+ const flags = GAA_FLAG_INCLUDE_GATEWAYS | GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST;
89
+ const view = adapterState.fill((dataPointer, sizePointer) => Iphlpapi.GetAdaptersAddresses(familyValue, flags, null, dataPointer, sizePointer));
90
+ const base = adapterState.buffer;
91
+ const baseAddress = Number(base.ptr);
92
+ const result: Adapter[] = [];
93
+
94
+ for (const node of walkList(base, baseAddress, NODE_NEXT)) {
95
+ const physicalLength = view.getUint32(node + NODE_PHYSICAL_ADDRESS_LENGTH, true);
96
+ const linkSpeed = base.readBigUInt64LE(node + NODE_TRANSMIT_LINK_SPEED);
97
+ const ipv4: string[] = [];
98
+ const ipv6: string[] = [];
99
+ const gateways: string[] = [];
100
+ const dnsServers: string[] = [];
101
+ collectUnicast(base, baseAddress, node + NODE_FIRST_UNICAST, ipv4, ipv6);
102
+ collectAddresses(base, baseAddress, node + NODE_FIRST_GATEWAY, gateways);
103
+ collectAddresses(base, baseAddress, node + NODE_FIRST_DNS_SERVER, dnsServers);
104
+
105
+ result.push({
106
+ index: view.getUint32(node + NODE_IF_INDEX, true),
107
+ name: readAnsiAt(base, pointerToOffset(base, baseAddress, node + NODE_ADAPTER_NAME)),
108
+ friendlyName: readWideAt(base, pointerToOffset(base, baseAddress, node + NODE_FRIENDLY_NAME)),
109
+ description: readWideAt(base, pointerToOffset(base, baseAddress, node + NODE_DESCRIPTION)),
110
+ mac: macFromBytes(base, node + NODE_PHYSICAL_ADDRESS, physicalLength),
111
+ type: view.getUint32(node + NODE_IF_TYPE, true),
112
+ operStatus: OPER_STATUS_NAMES.get(view.getInt32(node + NODE_OPER_STATUS, true)) ?? 'unknown',
113
+ linkSpeedMbps: linkSpeed === LINK_SPEED_UNKNOWN ? 0 : Number(linkSpeed / 1_000_000n),
114
+ mtu: view.getUint32(node + NODE_MTU, true),
115
+ ipv4,
116
+ ipv6,
117
+ gateways,
118
+ dnsServers,
119
+ dnsSuffix: readWideAt(base, pointerToOffset(base, baseAddress, node + NODE_DNS_SUFFIX)),
120
+ });
121
+ }
122
+ return result;
123
+ }
package/addr.ts ADDED
@@ -0,0 +1,83 @@
1
+ export interface SocketAddress {
2
+ family: 'ipv4' | 'ipv6' | 'unknown';
3
+ address: string;
4
+ port: number;
5
+ scopeId?: number;
6
+ }
7
+
8
+ const AF_INET = 0x0000_0002;
9
+ const AF_INET6 = 0x0000_0017;
10
+
11
+ /** IPv4 dotted-quad from a u32 holding the four address bytes in memory (network) order. */
12
+ export function ipv4FromU32(value: number): string {
13
+ return `${value & 0xff}.${(value >>> 8) & 0xff}.${(value >>> 16) & 0xff}.${(value >>> 24) & 0xff}`;
14
+ }
15
+
16
+ /** Host-order port from a network-order (big-endian) 16-bit value. */
17
+ export function portFromNetworkOrder(value: number): number {
18
+ return ((value & 0xff) << 8) | ((value >>> 8) & 0xff);
19
+ }
20
+
21
+ /** Colon-separated lowercase-hex MAC from `length` bytes at `offset`. */
22
+ export function macFromBytes(buffer: Buffer, offset: number, length: number): string {
23
+ let mac = '';
24
+ for (let index = 0; index < length; index++) {
25
+ if (index > 0) mac += ':';
26
+ mac += buffer[offset + index].toString(16).padStart(2, '0');
27
+ }
28
+ return mac;
29
+ }
30
+
31
+ /** Canonical RFC 5952 IPv6 text (lowercase, longest-run `::` compression) from 16 bytes at `offset`. */
32
+ export function ipv6FromBytes(buffer: Buffer, offset: number): string {
33
+ const groups: number[] = [];
34
+ for (let index = 0; index < 8; index++) groups.push((buffer[offset + index * 2] << 8) | buffer[offset + index * 2 + 1]);
35
+
36
+ let bestStart = -1;
37
+ let bestLength = 0;
38
+ let runStart = -1;
39
+ let runLength = 0;
40
+ for (let index = 0; index < 8; index++) {
41
+ if (groups[index] === 0) {
42
+ if (runStart === -1) runStart = index;
43
+ runLength++;
44
+ } else {
45
+ runStart = -1;
46
+ runLength = 0;
47
+ }
48
+ if (runLength > bestLength) {
49
+ bestLength = runLength;
50
+ bestStart = runStart;
51
+ }
52
+ }
53
+ if (bestLength < 2) bestStart = -1; // RFC 5952: do not compress a single zero group
54
+
55
+ const segments: string[] = [];
56
+ for (let index = 0; index < 8; index++) {
57
+ if (index === bestStart) {
58
+ segments.push(''); // open the compressed run
59
+ index += bestLength - 1;
60
+ if (index === 7) segments.push(''); // run reaches the end → trailing colon
61
+ continue;
62
+ }
63
+ segments.push(groups[index].toString(16));
64
+ }
65
+ if (bestStart === 0) segments.unshift(''); // run starts at the beginning → leading colon
66
+ return segments.join(':');
67
+ }
68
+
69
+ /**
70
+ * Decode a SOCKADDR_INET union at `offset`: si_family (USHORT) at +0 selects
71
+ * sockaddr_in (port@+2 BE, addr@+4) or sockaddr_in6 (port@+2 BE, addr@+8,
72
+ * scope_id@+24). Ports are network byte order; addresses are formatted.
73
+ */
74
+ export function decodeSockaddr(buffer: Buffer, offset: number): SocketAddress {
75
+ const family = buffer.readUInt16LE(offset);
76
+ if (family === AF_INET) {
77
+ return { family: 'ipv4', address: ipv4FromU32(buffer.readUInt32LE(offset + 4)), port: buffer.readUInt16BE(offset + 2) };
78
+ }
79
+ if (family === AF_INET6) {
80
+ return { family: 'ipv6', address: ipv6FromBytes(buffer, offset + 8), port: buffer.readUInt16BE(offset + 2), scopeId: buffer.readUInt32LE(offset + 24) };
81
+ }
82
+ return { family: 'unknown', address: '', port: 0 };
83
+ }
package/arp.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { decodeSockaddr, macFromBytes } from './addr';
2
+ import { addressFamilyValue, type AddressFamilyName } from './constants';
3
+ import { Iphlpapi, mibTable } from './win32';
4
+
5
+ // MIB_IPNET_TABLE2 { ULONG NumEntries; MIB_IPNET_ROW2 Table[] } — rows 8-aligned.
6
+ const TABLE_FIRST_ROW = 8;
7
+ // MIB_IPNET_ROW2 (x64, netioapi.h) — stride verified end-to-end vs `arp -a` (S4.3).
8
+ const ROW_SIZE = 88;
9
+ const ROW_ADDRESS = 0; // SOCKADDR_INET
10
+ const ROW_INTERFACE_INDEX = 28;
11
+ const ROW_PHYSICAL_ADDRESS = 40;
12
+ const ROW_PHYSICAL_ADDRESS_LENGTH = 72;
13
+ const ROW_STATE = 76;
14
+ const ROW_FLAGS = 80; // bit0 IsRouter, bit1 IsUnreachable
15
+
16
+ const NEIGHBOR_STATE_NAMES: ReadonlyMap<number, string> = new Map([
17
+ [0, 'unreachable'],
18
+ [1, 'incomplete'],
19
+ [2, 'probe'],
20
+ [3, 'delay'],
21
+ [4, 'stale'],
22
+ [5, 'reachable'],
23
+ [6, 'permanent'],
24
+ ]);
25
+
26
+ export interface Neighbor {
27
+ address: string;
28
+ mac: string;
29
+ interfaceIndex: number;
30
+ state: string;
31
+ isRouter: boolean;
32
+ isUnreachable: boolean;
33
+ family: 'ipv4' | 'ipv6' | 'unknown';
34
+ }
35
+
36
+ /** The typed neighbor (ARP + IPv6 ND) table over GetIpNetTable2 — richer than `arp -a` (reachability STATE, IPv6). */
37
+ export function neighbors(family: AddressFamilyName = 'all'): Neighbor[] {
38
+ const familyValue = addressFamilyValue(family);
39
+ return mibTable(
40
+ (tablePointer) => Iphlpapi.GetIpNetTable2(familyValue, tablePointer),
41
+ TABLE_FIRST_ROW,
42
+ ROW_SIZE,
43
+ (table, row) => {
44
+ const address = decodeSockaddr(table, row + ROW_ADDRESS);
45
+ const flags = table.readUInt8(row + ROW_FLAGS);
46
+ return {
47
+ address: address.address,
48
+ mac: macFromBytes(table, row + ROW_PHYSICAL_ADDRESS, table.readUInt32LE(row + ROW_PHYSICAL_ADDRESS_LENGTH)),
49
+ interfaceIndex: table.readUInt32LE(row + ROW_INTERFACE_INDEX),
50
+ state: NEIGHBOR_STATE_NAMES.get(table.readUInt32LE(row + ROW_STATE)) ?? 'unknown',
51
+ isRouter: (flags & 0x1) !== 0,
52
+ isUnreachable: (flags & 0x2) !== 0,
53
+ family: address.family,
54
+ };
55
+ },
56
+ );
57
+ }
58
+
59
+ /** The classic IPv4-only ARP table view (a familiar `arp -a` shape over the modern API). */
60
+ export function arpTable(): Neighbor[] {
61
+ return neighbors('ipv4');
62
+ }
package/constants.ts ADDED
@@ -0,0 +1,75 @@
1
+ export { DnsType } from '@bun-win32/dnsapi';
2
+
3
+ export type AddressFamilyName = 'all' | 'ipv4' | 'ipv6';
4
+
5
+ const ADDRESS_FAMILY_VALUES: Record<AddressFamilyName, number> = {
6
+ all: 0x0000_0000, // AF_UNSPEC
7
+ ipv4: 0x0000_0002, // AF_INET
8
+ ipv6: 0x0000_0017, // AF_INET6
9
+ };
10
+
11
+ export const AF_INET = 0x0000_0002;
12
+ export const AF_INET6 = 0x0000_0017;
13
+
14
+ export function addressFamilyValue(family: AddressFamilyName): number {
15
+ return ADDRESS_FAMILY_VALUES[family];
16
+ }
17
+
18
+ // GetExtendedTcpTable / GetExtendedUdpTable table-class selectors (TCP_TABLE_CLASS / UDP_TABLE_CLASS).
19
+ export const TCP_TABLE_OWNER_MODULE_ALL = 0x0000_0008;
20
+ export const TCP_TABLE_OWNER_PID_ALL = 0x0000_0005;
21
+ export const TCPIP_OWNER_MODULE_INFO_BASIC = 0x0000_0000;
22
+ export const UDP_TABLE_OWNER_PID = 0x0000_0001;
23
+
24
+ // MIB_TCP_STATE (1..12).
25
+ const TCP_STATE_NAMES: ReadonlyMap<number, string> = new Map([
26
+ [1, 'closed'],
27
+ [2, 'listen'],
28
+ [3, 'syn-sent'],
29
+ [4, 'syn-received'],
30
+ [5, 'established'],
31
+ [6, 'fin-wait-1'],
32
+ [7, 'fin-wait-2'],
33
+ [8, 'close-wait'],
34
+ [9, 'closing'],
35
+ [10, 'last-ack'],
36
+ [11, 'time-wait'],
37
+ [12, 'delete-tcb'],
38
+ ]);
39
+
40
+ export function tcpStateName(state: number): string {
41
+ return TCP_STATE_NAMES.get(state) ?? `unknown(${state})`;
42
+ }
43
+
44
+ // ICMP reply status codes (ipexport.h IP_STATUS).
45
+ export const ICMP_SUCCESS = 0x0000_0000;
46
+ export const ICMP_PACKET_TOO_BIG = 11009;
47
+ export const ICMP_REQUEST_TIMED_OUT = 11010;
48
+ export const ICMP_TTL_EXPIRED_TRANSIT = 11013;
49
+
50
+ const ICMP_STATUS_NAMES: ReadonlyMap<number, string> = new Map([
51
+ [0, 'success'],
52
+ [11001, 'buffer too small'],
53
+ [11002, 'destination network unreachable'],
54
+ [11003, 'destination host unreachable'],
55
+ [11004, 'destination protocol unreachable'],
56
+ [11005, 'destination port unreachable'],
57
+ [11006, 'no resources'],
58
+ [11007, 'bad option'],
59
+ [11008, 'hardware error'],
60
+ [11009, 'packet too big'],
61
+ [11010, 'request timed out'],
62
+ [11011, 'bad request'],
63
+ [11012, 'bad route'],
64
+ [11013, 'TTL expired in transit'],
65
+ [11014, 'TTL expired during reassembly'],
66
+ [11015, 'parameter problem'],
67
+ [11016, 'source quench'],
68
+ [11017, 'option too big'],
69
+ [11018, 'bad destination'],
70
+ [11050, 'general failure'],
71
+ ]);
72
+
73
+ export function icmpStatusName(status: number): string {
74
+ return ICMP_STATUS_NAMES.get(status) ?? `unknown(${status})`;
75
+ }