@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 +99 -0
- package/README.md +79 -0
- package/adapters.ts +123 -0
- package/addr.ts +83 -0
- package/arp.ts +62 -0
- package/constants.ts +75 -0
- package/dns.ts +161 -0
- package/extras.ts +76 -0
- package/index.ts +13 -0
- package/package.json +85 -0
- package/ping.ts +193 -0
- package/routes.ts +80 -0
- package/sockets.ts +179 -0
- package/stats.ts +220 -0
- package/traceroute.ts +35 -0
- package/wifi.ts +363 -0
- package/win32.ts +144 -0
package/dns.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { type Pointer, toArrayBuffer } from 'bun:ffi';
|
|
2
|
+
|
|
3
|
+
import { ipv4FromU32, ipv6FromBytes } from './addr';
|
|
4
|
+
import { DnsType } from './constants';
|
|
5
|
+
import { Dnsapi, readWideAt, Ws2_32 } from './win32';
|
|
6
|
+
|
|
7
|
+
const DNS_QUERY_STANDARD = 0x0000_0000;
|
|
8
|
+
const DNS_FREE_RECORD_LIST = 0x0000_0001; // DnsFreeType.DnsFreeRecordList
|
|
9
|
+
const AF_INET = 0x0000_0002;
|
|
10
|
+
const AF_INET6 = 0x0000_0017;
|
|
11
|
+
|
|
12
|
+
export type RecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'NS' | 'PTR' | 'SOA' | 'SRV' | 'TXT';
|
|
13
|
+
|
|
14
|
+
export type DnsRecord =
|
|
15
|
+
| { type: 'A'; address: string; ttl: number }
|
|
16
|
+
| { type: 'AAAA'; address: string; ttl: number }
|
|
17
|
+
| { type: 'CNAME'; name: string; ttl: number }
|
|
18
|
+
| { type: 'MX'; preference: number; exchange: string; ttl: number }
|
|
19
|
+
| { type: 'NS'; name: string; ttl: number }
|
|
20
|
+
| { type: 'PTR'; name: string; ttl: number }
|
|
21
|
+
| { type: 'SOA'; primary: string; admin: string; serial: number; refresh: number; retry: number; expire: number; minimumTtl: number; ttl: number }
|
|
22
|
+
| { type: 'SRV'; priority: number; weight: number; port: number; target: string; ttl: number }
|
|
23
|
+
| { type: 'TXT'; strings: string[]; ttl: number };
|
|
24
|
+
|
|
25
|
+
export interface LookupResult {
|
|
26
|
+
ipv4: string[];
|
|
27
|
+
ipv6: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RECORD_TYPE_VALUES: Record<RecordType, number> = {
|
|
31
|
+
A: DnsType.DNS_TYPE_A,
|
|
32
|
+
AAAA: DnsType.DNS_TYPE_AAAA,
|
|
33
|
+
CNAME: DnsType.DNS_TYPE_CNAME,
|
|
34
|
+
MX: DnsType.DNS_TYPE_MX,
|
|
35
|
+
NS: DnsType.DNS_TYPE_NS,
|
|
36
|
+
PTR: DnsType.DNS_TYPE_PTR,
|
|
37
|
+
SOA: DnsType.DNS_TYPE_SOA,
|
|
38
|
+
SRV: DnsType.DNS_TYPE_SRV,
|
|
39
|
+
TXT: DnsType.DNS_TYPE_TEXT,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let winsockReady = false;
|
|
43
|
+
function ensureWinsock(): void {
|
|
44
|
+
if (winsockReady) return;
|
|
45
|
+
Ws2_32.WSAStartup(0x0202, Buffer.allocUnsafeSlow(0x0198).ptr);
|
|
46
|
+
winsockReady = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readWidePointer(pointer: number): string {
|
|
50
|
+
return pointer === 0 ? '' : readWideAt(Buffer.from(toArrayBuffer(pointer as Pointer, 0, 0x0400)), 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// DNS_RECORD data union (x64): each per-type payload begins at +32; wide-string fields are SEPARATELY-allocated pointers.
|
|
54
|
+
function decodeRecord(addr: Pointer, type: RecordType, ttl: number): DnsRecord {
|
|
55
|
+
const node = Buffer.from(toArrayBuffer(addr, 0, 72)); // header(32) + largest fixed payload (SOA)
|
|
56
|
+
switch (type) {
|
|
57
|
+
case 'A':
|
|
58
|
+
return { type, address: ipv4FromU32(node.readUInt32LE(32)), ttl };
|
|
59
|
+
case 'AAAA':
|
|
60
|
+
return { type, address: ipv6FromBytes(node, 32), ttl };
|
|
61
|
+
case 'CNAME':
|
|
62
|
+
case 'NS':
|
|
63
|
+
case 'PTR':
|
|
64
|
+
return { type, name: readWidePointer(Number(node.readBigUInt64LE(32))), ttl };
|
|
65
|
+
case 'MX':
|
|
66
|
+
return { type, exchange: readWidePointer(Number(node.readBigUInt64LE(32))), preference: node.readUInt16LE(40), ttl };
|
|
67
|
+
case 'SRV':
|
|
68
|
+
return { type, target: readWidePointer(Number(node.readBigUInt64LE(32))), priority: node.readUInt16LE(40), weight: node.readUInt16LE(42), port: node.readUInt16LE(44), ttl };
|
|
69
|
+
case 'SOA':
|
|
70
|
+
return {
|
|
71
|
+
type,
|
|
72
|
+
primary: readWidePointer(Number(node.readBigUInt64LE(32))),
|
|
73
|
+
admin: readWidePointer(Number(node.readBigUInt64LE(40))),
|
|
74
|
+
serial: node.readUInt32LE(48),
|
|
75
|
+
refresh: node.readUInt32LE(52),
|
|
76
|
+
retry: node.readUInt32LE(56),
|
|
77
|
+
expire: node.readUInt32LE(60),
|
|
78
|
+
minimumTtl: node.readUInt32LE(64),
|
|
79
|
+
ttl,
|
|
80
|
+
};
|
|
81
|
+
case 'TXT': {
|
|
82
|
+
const count = node.readUInt32LE(32);
|
|
83
|
+
const array = Buffer.from(toArrayBuffer(addr, 0, 40 + count * 8)); // dwStringCount@32, pStringArray@40
|
|
84
|
+
const strings: string[] = [];
|
|
85
|
+
for (let index = 0; index < count; index++) strings.push(readWidePointer(Number(array.readBigUInt64LE(40 + index * 8))));
|
|
86
|
+
return { type, strings, ttl };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const queryOut = Buffer.allocUnsafeSlow(8);
|
|
92
|
+
|
|
93
|
+
/** Query DNS directly (DnsQuery_W) for a typed record set — arbitrary record types, beyond node:dns's fixed list. */
|
|
94
|
+
export function resolve<T extends RecordType>(name: string, type: T): Extract<DnsRecord, { type: T }>[];
|
|
95
|
+
export function resolve(name: string): Extract<DnsRecord, { type: 'A' }>[];
|
|
96
|
+
export function resolve(name: string, type: RecordType = 'A'): DnsRecord[] {
|
|
97
|
+
const typeValue = RECORD_TYPE_VALUES[type];
|
|
98
|
+
const wide = Buffer.allocUnsafeSlow((name.length + 1) * 2);
|
|
99
|
+
wide.writeUInt16LE(0, wide.write(name, 'utf16le'));
|
|
100
|
+
const status = Dnsapi.DnsQuery_W(wide.ptr, typeValue, DNS_QUERY_STANDARD, null, queryOut.ptr, null);
|
|
101
|
+
if (status !== 0) return [];
|
|
102
|
+
const head = Number(queryOut.readBigUInt64LE(0));
|
|
103
|
+
if (head === 0) return [];
|
|
104
|
+
const records: DnsRecord[] = [];
|
|
105
|
+
try {
|
|
106
|
+
let current = head;
|
|
107
|
+
while (current !== 0) {
|
|
108
|
+
const node = Buffer.from(toArrayBuffer(current as Pointer, 0, 32));
|
|
109
|
+
if (node.readUInt16LE(16) === typeValue) records.push(decodeRecord(current as Pointer, type, node.readUInt32LE(24)));
|
|
110
|
+
current = Number(node.readBigUInt64LE(0));
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
Dnsapi.DnsRecordListFree(head as Pointer, DNS_FREE_RECORD_LIST);
|
|
114
|
+
}
|
|
115
|
+
return records;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ipv6ToArpa(ip: string): string {
|
|
119
|
+
ensureWinsock();
|
|
120
|
+
const bytes = Buffer.allocUnsafeSlow(16);
|
|
121
|
+
const wide = Buffer.allocUnsafeSlow((ip.length + 1) * 2);
|
|
122
|
+
wide.writeUInt16LE(0, wide.write(ip, 'utf16le'));
|
|
123
|
+
if (Ws2_32.InetPtonW(AF_INET6, wide.ptr, bytes.ptr) !== 1) throw new Error(`invalid IPv6 address "${ip}"`);
|
|
124
|
+
let name = '';
|
|
125
|
+
for (let index = 15; index >= 0; index--) name += `${(bytes[index] & 0x0f).toString(16)}.${(bytes[index] >> 4).toString(16)}.`;
|
|
126
|
+
return `${name}ip6.arpa`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Reverse-resolve an IP to PTR names (in-addr.arpa / ip6.arpa). */
|
|
130
|
+
export function reverse(ip: string): string[] {
|
|
131
|
+
const queryName = ip.includes(':') ? ipv6ToArpa(ip) : `${ip.split('.').reverse().join('.')}.in-addr.arpa`;
|
|
132
|
+
return resolve(queryName, 'PTR').map((record) => record.name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The SYSTEM resolver (getaddrinfo) — honors the hosts file and the full resolution policy; a different answer from resolve(). */
|
|
136
|
+
export function lookup(name: string): LookupResult {
|
|
137
|
+
ensureWinsock();
|
|
138
|
+
const result: LookupResult = { ipv4: [], ipv6: [] };
|
|
139
|
+
const resultPointer = Buffer.allocUnsafeSlow(8);
|
|
140
|
+
const nameBuffer = Buffer.allocUnsafeSlow(name.length + 1); // getaddrinfo is the ANSI (LPCSTR) variant
|
|
141
|
+
nameBuffer.write(name, 'latin1');
|
|
142
|
+
nameBuffer[name.length] = 0;
|
|
143
|
+
if (Ws2_32.getaddrinfo(nameBuffer.ptr, null, null, resultPointer.ptr) !== 0) return result;
|
|
144
|
+
const head = Number(resultPointer.readBigUInt64LE(0));
|
|
145
|
+
try {
|
|
146
|
+
let nodePointer = head;
|
|
147
|
+
while (nodePointer !== 0) {
|
|
148
|
+
const node = Buffer.from(toArrayBuffer(nodePointer as Pointer, 0, 48)); // ADDRINFOA
|
|
149
|
+
const addressPointer = Number(node.readBigUInt64LE(32));
|
|
150
|
+
if (addressPointer !== 0) {
|
|
151
|
+
const sockaddr = Buffer.from(toArrayBuffer(addressPointer as Pointer, 0, 28));
|
|
152
|
+
if (node.readInt32LE(4) === AF_INET) result.ipv4.push(ipv4FromU32(sockaddr.readUInt32LE(4)));
|
|
153
|
+
else if (node.readInt32LE(4) === AF_INET6) result.ipv6.push(ipv6FromBytes(sockaddr, 8));
|
|
154
|
+
}
|
|
155
|
+
nodePointer = Number(node.readBigUInt64LE(40));
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
Ws2_32.freeaddrinfo(head as Pointer);
|
|
159
|
+
}
|
|
160
|
+
return result;
|
|
161
|
+
}
|
package/extras.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { decodeSockaddr, ipv4FromU32 } from './addr';
|
|
2
|
+
import { ICMP_PACKET_TOO_BIG, ICMP_SUCCESS } from './constants';
|
|
3
|
+
import { resolveIPv4, sendEcho } from './ping';
|
|
4
|
+
import { Iphlpapi, Win32Error } from './win32';
|
|
5
|
+
|
|
6
|
+
const AF_INET = 0x0000_0002;
|
|
7
|
+
const IP_FLAG_DF = 0x02;
|
|
8
|
+
const SOCKADDR_INET_SIZE = 28;
|
|
9
|
+
const MIB_IPFORWARD_ROW2_SIZE = 104;
|
|
10
|
+
const ROW2_INTERFACE_INDEX = 8;
|
|
11
|
+
const ROW2_NEXT_HOP = 44; // SOCKADDR_INET
|
|
12
|
+
const ROW2_METRIC = 84;
|
|
13
|
+
|
|
14
|
+
export interface BestRoute {
|
|
15
|
+
destination: string;
|
|
16
|
+
sourceAddress: string;
|
|
17
|
+
nextHop: string;
|
|
18
|
+
interfaceIndex: number;
|
|
19
|
+
metric: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PathMtuResult {
|
|
23
|
+
mtu: number;
|
|
24
|
+
determined: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const bestRouteBuffer = Buffer.allocUnsafeSlow(MIB_IPFORWARD_ROW2_SIZE);
|
|
28
|
+
const bestSourceBuffer = Buffer.allocUnsafeSlow(SOCKADDR_INET_SIZE);
|
|
29
|
+
const destinationBuffer = Buffer.allocUnsafeSlow(SOCKADDR_INET_SIZE);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which interface, source IP, and next hop the kernel would use to reach a host
|
|
33
|
+
* (GetBestRoute2) — IPv4. No incumbent does this without parsing `route print`.
|
|
34
|
+
*/
|
|
35
|
+
export function bestRoute(host: string): BestRoute {
|
|
36
|
+
const destination = resolveIPv4(host);
|
|
37
|
+
destinationBuffer.fill(0);
|
|
38
|
+
destinationBuffer.writeUInt16LE(AF_INET, 0); // SOCKADDR_INET.si_family
|
|
39
|
+
destinationBuffer.writeUInt32LE(destination, 4); // sin_addr (network order)
|
|
40
|
+
const error = Iphlpapi.GetBestRoute2(null, 0, null, destinationBuffer.ptr, 0, bestRouteBuffer.ptr, bestSourceBuffer.ptr);
|
|
41
|
+
if (error !== 0) throw new Win32Error(error);
|
|
42
|
+
return {
|
|
43
|
+
destination: ipv4FromU32(destination),
|
|
44
|
+
sourceAddress: decodeSockaddr(bestSourceBuffer, 0).address,
|
|
45
|
+
nextHop: decodeSockaddr(bestRouteBuffer, ROW2_NEXT_HOP).address,
|
|
46
|
+
interfaceIndex: bestRouteBuffer.readUInt32LE(ROW2_INTERFACE_INDEX),
|
|
47
|
+
metric: bestRouteBuffer.readUInt32LE(ROW2_METRIC),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Path-MTU discovery by binary-searching a Don't-Fragment ICMP payload: a hop
|
|
53
|
+
* that must fragment replies IP_PACKET_TOO_BIG, a DF black-hole simply times out
|
|
54
|
+
* — both shrink the upper bound, so the largest payload that round-trips is the
|
|
55
|
+
* path MTU (payload + 28-byte IP+ICMP headers). No raw socket, no admin.
|
|
56
|
+
*/
|
|
57
|
+
export function pathMtu(host: string, options: { timeoutMs?: number } = {}): PathMtuResult {
|
|
58
|
+
const destination = resolveIPv4(host);
|
|
59
|
+
const timeoutMs = options.timeoutMs ?? 1500;
|
|
60
|
+
let low = 0;
|
|
61
|
+
let high = 1472; // 1500 MTU − 28 header
|
|
62
|
+
let largestWorking = -1;
|
|
63
|
+
while (low <= high) {
|
|
64
|
+
const payload = (low + high) >> 1;
|
|
65
|
+
const echo = sendEcho(destination, 128, timeoutMs, payload, IP_FLAG_DF);
|
|
66
|
+
if (echo.replied && echo.status === ICMP_SUCCESS) {
|
|
67
|
+
largestWorking = payload;
|
|
68
|
+
low = payload + 1;
|
|
69
|
+
} else if (echo.replied && echo.status === ICMP_PACKET_TOO_BIG) {
|
|
70
|
+
high = payload - 1;
|
|
71
|
+
} else {
|
|
72
|
+
high = payload - 1; // timeout / DF black-hole → treat as too big (conservative)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return largestWorking < 0 ? { mtu: 0, determined: false } : { mtu: largestWorking + 28, determined: true };
|
|
76
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './adapters';
|
|
2
|
+
export * from './addr';
|
|
3
|
+
export * from './arp';
|
|
4
|
+
export * from './constants';
|
|
5
|
+
export * from './dns';
|
|
6
|
+
export * from './extras';
|
|
7
|
+
export * from './ping';
|
|
8
|
+
export * from './routes';
|
|
9
|
+
export * from './sockets';
|
|
10
|
+
export * from './stats';
|
|
11
|
+
export * from './traceroute';
|
|
12
|
+
export * from './wifi';
|
|
13
|
+
export * from './win32';
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"author": "Stev Peifer <stev.p@outlook.com>",
|
|
3
|
+
"bugs": {
|
|
4
|
+
"url": "https://github.com/ObscuritySRL/bun-win32/issues"
|
|
5
|
+
},
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@bun-win32/core": "1.1.4",
|
|
8
|
+
"@bun-win32/dnsapi": "1.0.2",
|
|
9
|
+
"@bun-win32/iphlpapi": "1.0.5",
|
|
10
|
+
"@bun-win32/kernel32": "1.0.26",
|
|
11
|
+
"@bun-win32/wlanapi": "1.0.4",
|
|
12
|
+
"@bun-win32/ws2_32": "1.0.6"
|
|
13
|
+
},
|
|
14
|
+
"description": "Syscall-grade network diagnostics for Bun on Windows — routing table, socket→PID map, no-admin ICMP ping/traceroute, ARP, DNS, live throughput, and WiFi — decoded from binary structs, zero native dependencies, no netsh/ping.exe/wmic scraping.",
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./index.ts"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"module": "index.ts",
|
|
23
|
+
"name": "@bun-win32/netdiag",
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"typescript": "^5"
|
|
26
|
+
},
|
|
27
|
+
"private": false,
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/ObscuritySRL/bun-win32#readme",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git://github.com/ObscuritySRL/bun-win32.git",
|
|
35
|
+
"directory": "packages/netdiag"
|
|
36
|
+
},
|
|
37
|
+
"type": "module",
|
|
38
|
+
"version": "1.0.0",
|
|
39
|
+
"main": "./index.ts",
|
|
40
|
+
"keywords": [
|
|
41
|
+
"bun",
|
|
42
|
+
"ffi",
|
|
43
|
+
"win32",
|
|
44
|
+
"windows",
|
|
45
|
+
"network",
|
|
46
|
+
"netstat",
|
|
47
|
+
"ping",
|
|
48
|
+
"traceroute",
|
|
49
|
+
"dns",
|
|
50
|
+
"wifi",
|
|
51
|
+
"arp",
|
|
52
|
+
"routing",
|
|
53
|
+
"diagnostics",
|
|
54
|
+
"typescript"
|
|
55
|
+
],
|
|
56
|
+
"files": [
|
|
57
|
+
"AI.md",
|
|
58
|
+
"README.md",
|
|
59
|
+
"adapters.ts",
|
|
60
|
+
"addr.ts",
|
|
61
|
+
"arp.ts",
|
|
62
|
+
"constants.ts",
|
|
63
|
+
"dns.ts",
|
|
64
|
+
"extras.ts",
|
|
65
|
+
"index.ts",
|
|
66
|
+
"ping.ts",
|
|
67
|
+
"routes.ts",
|
|
68
|
+
"sockets.ts",
|
|
69
|
+
"stats.ts",
|
|
70
|
+
"traceroute.ts",
|
|
71
|
+
"wifi.ts",
|
|
72
|
+
"win32.ts"
|
|
73
|
+
],
|
|
74
|
+
"sideEffects": false,
|
|
75
|
+
"engines": {
|
|
76
|
+
"bun": ">=1.1.0"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"example:net-report": "bun ./example/net-report.ts",
|
|
80
|
+
"example:netwatch": "bun ./example/netwatch.ts",
|
|
81
|
+
"example:ping": "bun ./example/ping.ts",
|
|
82
|
+
"example:selftest": "bun ./example/netdiag.selftest.ts",
|
|
83
|
+
"example:wifi-scan": "bun ./example/wifi-scan.ts"
|
|
84
|
+
}
|
|
85
|
+
}
|
package/ping.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { type Pointer, toArrayBuffer } from 'bun:ffi';
|
|
2
|
+
|
|
3
|
+
import { ipv4FromU32 } from './addr';
|
|
4
|
+
import { ICMP_REQUEST_TIMED_OUT, ICMP_SUCCESS, icmpStatusName } from './constants';
|
|
5
|
+
import { Iphlpapi, Kernel32, Ws2_32 } from './win32';
|
|
6
|
+
|
|
7
|
+
const AF_INET = 0x0000_0002;
|
|
8
|
+
const ADDRINFO_SIZE = 48; // ADDRINFOA (x64)
|
|
9
|
+
const ADDRINFO_FAMILY = 4;
|
|
10
|
+
const ADDRINFO_ADDR = 32; // ai_addr (sockaddr*)
|
|
11
|
+
const ADDRINFO_NEXT = 40;
|
|
12
|
+
|
|
13
|
+
export interface PingReply {
|
|
14
|
+
alive: boolean;
|
|
15
|
+
address: string;
|
|
16
|
+
roundTripMs: number;
|
|
17
|
+
ttl: number;
|
|
18
|
+
status: number;
|
|
19
|
+
statusText: string;
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PingOptions {
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
payloadSize?: number;
|
|
26
|
+
ttl?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface EchoResult {
|
|
30
|
+
replied: boolean;
|
|
31
|
+
address: string;
|
|
32
|
+
roundTripMs: number;
|
|
33
|
+
ttl: number;
|
|
34
|
+
status: number;
|
|
35
|
+
bytes: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let winsockReady = false;
|
|
39
|
+
function ensureWinsock(): void {
|
|
40
|
+
if (winsockReady) return;
|
|
41
|
+
Ws2_32.WSAStartup(0x0202, Buffer.allocUnsafeSlow(0x0198).ptr); // WSADATA 408 bytes
|
|
42
|
+
winsockReady = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resolve a host (name or IPv4 literal) to a network-order IPv4 u32 via the system resolver (ANSI getaddrinfo). Throws on failure. */
|
|
46
|
+
export function resolveIPv4(host: string): number {
|
|
47
|
+
ensureWinsock();
|
|
48
|
+
const resultPointer = Buffer.allocUnsafeSlow(8);
|
|
49
|
+
const nameBuffer = Buffer.allocUnsafeSlow(host.length + 1); // own buffer; getaddrinfo is the ANSI (LPCSTR) variant
|
|
50
|
+
nameBuffer.write(host, 'latin1');
|
|
51
|
+
nameBuffer[host.length] = 0;
|
|
52
|
+
const error = Ws2_32.getaddrinfo(nameBuffer.ptr, null, null, resultPointer.ptr);
|
|
53
|
+
if (error !== 0) throw new Error(`cannot resolve "${host}": getaddrinfo failed (${error})`);
|
|
54
|
+
const head = Number(resultPointer.readBigUInt64LE(0));
|
|
55
|
+
try {
|
|
56
|
+
let nodePointer = head;
|
|
57
|
+
while (nodePointer !== 0) {
|
|
58
|
+
const node = Buffer.from(toArrayBuffer(Number(nodePointer) as Pointer, 0, ADDRINFO_SIZE));
|
|
59
|
+
const addressPointer = Number(node.readBigUInt64LE(ADDRINFO_ADDR));
|
|
60
|
+
if (node.readInt32LE(ADDRINFO_FAMILY) === AF_INET && addressPointer !== 0) {
|
|
61
|
+
return Buffer.from(toArrayBuffer(Number(addressPointer) as Pointer, 0, 16)).readUInt32LE(4); // sockaddr_in.sin_addr
|
|
62
|
+
}
|
|
63
|
+
nodePointer = Number(node.readBigUInt64LE(ADDRINFO_NEXT));
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
Ws2_32.freeaddrinfo(Number(head) as Pointer);
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`no IPv4 address found for "${host}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let icmpHandle = 0n;
|
|
72
|
+
function handle(): bigint {
|
|
73
|
+
if (icmpHandle === 0n) icmpHandle = Iphlpapi.IcmpCreateFile();
|
|
74
|
+
return icmpHandle;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const requestData = Buffer.allocUnsafeSlow(0x0001_0000); // up to 64K payload (own, stable)
|
|
78
|
+
requestData.fill(0x61);
|
|
79
|
+
const replyBuffer = Buffer.allocUnsafeSlow(0x0002_0000); // 128K (own, stable)
|
|
80
|
+
const replyView = new DataView(replyBuffer.buffer, replyBuffer.byteOffset, replyBuffer.byteLength);
|
|
81
|
+
const optionsBuffer = Buffer.allocUnsafeSlow(8); // IP_OPTION_INFORMATION32 (x64): Ttl,Tos,Flags,OptionsSize, OptionsData u32
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One synchronous ICMP echo. ttl > 0 builds an IP_OPTION_INFORMATION32 (the
|
|
85
|
+
* REQUIRED x64 layout — the 64-bit struct returns ERROR_INVALID_PARAMETER) for
|
|
86
|
+
* traceroute/PMTU; ttl <= 0 sends with the OS default TTL and no options. The
|
|
87
|
+
* options struct is assembled immediately before the call with no await between.
|
|
88
|
+
*/
|
|
89
|
+
export function sendEcho(destination: number, ttl: number, timeoutMs: number, payloadSize: number, flags = 0): EchoResult {
|
|
90
|
+
let optionsPointer: Pointer | null = null;
|
|
91
|
+
if (ttl > 0 || flags !== 0) {
|
|
92
|
+
optionsBuffer.writeUInt8(ttl > 0 ? ttl : 128, 0);
|
|
93
|
+
optionsBuffer.writeUInt8(0, 1); // Tos
|
|
94
|
+
optionsBuffer.writeUInt8(flags, 2); // Flags (IP_FLAG_DF = 0x02 for path-MTU)
|
|
95
|
+
optionsBuffer.writeUInt8(0, 3); // OptionsSize
|
|
96
|
+
optionsBuffer.writeUInt32LE(0, 4); // OptionsData = NULL
|
|
97
|
+
optionsPointer = optionsBuffer.ptr;
|
|
98
|
+
}
|
|
99
|
+
const size = payloadSize < 0 ? 0 : payloadSize > 0xffff ? 0xffff : payloadSize; // RequestSize is a WORD
|
|
100
|
+
const replies = Iphlpapi.IcmpSendEcho(handle(), destination, requestData.ptr, size, optionsPointer, replyBuffer.ptr, replyBuffer.byteLength, timeoutMs);
|
|
101
|
+
if (replies === 0) {
|
|
102
|
+
// 0 replies = no echo arrived. GetLastError carries the IP_STATUS, but Bun's FFI does not
|
|
103
|
+
// reliably preserve it across the boundary → default to "timed out" (the dominant cause).
|
|
104
|
+
const lastError = Kernel32.GetLastError();
|
|
105
|
+
return { replied: false, address: '', roundTripMs: 0, ttl: 0, status: lastError === 0 ? ICMP_REQUEST_TIMED_OUT : lastError, bytes: 0 };
|
|
106
|
+
}
|
|
107
|
+
// ICMP_ECHO_REPLY (x64): Address@0 Status@4 RoundTripTime@8 DataSize@12 Reserved@14 Data@16 Options{Ttl@24}
|
|
108
|
+
return {
|
|
109
|
+
replied: true,
|
|
110
|
+
address: ipv4FromU32(replyView.getUint32(0, true)),
|
|
111
|
+
status: replyView.getUint32(4, true),
|
|
112
|
+
roundTripMs: replyView.getUint32(8, true),
|
|
113
|
+
bytes: replyView.getUint16(12, true),
|
|
114
|
+
ttl: replyBuffer.readUInt8(24),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** No-admin ICMP ping over IcmpSendEcho — no ping.exe spawn, no locale parse, no CMD flash. */
|
|
119
|
+
export async function ping(host: string, options: PingOptions = {}): Promise<PingReply> {
|
|
120
|
+
const destination = resolveIPv4(host);
|
|
121
|
+
const echo = sendEcho(destination, options.ttl ?? 0, options.timeoutMs ?? 1000, options.payloadSize ?? 32);
|
|
122
|
+
return {
|
|
123
|
+
alive: echo.replied && echo.status === ICMP_SUCCESS,
|
|
124
|
+
address: echo.replied ? echo.address : ipv4FromU32(destination),
|
|
125
|
+
roundTripMs: echo.roundTripMs,
|
|
126
|
+
ttl: echo.ttl,
|
|
127
|
+
status: echo.status,
|
|
128
|
+
statusText: icmpStatusName(echo.status),
|
|
129
|
+
bytes: echo.bytes,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Ping several hosts — no N-process fork (the async /24 sweep, pingSweep, adds true concurrency over IcmpSendEcho2). */
|
|
134
|
+
export function pingMany(hosts: string[], options: PingOptions = {}): Promise<PingReply[]> {
|
|
135
|
+
return Promise.all(hosts.map((host) => ping(host, options)));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SweepReply {
|
|
139
|
+
address: string;
|
|
140
|
+
alive: boolean;
|
|
141
|
+
roundTripMs: number;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sweepRequest = Buffer.allocUnsafeSlow(32);
|
|
145
|
+
sweepRequest.fill(0x61);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Async /24 subnet sweep: fan out IcmpSendEcho2 with a Win32 Event per host (NOT
|
|
149
|
+
* the ApcRoutine — that fires on a foreign alertable thread, a JSCallback crash
|
|
150
|
+
* class), then collect. Wall time ≈ one timeout, not N×timeout — the thing
|
|
151
|
+
* scrapers can't do without forking N processes. `prefix` is a /24 base.
|
|
152
|
+
*/
|
|
153
|
+
export async function pingSweep(prefix: string, options: { timeoutMs?: number } = {}): Promise<SweepReply[]> {
|
|
154
|
+
const timeoutMs = options.timeoutMs ?? 1000;
|
|
155
|
+
const octets = prefix
|
|
156
|
+
.replace(/\/\d+$/, '')
|
|
157
|
+
.split('.')
|
|
158
|
+
.map(Number); // accepts '192.168.0', '192.168.0.0', '192.168.0.0/24'
|
|
159
|
+
if (octets.length < 3 || octets.slice(0, 3).some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
160
|
+
throw new Error(`pingSweep prefix must be a /24 base like '192.168.0' (got "${prefix}")`);
|
|
161
|
+
}
|
|
162
|
+
const prefixWord = (octets[0] | (octets[1] << 8) | (octets[2] << 16)) >>> 0;
|
|
163
|
+
const icmpHandle = handle();
|
|
164
|
+
|
|
165
|
+
const addresses: string[] = [];
|
|
166
|
+
const events: bigint[] = [];
|
|
167
|
+
const replyBuffers: Buffer[] = [];
|
|
168
|
+
for (let host = 1; host <= 254; host++) {
|
|
169
|
+
const destination = (prefixWord | (host << 24)) >>> 0;
|
|
170
|
+
const event = Kernel32.CreateEventW(null, 1, 0, null); // manual-reset, non-signaled
|
|
171
|
+
const reply = Buffer.allocUnsafeSlow(256);
|
|
172
|
+
addresses.push(`${octets[0]}.${octets[1]}.${octets[2]}.${host}`);
|
|
173
|
+
events.push(event);
|
|
174
|
+
replyBuffers.push(reply);
|
|
175
|
+
Iphlpapi.IcmpSendEcho2(icmpHandle, event, null, null, destination, sweepRequest.ptr, 32, null, reply.ptr, reply.byteLength, timeoutMs); // ERROR_IO_PENDING
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await Bun.sleep(timeoutMs + 250); // all events signal by completion/timeout
|
|
179
|
+
|
|
180
|
+
const result: SweepReply[] = [];
|
|
181
|
+
for (let index = 0; index < addresses.length; index++) {
|
|
182
|
+
let alive = false;
|
|
183
|
+
let roundTripMs = 0;
|
|
184
|
+
if (Kernel32.WaitForSingleObject(events[index], 0) === 0 && Iphlpapi.IcmpParseReplies(replyBuffers[index].ptr, replyBuffers[index].byteLength) > 0) {
|
|
185
|
+
const view = new DataView(replyBuffers[index].buffer, replyBuffers[index].byteOffset, replyBuffers[index].byteLength);
|
|
186
|
+
alive = view.getUint32(4, true) === ICMP_SUCCESS;
|
|
187
|
+
roundTripMs = view.getUint32(8, true);
|
|
188
|
+
}
|
|
189
|
+
result.push({ address: addresses[index], alive, roundTripMs });
|
|
190
|
+
Kernel32.CloseHandle(events[index]);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
package/routes.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { decodeSockaddr } from './addr';
|
|
2
|
+
import { addressFamilyValue, type AddressFamilyName } from './constants';
|
|
3
|
+
import { Iphlpapi, mibTable } from './win32';
|
|
4
|
+
|
|
5
|
+
// MIB_IPFORWARD_TABLE2 { ULONG NumEntries; MIB_IPFORWARD_ROW2 Table[] } — rows 8-aligned.
|
|
6
|
+
const TABLE_FIRST_ROW = 8;
|
|
7
|
+
// MIB_IPFORWARD_ROW2 (x64, netioapi.h) — stride verified end-to-end vs `route print` (S4.3).
|
|
8
|
+
const ROW_SIZE = 104;
|
|
9
|
+
const ROW_INTERFACE_INDEX = 8;
|
|
10
|
+
const ROW_DESTINATION_PREFIX = 12; // IP_ADDRESS_PREFIX.Prefix (SOCKADDR_INET)
|
|
11
|
+
const ROW_PREFIX_LENGTH = 40; // IP_ADDRESS_PREFIX.PrefixLength (UINT8)
|
|
12
|
+
const ROW_NEXT_HOP = 44; // SOCKADDR_INET
|
|
13
|
+
const ROW_METRIC = 84;
|
|
14
|
+
const ROW_PROTOCOL = 88;
|
|
15
|
+
const ROW_LOOPBACK = 92;
|
|
16
|
+
const ROW_ORIGIN = 100;
|
|
17
|
+
|
|
18
|
+
const ROUTE_PROTOCOL_NAMES: ReadonlyMap<number, string> = new Map([
|
|
19
|
+
[1, 'other'],
|
|
20
|
+
[2, 'local'],
|
|
21
|
+
[3, 'static'],
|
|
22
|
+
[4, 'icmp'],
|
|
23
|
+
[10, 'rip'],
|
|
24
|
+
[13, 'ospf'],
|
|
25
|
+
[14, 'bgp'],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const ROUTE_ORIGIN_NAMES: ReadonlyMap<number, string> = new Map([
|
|
29
|
+
[0, 'manual'],
|
|
30
|
+
[1, 'well-known'],
|
|
31
|
+
[2, 'dhcp'],
|
|
32
|
+
[3, 'router-advertisement'],
|
|
33
|
+
[4, '6to4'],
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export interface Route {
|
|
37
|
+
destinationPrefix: { address: string; length: number };
|
|
38
|
+
nextHop: string;
|
|
39
|
+
interfaceIndex: number;
|
|
40
|
+
metric: number;
|
|
41
|
+
protocol: string;
|
|
42
|
+
loopback: boolean;
|
|
43
|
+
origin: string;
|
|
44
|
+
family: 'ipv4' | 'ipv6' | 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The IPv4 + IPv6 routing table over GetIpForwardTable2 (replaces the wmic-dependent default-gateway). */
|
|
48
|
+
export function routes(family: AddressFamilyName = 'all'): Route[] {
|
|
49
|
+
const familyValue = addressFamilyValue(family);
|
|
50
|
+
return mibTable(
|
|
51
|
+
(tablePointer) => Iphlpapi.GetIpForwardTable2(familyValue, tablePointer),
|
|
52
|
+
TABLE_FIRST_ROW,
|
|
53
|
+
ROW_SIZE,
|
|
54
|
+
(table, row) => {
|
|
55
|
+
const prefix = decodeSockaddr(table, row + ROW_DESTINATION_PREFIX);
|
|
56
|
+
const nextHop = decodeSockaddr(table, row + ROW_NEXT_HOP);
|
|
57
|
+
return {
|
|
58
|
+
destinationPrefix: { address: prefix.address, length: table.readUInt8(row + ROW_PREFIX_LENGTH) },
|
|
59
|
+
nextHop: nextHop.address,
|
|
60
|
+
interfaceIndex: table.readUInt32LE(row + ROW_INTERFACE_INDEX),
|
|
61
|
+
metric: table.readUInt32LE(row + ROW_METRIC),
|
|
62
|
+
protocol: ROUTE_PROTOCOL_NAMES.get(table.readUInt32LE(row + ROW_PROTOCOL)) ?? 'other',
|
|
63
|
+
loopback: table.readUInt8(row + ROW_LOOPBACK) !== 0,
|
|
64
|
+
origin: ROUTE_ORIGIN_NAMES.get(table.readUInt32LE(row + ROW_ORIGIN)) ?? 'unknown',
|
|
65
|
+
family: prefix.family,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The next hop of the lowest-metric default route (0.0.0.0/0 or ::/0) — the locale-proof default-gateway replacement. */
|
|
72
|
+
export function defaultGateway(family: AddressFamilyName = 'ipv4'): string | undefined {
|
|
73
|
+
let best: Route | undefined;
|
|
74
|
+
for (const route of routes(family)) {
|
|
75
|
+
if (route.destinationPrefix.length !== 0) continue;
|
|
76
|
+
if (route.nextHop === '' || route.nextHop === '0.0.0.0' || route.nextHop === '::') continue;
|
|
77
|
+
if (best === undefined || route.metric < best.metric) best = route;
|
|
78
|
+
}
|
|
79
|
+
return best?.nextHop;
|
|
80
|
+
}
|