From 5c49ffa36145b63bd6415af27ac6fee6b8dd5fe3 Mon Sep 17 00:00:00 2001 From: euxane Date: Sun, 24 Nov 2024 17:17:02 +0100 Subject: ping: add module --- ping.nim | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 ping.nim (limited to 'ping.nim') diff --git a/ping.nim b/ping.nim new file mode 100644 index 0000000..c99da96 --- /dev/null +++ b/ping.nim @@ -0,0 +1,286 @@ +# tickwatch +# Author: Euxane TRAN-GIRARD +# Licence: EUPL-1.2 + +import std/sequtils +import std/strutils +import std/random +import std/os +import std/net +import std/nativesockets +import std/posix +import std/times +import std/monotimes + + +type + ICMPPacket = object + ## https://datatracker.ietf.org/doc/html/rfc792 + ## https://datatracker.ietf.org/doc/html/rfc4443#section-4.1 + typ: uint8 + code: uint8 + checksum: uint16 + ident: uint16 + seqNum: uint16 + payload: string + + IPv6PseudoHeader = object + ## https://datatracker.ietf.org/doc/html/rfc2460#section-8.1 + source: IpAddress + dest: IpAddress + length: uint32 + next: uint8 + + +const + ICMP_ECHO_REQUEST = 8u8 + ICMP_ECHO_REPLY = 0u8 + ICMPv6_ECHO_REQUEST = 128u8 + ICMPv6_ECHO_REPLY = 129u8 + ICMP_IPv4_HEADER_LENGTH = 20 + RANDOM_PAYLOAD_LENGTH = 32 + ICMP_PACKET_BASE_LENGTH = 8 + DEFAULT_TIMEOUT = initDuration(seconds = 1) + + +func pack16(n: uint16): array[2, uint8] = + cast[array[2, uint8]](nativesockets.htons(n)) + +func pack32(n: uint32): array[4, uint8] = + cast[array[4, uint8]](nativesockets.htonl(n)) + +func unpack16(a: array[2, uint8]): uint16 = + nativesockets.ntohs(cast[uint16](a)) + +func unpack32(a: array[4, uint8]): uint32 = + nativesockets.ntohl(cast[uint32](a)) + +func pack(p: ICMPPacket): seq[uint8] = + result = newSeqOfCap[uint8](ICMP_PACKET_BASE_LENGTH + p.payload.len) + result.add p.typ + result.add p.code + result.add p.checksum.pack16 + result.add p.ident.pack16 + result.add p.seqNum.pack16 + result.add cast[seq[uint8]](p.payload) + +func unpackICMPPacket(a: openArray[uint8]): ICMPPacket = + result.typ = a[0] + result.code = a[1] + result.checksum = [a[2], a[3]].unpack16 + result.ident = [a[4], a[5]].unpack16 + result.seqNum = [a[6], a[7]].unpack16 + result.payload = cast[string](a[8..^1]) + +func pack(h: IPv6PseudoHeader): seq[uint8] = + result = newSeqOfCap[uint8](40) + result.add h.source.address_v6 + result.add h.dest.address_v6 + result.add h.length.pack32 + result.add [0u8, 0, 0] + result.add h.next + +func checksum(a: openArray[uint8]): uint16 = + ## https://datatracker.ietf.org/doc/html/rfc1071 + var sum = 0u32 + for i in countup(0, a.len - 1, 2): + sum += uint32(a[i]) shl 8 + if i + 1 < a.len: + sum += uint32(a[i + 1]) + + while (sum shr 16) != 0: + sum = (sum and 0xFFFF) + (sum shr 16) + + not uint16(sum) + +func icmpV6Header(source, dest: IpAddress, length: int): IPv6PseudoHeader = + IPv6PseudoHeader( + source: source, + dest: dest, + length: length.uint32, + next: posix.IPPROTO_ICMPv6.uint8, + ) + +func buildEchoRequest( + source, dest: IpAddress, + ident, seqNum: uint16, + payload: string = "", +): ICMPPacket = + result = ICMPPacket(ident: ident, seqNum: seqNum, payload: payload) + case dest.family: + of IPv6: + result.typ = ICMPv6_ECHO_REQUEST + let packedICMP = result.pack + let pseudoHeader = icmpV6Header(source, dest, packedICMP.len).pack + result.checksum = checksum(pseudoHeader & packedICMP) + of IPv4: + result.typ = ICMP_ECHO_REQUEST + result.checksum = checksum(result.pack) + +func validateChecksum(packet: ICMPPacket, source, dest: IpAddress): bool = + var sourcePacket = packet + sourcePacket.checksum = 0 + case packet.typ: + of ICMPv6_ECHO_REPLY: + let packedICMP = sourcePacket.pack + let pseudoHeader = icmpV6Header(source, dest, packedICMP.len).pack + packet.checksum == checksum(pseudoHeader & packedICMP) + of ICMP_ECHO_REPLY: + packet.checksum == checksum(sourcePacket.pack) + else: + false + +func domainProtocol(family: IpAddressFamily): (Domain, Protocol) = + case family: + of IPv6: (AF_INET6, IPPROTO_ICMPv6) + of IPv4: (AF_INET, IPPROTO_ICMP) + +func icmpReplyType(family: IpAddressFamily): uint8 = + case family: + of IPv6: ICMPv6_ECHO_REPLY + of IPv4: ICMP_ECHO_REPLY + +func stripIPHeader(family: IpAddressFamily, buffer: seq[uint8]): seq[uint8] = + case family: + of IPv6: buffer + of IPv4: buffer[min(ICMP_IPv4_HEADER_LENGTH, buffer.len)..^1] + +func toTimeval(d: Duration): Timeval = + let uSecs = d.inMicroseconds + Timeval( + tv_sec: posix.Time(uSecs div (1000 * 1000)), + tv_usec: posix.Suseconds(uSecs mod (1000 * 1000)), + ) + +proc setTimeout(sock: Socket, opt: cint, timeout: Duration) = + let timeVal = timeout.toTimeval + let timeValLen = sizeof(timeVal).SockLen + if sock.getFd().setSockOpt(SOL_SOCKET, opt, timeVal.addr, timeValLen) < 0: + raiseOsError osLastError() + +proc receiveReply(sock: Socket, timeout: Duration): seq[uint8] = + var buffer: string + var ipAddr: IpAddress + var port: Port + sock.setTimeout(SO_RCVTIMEO, timeout) + discard sock.recvFrom(buffer, 1024, ipAddr, port) + stripIPHeader(ipAddr.family, cast[seq[uint8]](buffer)) + + +type PingMonitor = ref object + socket: Socket + target: IpAddress + source: IpAddress + ident: uint16 + payload: string + seqNum: uint16 + +proc procIdent*(): uint16 = + uint16(getCurrentProcessId() and 0xFFFF) + +proc initPingMonitor*(target: IpAddress, ident: uint16): PingMonitor = + let (domain, proto) = target.family.domainProtocol + PingMonitor( + socket: newSocket(domain, SOCK_RAW, proto), + target: target, + source: getPrimaryIPAddr(target), + ident: ident, + ) + +func buildEchoRequest(mon: PingMonitor): ICMPPacket = + buildEchoRequest(mon.source, mon.target, mon.ident, mon.seqNum, mon.payload) + +proc sendEchoRequest(mon: PingMonitor, timeout: Duration) = + try: + let echoRequest = mon.buildEchoRequest + mon.socket.setTimeout(SO_SNDTIMEO, timeout) + mon.socket.sendTo(mon.target, Port 0, cast[string](echoRequest.pack)) + except CatchableError: + discard + +func validateICMPReply(mon: PingMonitor, packet: ICMPPacket): bool = + packet.typ == icmpReplyType(mon.target.family) and + packet.ident == mon.ident and + packet.seqNum == mon.seqNum and + packet.payload == mon.payload and + packet.validateChecksum(mon.target, mon.source) + +proc tryReceiveReply(mon: PingMonitor, timeout: Duration): bool = + try: + let data = mon.socket.receiveReply(timeout) + if data.len < ICMP_PACKET_BASE_LENGTH: return false + let unpacked = unpackICMPPacket(data) + mon.validateICMPReply(unpacked) + except CatchableError: + false + +proc genRandomPayload(length = RANDOM_PAYLOAD_LENGTH): string = + newSeqWith(length, char.rand).join + +proc ping*(mon: PingMonitor, timeout = DEFAULT_TIMEOUT): Duration = + let startTime = getMonoTime() + let replyDeadline = startTime + timeout + + inc mon.seqNum + mon.payload = genRandomPayload() + mon.sendEchoRequest(timeout) + + while true: + let receiveTimeout = replyDeadline - getMonoTime() + if receiveTimeout <= DurationZero: + break + if mon.tryReceiveReply(receiveTimeout): + return getMonoTime() - startTime + + raise newException(TimeoutError, "Ping timeout") + + +when defined(test): + import std/unittest + suite "ping": + test "pack/unpack endianness": + check pack16(256) == [1u8, 0] + check unpack16([1u8, 0]) == 256 + check pack32(256) == [0u8, 0, 1, 0] + check unpack32([0u8, 0, 1, 0]) == 256 + + test "pack/unpack ICMP packet": + let packet = ICMPPacket( + typ: 1, + code: 2, + checksum: 3, + ident: 4, + seqNum: 5, + payload: cast[string](@[6u8, 7]), + ) + let packed = pack(packet) + check packed == [1u8, 2, 0, 3, 0, 4, 0, 5, 6, 7] + check unpackICMPPacket(packed) == packet + + test "pack IPv6 pseudo-header": + let pseudoHeader = IPv6PseudoHeader( + source: parseIpAddress("::1"), + dest: parseIpAddress("::2"), + length: 1234, + next: 123, + ) + check pseudoHeader.pack == [ + 0u8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, + 0, 0, 4, 210, 0, 0, 0, 123, + ] + + test "packet checksum": + # numeric example from https://datatracker.ietf.org/doc/html/rfc1071 + let exampleBytes = [0x00u8, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7] + check checksum(exampleBytes) == not 0xddf2u16 + + test "ping localhost": + for target in ["127.0.0.1", "::1"]: + let targetIp = parseIpAddress(target) + let mon = try: + initPingMonitor(targetIp, procIdent()) + except OSError: + skip() # ping requires raw socket permission + break + discard mon.ping() -- cgit v1.2.3