]> git.ipfire.org Git - collecty.git/blob - src/collecty/ping.py
e2d797054b53e1906028f017edf87eacab49426b
[collecty.git] / src / collecty / ping.py
1 #!/usr/bin/python3
2
3 import array
4 import math
5 import os
6 import random
7 import select
8 import socket
9 import struct
10 import sys
11 import time
12
13 ICMP_TYPE_ECHO_REPLY = 0
14 ICMP_TYPE_ECHO_REQUEST = 8
15 ICMP_MAX_RECV = 2048
16
17 MAX_SLEEP = 1000
18
19 class PingError(Exception):
20 msg = None
21
22
23 class PingResolveError(PingError):
24 msg = "Could not resolve hostname"
25
26
27 class Ping(object):
28 def __init__(self, destination, timeout=1000, packet_size=56):
29 self.destination = self._resolve(destination)
30 self.timeout = timeout
31 self.packet_size = packet_size
32
33 self.id = os.getpid() & 0xffff # XXX ? Is this a good idea?
34
35 self.seq_number = 0
36
37 # Number of sent packets.
38 self.send_count = 0
39
40 # Save the delay of all responses.
41 self.times = []
42
43 def run(self, count=None, deadline=None):
44 while True:
45 delay = self.do()
46
47 self.seq_number += 1
48
49 if count and self.seq_number >= count:
50 break
51
52 if deadline and self.total_time >= deadline:
53 break
54
55 if delay == None:
56 delay = 0
57
58 if MAX_SLEEP > delay:
59 time.sleep((MAX_SLEEP - delay) / 1000)
60
61 def do(self):
62 s = None
63 try:
64 # Open a socket for ICMP communication.
65 s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.getprotobyname("icmp"))
66
67 # Send one packet.
68 send_time = self.send_icmp_echo_request(s)
69
70 # Increase number of sent packets (even if it could not be sent).
71 self.send_count += 1
72
73 # If the packet could not be sent, we may stop here.
74 if send_time is None:
75 return
76
77 # Wait for the reply.
78 receive_time, packet_size, ip, ip_header, icmp_header = self.receive_icmp_echo_reply(s)
79
80 finally:
81 # Close the socket.
82 if s:
83 s.close()
84
85 # If a packet has been received...
86 if receive_time:
87 delay = (receive_time - send_time) * 1000
88 self.times.append(delay)
89
90 return delay
91
92 def send_icmp_echo_request(self, s):
93 # Header is type (8), code (8), checksum (16), id (16), sequence (16)
94 checksum = 0
95
96 # Create a header with checksum == 0.
97 header = struct.pack("!BBHHH", ICMP_TYPE_ECHO_REQUEST, 0,
98 checksum, self.id, self.seq_number)
99
100 # Get some bytes for padding.
101 padding = os.urandom(self.packet_size)
102
103 # Calculate the checksum for header + padding data.
104 checksum = self._calculate_checksum(header + padding)
105
106 # Rebuild the header with the new checksum.
107 header = struct.pack("!BBHHH", ICMP_TYPE_ECHO_REQUEST, 0,
108 checksum, self.id, self.seq_number)
109
110 # Build the packet.
111 packet = header + padding
112
113 # Save the time when the packet has been sent.
114 send_time = time.time()
115
116 # Send the packet.
117 try:
118 s.sendto(packet, (self.destination, 0))
119 except socket.error as e:
120 if e.errno == 1: # Operation not permitted
121 # The packet could not be sent, probably because of
122 # wrong firewall settings.
123 return
124
125 return send_time
126
127 def receive_icmp_echo_reply(self, s):
128 timeout = self.timeout / 1000.0
129
130 # Wait until the reply packet arrived or until we hit timeout.
131 while True:
132 select_start = time.time()
133
134 inputready, outputready, exceptready = select.select([s], [], [], timeout)
135 select_duration = (time.time() - select_start)
136
137 if inputready == []: # Timeout
138 return None, 0, 0, 0, 0
139
140 # Save the time when the packet has been received.
141 receive_time = time.time()
142
143 # Read the packet from the socket.
144 packet_data, address = s.recvfrom(ICMP_MAX_RECV)
145
146 # Parse the ICMP header.
147 icmp_header = self._header2dict(
148 ["type", "code", "checksum", "packet_id", "seq_number"],
149 "!BBHHH", packet_data[20:28]
150 )
151
152 # This is the reply to our packet if the ID matches.
153 if icmp_header["packet_id"] == self.id:
154 # Parse the IP header.
155 ip_header = self._header2dict(
156 ["version", "type", "length", "id", "flags",
157 "ttl", "protocol", "checksum", "src_ip", "dst_ip"],
158 "!BBHHHBBHII", packet_data[:20]
159 )
160
161 packet_size = len(packet_data) - 28
162 ip = socket.inet_ntoa(struct.pack("!I", ip_header["src_ip"]))
163
164 return receive_time, packet_size, ip, ip_header, icmp_header
165
166 # Check if the timeout has already been hit.
167 timeout = timeout - select_duration
168 if timeout <= 0:
169 return None, 0, 0, 0, 0
170
171 def _header2dict(self, names, struct_format, data):
172 """
173 Unpack tghe raw received IP and ICMP header informations to a dict
174 """
175 unpacked_data = struct.unpack(struct_format, data)
176 return dict(list(zip(names, unpacked_data)))
177
178 def _calculate_checksum(self, source_string):
179 if len(source_string) % 2:
180 source_string += "\x00"
181
182 converted = array.array("H", source_string)
183 if sys.byteorder == "big":
184 converted.byteswap()
185
186 val = sum(converted)
187
188 # Truncate val to 32 bits (a variance from ping.c, which uses signed
189 # integers, but overflow is unlinkely in ping).
190 val &= 0xffffffff
191
192 # Add high 16 bits to low 16 bits.
193 val = (val >> 16) + (val & 0xffff)
194
195 # Add carry from above (if any).
196 val += (val >> 16)
197
198 # Invert and truncate to 16 bits.
199 answer = ~val & 0xffff
200
201 return socket.htons(answer)
202
203 def _resolve(self, host):
204 """
205 Resolve host.
206 """
207 if self._is_valid_ipv4_address(host):
208 return host
209
210 try:
211 return socket.gethostbyname(host)
212 except socket.gaierror as e:
213 if e.errno == -3:
214 raise PingResolveError
215
216 raise
217
218 def _is_valid_ipv4_address(self, addr):
219 """
220 Check addr to be a valid IPv4 address.
221 """
222 parts = addr.split(".")
223
224 if not len(parts) == 4:
225 return False
226
227 for part in parts:
228 try:
229 number = int(part)
230 except ValueError:
231 return False
232
233 if number > 255:
234 return False
235
236 return True
237
238 @property
239 def receive_count(self):
240 """
241 The number of received packets.
242 """
243 return len(self.times)
244
245 @property
246 def total_time(self):
247 """
248 The total time of all roundtrips.
249 """
250 try:
251 return sum(self.times)
252 except ValueError:
253 return
254
255 @property
256 def min_time(self):
257 """
258 The smallest roundtrip time.
259 """
260 try:
261 return min(self.times)
262 except ValueError:
263 return
264
265 @property
266 def max_time(self):
267 """
268 The biggest roundtrip time.
269 """
270 try:
271 return max(self.times)
272 except ValueError:
273 return
274
275 @property
276 def avg_time(self):
277 """
278 Calculate the average response time.
279 """
280 try:
281 return self.total_time / self.receive_count
282 except ZeroDivisionError:
283 return
284
285 @property
286 def variance(self):
287 """
288 Calculate the variance of all roundtrips.
289 """
290 if self.avg_time is None:
291 return
292
293 var = 0
294
295 for t in self.times:
296 var += (t - self.avg_time) ** 2
297
298 var /= self.receive_count
299 return var
300
301 @property
302 def stddev(self):
303 """
304 Standard deviation of all roundtrips.
305 """
306 return math.sqrt(self.variance)
307
308 @property
309 def loss(self):
310 """
311 Outputs the percentage of dropped packets.
312 """
313 dropped = self.send_count - self.receive_count
314
315 return dropped / self.send_count
316
317
318 if __name__ == "__main__":
319 p = Ping("ping.ipfire.org")
320 p.run(count=5)
321
322 print("Min/Avg/Max/Stddev: %.2f/%.2f/%.2f/%.2f" % \
323 (p.min_time, p.avg_time, p.max_time, p.stddev))
324 print("Sent/Recv/Loss: %d/%d/%.2f" % (p.send_count, p.receive_count, p.loss))