visit
You’ve probably used the ping
command multiple times to test the connection to the server. This is a widely available tool. Have you ever thought about how exactly it works and how to implement it yourself? I find exploring some unknown things helpful in understanding how the system works, and in this article, I'll try to explain how exactly it works and how to recreate it from scratch in Node.js.
In case you have never used ping
before, "ping
is a computer network administration software utility used to test the reachability of a host on an Internet Protocol (IP) network... Ping measures the round-trip time for messages sent from the originating host to a destination computer that are echoed back to the source." (c)
Let's give it a try. Just open a terminal and execute ping 1.1.1.1
. You should see something similar to this:
$ ping 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 56 data bytes
64 bytes from 1.1.1.1: icmp_seq=0 ttl=59 time=8.322 ms
64 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=19.255 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=7.433 ms
...
Now, we have a good theory behind us to get started. However, to debug our program, it would be helpful to see the actual bits and bytes we send to some IPs. The easiest way to check ANY traffic your machine is using is to use Wireshark. It is free and open-source software that works on both Windows and macOS. Before we start, let's check if theory and practice align on how the protocol works.
Two easy steps:
Now execute $ ping 1.1.1.1
command in the terminal. This is more or less what you will see:
This will be very helpful in validating our setup. You can also hover over Packet Bytes to highlight what they are responsible for. In this case, you can see the Type of request is equal to 8
, which is exactly what we would expect from our protocol described above.
Now we have all the building blocks; we can start coding. Unfortunately, Node.js has no native support for raw sockets to use ICMP protocol. However, the raw-socket
npm package supports it using node-gyp to access system-level APIs.
So practically, we need to:
import raw from "raw-socket";
const socket = raw.createSocket({ protocol: raw.Protocol.ICMP });
socket.on("message", function (buffer, source) {
// TODO Decode
});
socket.on("error", (e) => {
console.error("Socket Error: ", e);
socket.close();
});
const pingBuffer = createPingBuffer(1234, 0, "Payload");
socket.send(pingBuffer, 0, buffer.length, "1.1.1.1", function (error, bytes) {
if (error) console.error("Unable to send message: ", error.toString());
});
function createPingBuffer(identifier, sequence, payload) {
// TODO Encode
}
This is the main part of the app. Here we create an ICMP socket and start listening for new messages. At the same time, we send our ping command to the 1.1.1.1
IP address.
Type = 8 (Request) = 00001000 (binary)
Code = 0 = 00000000 (binary)
Checksum = dynamic value
Identifier = 1 = 0000000000000001 (binary)
Sequence Number = 0 = 0000000000000000 (binary)
00000000000000000000
const ICMP_HEADER_SIZE = 8;
function createPingBuffer(identifier, sequenceNumber, payload) {
// Allocate empty 8 bytes buffer filled with 0
const buffer = Buffer.alloc(ICMP_HEADER_SIZE);
buffer.writeUInt8(8, 0); // Type 1 byte with offset 0 bytes
buffer.writeUInt8(0, 1); // Code 1 byte with offset 1 byte
buffer.writeUInt16BE(0, 2); // Checksum 2 bytes with offset 2 bytes
buffer.writeUInt16BE(identifier, 4); // Identifier 2 bytes with offset 4 bytes
buffer.writeUInt16BE(sequenceNumber, 6); // Sequence Number 2 bytes with offset 6 bytes
// Override 0 checksum with correct value based on full request
raw.writeChecksum(buffer, 2, raw.createChecksum(buffer));
return buffer;
}
IHL stands for Internet Header Length. It has 4 bits that specify the number of 32-bit (4 bytes) words in the header. Unfortunately, Node.js Buffers lack helper methods to read 4-bit data. It means that we will have to implement it ourselves.
Here we will need to use . To get the last 4 bits of a byte, we will need to use bitwise AND
function getIPProtocolHeaderSize(buffer) {
const versionAndIHL = buffer.readUInt8();
const IHL = versionAndIHL & 0b00001111; // will remove everything in the first 4 bits -> 0x0000XXXX
return IHL * 4; // multiply by 4 as it contains number of 32-bit (4 bytes) words to get total size in bytes
}
function decodePingPacket(buffer) {
const ipOffset = getIPProtocolHeaderSize(buffer);
// IP level TTL
const ttl = buffer.readUInt8(8);
const type = buffer.readUInt8(ipOffset);
const code = buffer.readUInt8(ipOffset + 1);
const checksum = buffer.readUInt16BE(ipOffset + 2);
const identifier = buffer.readUInt16BE(ipOffset + 4);
const sequenceNumber = buffer.readUInt16BE(ipOffset + 6);
return {
ttl,
type,
code,
checksum,
identifier,
sequenceNumber,
};
}
Unix implementation of ping is open source, so we can learn some tricks from reading . Here, it adds timeval
as part of the payload.
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
Let's use the same approach with our client. We will send a precise time when we send the command and compare it when we get a response. Standard Date.now()
will not work well in this case as we deal with high-precision data. To get accurate time, we can use API. So let's put it into practice and add it to the payload together with the payload itself.
const time = performance.timeOrigin + performance.now();
const uint32 = new Uint32Array(2);
uint32[0] = time / 1000; // seconds
uint32[1] = (time - uint32[0] * 1000) * 1000; // microseconds
// write it as first 8 bytes of payload
buffer.writeUInt32BE(uint32[0], 8);
buffer.writeUInt32BE(uint32[1], 12);
// write payload itself
buffer.write(payload, 16);
// compute checksum
raw.writeChecksum(buffer, 2, raw.createChecksum(buffer));
return buffer;
I put together all the code here in . It has some extra code to connect all the pieces together. Just run node index.js 1.1.1.1
$ node index.js 1.1.1.1
PING 1.1.1.1 (1.1.1.1): 28 data bytes
36 bytes from 1.1.1.1: icmp_seq=0 ttl=59 time=32.622314453125 ms
36 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=9.591552734375 ms
36 bytes from 1.1.1.1: icmp_seq=2 ttl=59 time=16.5 ms
...