-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathkhronos_utils.py
More file actions
267 lines (227 loc) · 10 KB
/
Copy pathkhronos_utils.py
File metadata and controls
267 lines (227 loc) · 10 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
'''********************************************************************
* *
* Copyright (c) Network Time Foundation 2026 *
* *
* All Rights Reserved *
* *
* Redistribution and use in source and binary forms, with or without *
* modification, are permitted provided that the following conditions *
* are met: *
* 1. Redistributions of source code must retain the above copyright *
* notice, this list of conditions and the following disclaimer. *
* 2. Redistributions in binary form must reproduce the above *
* copyright notice, this list of conditions and the following *
* disclaimer in the documentation and/or other materials provided *
* with the distribution. *
* *
* THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS *
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED *
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE *
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR CONTRIBUTORS BE *
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR *
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT *
* OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR *
* BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF *
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT *
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE *
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH *
* DAMAGE. *
***********************************************************************
'''
import os
import json
import math
import logging
import time
import colorlog
import socket
from ntplibrary import NTPClient, NTPException, NTP
_NTP_EXPECTED_VERSION = 4
no_response_count = {}
LEAP_NOTINSYNC = 3
MAX_SERVER_FAILURES = 10
def init_logging(log_name, log_file = 'khronos.log'):
global logger
logger = colorlog.getLogger(log_name)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)-8s - %(message)s')
handler = colorlog.StreamHandler()
coloredFormatter = colorlog.ColoredFormatter(
# Format string using the special %(log_color)s variable
fmt="%(log_color)s%(asctime)s - %(name)s - %(levelname)-8s - %(message)s",
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'bold_red',
}
)
handler.setFormatter(coloredFormatter)
logger = colorlog.getLogger(log_name)
logger.addHandler(handler)
file_handler = logging.FileHandler(filename=log_file, mode='a') # define where the log will be written. mode parameter will determine whether to append to log if it exists ('a') or write over file ('w').
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def add_to_no_response_count(ip):
if not ip in no_response_count:
no_response_count[ip] = 0
no_response_count[ip] += 1
if no_response_count[ip] >= MAX_SERVER_FAILURES:
logger.error(f"Server {ip} exceeded the maximum number of failures allowed ({MAX_SERVER_FAILURES}).")
def remove_from_no_response_count(ip):
logger.info(f"Retry succeeded: {ip} server responded, failed count: {no_response_count[ip]}")
no_response_count[ip] = 0
def get_no_response_counts(ip):
return no_response_count[ip]
def read_server_list(file_to_load):
try:
if os.path.isfile(file_to_load):
# Open the file in read mode ('rb')
with open(file_to_load, 'rb') as file:
return json.load(file)
except FileNotFoundError:
logger.error(f"The file {file_to_load} was not found.")
except json.JSONDecodeError:
logger.error(f"The file {file_to_load} contains invalid JSON.")
return None
def lookup_dns_addresses(dns_names):
addresses = set()
for dns_name in dns_names:
try:
addr_info = socket.getaddrinfo(dns_name, None)
ips = set(info[4][0] for info in addr_info)
addresses |= ips
except socket.gaierror:
logger.error(f"Failed to resolve {dns_name}")
continue
return addresses
def retrieve_server_addresses(zone_dns_names, pool_size, max_time_secs):
# DNS names to retrieve ip addresses
for dns_name in zone_dns_names:
logger.debug(f"Lookup for {dns_name}")
final_server_list = set()
iterations = 1
start = time.time()
current_time = start
while len(final_server_list) < pool_size and current_time - start < max_time_secs:
final_server_list |= lookup_dns_addresses(zone_dns_names)
logger.debug(f"iteration {iterations}, so far collected {len(final_server_list)} servers.")
iterations += 1
if len(final_server_list) < pool_size:
time.sleep(60)
current_time = time.time()
return final_server_list
def open_write_file(file_to_save, file_permissions):
try:
file = open(file_to_save, file_permissions)
return file
except PermissionError:
print(f"Error: You do not have permission to write to this file {file_to_save}.")
except OSError as e:
print(f"Error: A system error occurred: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
def validate_failure_checks(ip, value, condition, check_type):
if condition:
return True
else:
logger.warning(f"Invalid {check_type}: {value} from {ip}")
return False
def validate_response_size(response, ip):
return validate_failure_checks(ip, response.packet_size, response.packet_size >= NTP._BASE_NTP_PACKET_SIZE and response.packet_size % 4 == 0, "packet size")
def validate_response_mode(response, ip):
match response.input_mode:
case 3:
return validate_failure_checks(ip, response.mode, response.mode == 4, "response mode")
case _:
return False
def validate_origin_timestamp(response, ip):
match response.input_mode:
case 3| 1| 2:
return validate_failure_checks(ip, response.orig_timestamp, response.orig_timestamp == response.sent_timestamp, "origin timestamp")
case _:
return True
def valid_stratum(response, ip):
return validate_failure_checks(ip, response.stratum, 0 < response.stratum < 16, "stratum")
def validate_synchonized(response, ip):
return validate_failure_checks(ip, response.leap, response.leap != LEAP_NOTINSYNC, "synchonized")
def server_version_check(response, ip):
return validate_failure_checks(ip, response.version, response.version == _NTP_EXPECTED_VERSION, "version")
def validate_responses(results):
responses = dict()
for ip, result in results.items():
if result.has_kiss_code:
logger.error(f"Kiss code {result.kiss_name} received from {ip}")
if (validate_response_size(result, ip)
and not result.has_kiss_code
and valid_stratum(result, ip)
and server_version_check(result, ip)
and validate_synchonized(result, ip)
and validate_response_mode(result, ip)
and validate_origin_timestamp(result, ip)):
responses[ip] = result
else:
logger.warning(f"Invalid response from {ip}")
return responses
def add_failure(ip, err, err_msg):
add_to_no_response_count(ip)
logger.warning(f"{err_msg}{ip}: {str(err)}")
def request_packet(ip, retried):
ntp_client = NTPClient()
try:
return ntp_client.request(ip, version=4, mode=3, timeout=20)
except NTPException as err:
add_failure(ip, err, retried)
except socket.timeout as err:
add_failure(ip, err, retried + "Socket timeout for ")
except ConnectionError as err:
add_failure(ip, err, retried + "Connection error for ")
except OSError as err:
add_failure(ip, err, retried + "OS error for ")
except Exception as err:
add_to_no_response_count(ip)
logger.exception(f"{retried}Unexpected error from {ip}: {str(err)}")
return None # Failed to get packet
"""
send requests to a chosen list of ips, return the offsets they return
:param servers: list of ip addresses
:return:
"""
def req_multiple_server_results(servers):
responses = {}
ips_failed = []
for ip in servers:
response = request_packet(ip, "")
if response is not None:
responses[ip] = response
else:
ips_failed.append(ip)
"""
Retry the failed requests
"""
for ip in ips_failed:
response = request_packet(ip, "Retried: ")
if response is not None:
responses[ip] = response
remove_from_no_response_count(ip)
return {ip: responses[ip] for ip in servers if ip in responses}
def req_multiple_server_offsets(servers):
results = req_multiple_server_results(servers)
responses = validate_responses(results)
return {ip: responses[ip].offset for ip in servers if ip in responses}
def get_offset_simple(m, d, k, w, err, servers):
# query chosen servers
offset_list = req_multiple_server_offsets(servers).values()
# check whether all surviving samples are "close"
avg_offset = sum(offset_list) / len(offset_list)
if (
(math.fabs(max(offset_list) - min(offset_list)) <= 2 * w) and
(math.fabs(avg_offset) <= w * 2 + err)
):
return avg_offset
print("failure: %f > %f and/or %f > %f" % (
math.fabs(max(offset_list) - min(offset_list)), 2 * w, math.fabs(avg_offset), w * 2 + err))
return None