Desktop-only capabilities
Four capabilities exist only in the Electron build: ICMP ping, system traceroute, TCP connect scanning, and ARP-based local discovery. Each is implemented in electron/network/handlers.ts and reached over an IPC bridge, never from the page directly.
How the renderer reaches them
Section titled “How the renderer reaches them”electron/preload.ts exposes exactly ten methods on window.electronAPI, and nothing else crosses the boundary. lib/electron.ts detects the desktop build by testing for that object rather than sniffing the user agent, and returns null from every call when it is absent.
window.electronAPI method |
IPC channel | Implementation |
|---|---|---|
ping(host, options) |
network:ping |
spawns the system ping binary |
traceroute(host, options) |
network:traceroute |
spawns traceroute, or tracert on Windows |
portScan(host, ports, options) |
network:portScan |
Node net.Socket connect attempts |
dnsLookup(hostname, options) |
network:dnsLookup |
Node dns.promises.Resolver |
getNetworkInterfaces() |
network:getInterfaces |
os.networkInterfaces() |
arpScan() |
network:arpScan |
spawns arp -a |
getSystemInfo() |
system:getInfo |
os hostname, platform, arch, cpus, memory, uptime |
getVersion(), getPlatform(), isElectron() |
app:* |
app metadata |
Two rows in that table shell out to system binaries and parse their text output, which is a design choice worth being explicit about: the app does not reimplement ping or traceroute, it drives yours. That means results match what you would get in a terminal on that machine, and it also means a missing binary is a real failure mode with its own error message.
ICMP ping
Section titled “ICMP ping”Real ICMP, because the system ping binary sends it. Not a raw socket in Node, which would need elevated privileges the Windows installer explicitly does not request.
| Parameter | Default | Range | Why bounded |
|---|---|---|---|
count |
4 | 1 to 10 | a caller cannot turn the tool into a flood |
timeout |
5000 ms | 1000 to 30000 | keeps the spawned process bounded |
| Overall process timeout | timeout * count + 5000 ms |
derived | the child is killed even if ping hangs |
Arguments are built per platform, because the flags genuinely differ: Windows takes -n for count and -w in milliseconds; macOS takes -c, -W in milliseconds, plus a -t overall deadline; Linux takes -c and -W in seconds. Getting that unit wrong is the classic bug in this area, and buildPingArgs is exported so tests/unit/electron-handlers.test.ts can assert it against each platform.
Output is parsed with a regex over the RTT lines. One subtlety: replies are de-duplicated by icmp_seq, because BSD ping prints duplicate replies marked dup! and counting those would report more responses than packets sent. Packet loss is therefore clamped at zero rather than allowed to go negative. The parser is tested against a real captured macOS transcript in tests/fixtures/ping-macos.txt.
System traceroute
Section titled “System traceroute”traceroute on Unix, tracert on Windows. Both are asked for numeric output and one query per hop, so the run is faster and the parse is simpler.
| Parameter | Default | Range |
|---|---|---|
maxHops |
30 | 1 to 64 |
timeout |
5000 ms | 1000 to 10000 |
| Overall process timeout | timeout * maxHops + 10000 ms |
derived |
The parser skips banner lines, accepts both the bare-IP and hostname (ip) forms, and requires either an RTT or a * before treating a numbered line as a hop, so a wrapped line cannot be misread as a hop. Windows sub-millisecond hops print as <1 ms and are recorded as 1. Fixture: tests/fixtures/traceroute-macos.txt.
This is the capability with no browser fallback at all, for the reason in what a browser cannot do: setting a per-packet TTL is not something a page can do.
TCP connect scanning
Section titled “TCP connect scanning”A real connect scan using Node’s net.Socket. No nmap, no raw packets, no SYN scan, so it is a full three-way handshake per port and it is visible in the target’s logs.
The state mapping is the reason this is worth having over the browser probe:
| Socket outcome | Reported state | Meaning |
|---|---|---|
connect event |
open |
handshake completed, responseTime recorded |
socket timeout |
filtered |
no answer at all, consistent with a drop |
ECONNREFUSED |
closed |
the host actively refused, so the host is up |
EHOSTUNREACH, ENETUNREACH, ETIMEDOUT, ECONNRESET |
filtered |
reachability problem, not a port answer |
| anything else | filtered |
conservative default |
The distinction between closed and filtered is the entire point. A browser probe collapses both into unknown because a failed fetch carries no error detail; a connect scan gets the errno and can tell them apart. closed is also a positive result about the host: something answered with a refusal, so the host exists.
Limits: timeout defaults to 3000 ms with a 500 to 10000 range, concurrency defaults to 50 with a 1 to 200 range, and a port list longer than 10000 entries is rejected outright. Concurrency is implemented as sequential batches through Promise.all rather than a rolling window, so one slow port in a batch delays the next batch. Service names come from a hardcoded 35-entry map, so an open port with no entry shows no name rather than a guess.
ARP and interface enumeration
Section titled “ARP and interface enumeration”arpScan() spawns arp -a with a fixed 10 second timeout and parses the output, with separate regexes for Windows and for the BSD and Linux (ip) at mac form. normalizeMac pads BSD’s unpadded octets, so 8:0:27:1a:2b:3c becomes 08:00:27:1a:2b:3c. Fixture: tests/fixtures/arp-macos.txt.
This reads the kernel’s existing ARP cache (RFC 826); it does not sweep the subnet. So it shows hosts your machine has recently talked to, which is a smaller and less predictable set than an active scan would find, and it needs no privileges.
Interface enumeration is os.networkInterfaces() with no platform branching. Two rough edges are visible in the code: a missing MAC falls back to the literal 00:00:00:00:00:00, and when an interface has several addresses of the same family the last one wins.
Two more, worth knowing before you rely on them:
arpScan(subnet)accepts asubnetargument, forwards it across IPC, and the handler ignores it entirely. The only caller passes nothing.ArpEntry.vendoris declared but never populated by the main process. Vendor names are resolved in the renderer fromlib/oui-vendors.ts.
DNS in the desktop build
Section titled “DNS in the desktop build”The desktop build can do the thing DoH cannot: query an arbitrary DNS server. It uses a new dns.promises.Resolver per request, because dns.setServers is process-global and concurrent lookups would race and could permanently clobber the resolver configuration.
The server must be an IPv4 literal, not a hostname, and the default is the literal string "system", meaning your OS resolver. Supported record types are the same nine the browser path offers. Non-string answers such as MX and SOA are JSON.stringifyed into the value field, and the ttl field is declared in the return type but never populated.
How input is kept from becoming a shell command
Section titled “How input is kept from becoming a shell command”Nothing is passed through a shell. Commands are launched with spawn(binary, args), so an argument containing ; is an argument, not a separator. On top of that:
| Guard | Rule |
|---|---|
| Host character filter | rejects ; & | ` $ ( ) { } [ ] < > \ ' " ! plus newlines and tabs |
| Host length | 253 characters maximum |
| Host format | must match an IPv4 literal, an IPv6 literal, or an RFC 1123 hostname |
| Port | integer, 1 to 65535 |
| Port list | non-empty array, 10000 entries maximum |
| DNS server | IPv4 literal only |
| Binary path | absolute and pinned per platform, so PATH cannot be hijacked |
Binary paths are hardcoded per platform (/sbin/ping on macOS, /bin/ping on Linux, bare ping on Windows) because a packaged app does not inherit a shell’s PATH. Timeouts are enforced twice: spawn’s own timeout option, plus a manual SIGTERM escalating to SIGKILL after one second, resolving with partial output if any arrived. A non-zero exit code with output is treated as success, because ping exits 1 for an unreachable host and that is a result, not an error.