Writing a simple NTP client

Hello habrausers. Today I want to talk about how to write your own simple NTP client. Basically, the conversation will turn to the structure of the packet and how the response from the NTP server is processed. The code will be written in python, because, in my opinion, there is simply no better language for such things. Connoisseurs will pay attention to the similarity of the code with the ntplib code - I was "inspired" by it.

So what is NTP anyway? NTP is a protocol for communicating with time servers. This protocol is used in many modern machines. For example, the w32tm service on windows.

There are 5 versions of the NTP protocol in total. The first, version 0 (1985, RFC958) is currently considered obsolete. Newer ones are currently used, 1st (1988, RFC1059), 2nd (1989, RFC1119), 3rd (1992, RFC1305) and 4th (1996, RFC2030). Versions 1-4 are compatible with each other, they differ only in the algorithms of the servers.

Packet Format

Writing a simple NTP client

Leap indicator (correction indicator) is a number that indicates the leap second warning. Meaning:

  • 0 - no correction
  • 1 - the last minute of the day contains 61 seconds
  • 2 - the last minute of the day contains 59 seconds
  • 3 - server failure (time out of sync)

Version number (version number) – NTP protocol version number (1-4).

Fashion (mode) β€” operation mode of the packet sender. Value from 0 to 7, most common:

  • 3 - client
  • 4 - server
  • 5 - broadcast mode

stratum (layering level) - the number of intermediate layers between the server and the reference clock (1 - the server takes data directly from the reference clock, 2 - the server takes data from the server with level 1, etc.).
pool is a signed integer representing the maximum interval between consecutive messages. The NTP client specifies here the interval at which it expects to poll the server, and the NTP server specifies the interval at which it expects to be polled. The value is equal to the binary logarithm of seconds.
Precision (precision) is a signed integer representing the accuracy of the system clock. The value is equal to the binary logarithm of seconds.
root delay (server latency) is the time it takes for the clock to reach the NTP server, as a fixed-point number of seconds.
root dispersion (server scatter) - The scatter of the NTP server clock as a fixed-point number of seconds.
Ref id (source id) – watch id. If the server has stratum 1, then ref id is the name of the atomic clock (4 ASCII characters). If the server uses another server, then the ref id contains the address of this server.
The last 4 fields are the time - 32 bits - the integer part, 32 bits - the fractional part.
References - the latest clock on the server.
originate – time when the packet was sent (filled in by the server – more on that below).
Receive – time when the packet was received by the server.
Transmit – time when the packet was sent from the server to the client (filled in by the client, more on that below).

The last two fields will not be considered.

Let's write our package:

Package code

class NTPPacket:
    _FORMAT = "!B B b b 11I"

    def __init__(self, version_number=2, mode=3, transmit=0):
        # Necessary of enter leap second (2 bits)
        self.leap_indicator = 0
        # Version of protocol (3 bits)
        self.version_number = version_number
        # Mode of sender (3 bits)
        self.mode = mode
        # The level of "layering" reading time (1 byte)
        self.stratum = 0
        # Interval between requests (1 byte)
        self.pool = 0
        # Precision (log2) (1 byte)
        self.precision = 0
        # Interval for the clock reach NTP server (4 bytes)
        self.root_delay = 0
        # Scatter the clock NTP-server (4 bytes)
        self.root_dispersion = 0
        # Indicator of clocks (4 bytes)
        self.ref_id = 0
        # Last update time on server (8 bytes)
        self.reference = 0
        # Time of sending packet from local machine (8 bytes)
        self.originate = 0
        # Time of receipt on server (8 bytes)
        self.receive = 0
        # Time of sending answer from server (8 bytes)
        self.transmit = transmit

To send (and receive) a packet to the server, we must be able to turn it into an array of bytes.
For this (and reverse) operation, we will write two functions - pack() and unpack():

pack function

def pack(self):
        return struct.pack(NTPPacket._FORMAT,
                (self.leap_indicator << 6) + 
                    (self.version_number << 3) + self.mode,
                self.stratum,
                self.pool,
                self.precision,
                int(self.root_delay) + get_fraction(self.root_delay, 16),
                int(self.root_dispersion) + 
                    get_fraction(self.root_dispersion, 16),
                self.ref_id,
                int(self.reference),
                get_fraction(self.reference, 32),
                int(self.originate),
                get_fraction(self.originate, 32),
                int(self.receive),
                get_fraction(self.receive, 32),
                int(self.transmit),
                get_fraction(self.transmit, 32))

unpack function

def unpack(self, data: bytes):
        unpacked_data = struct.unpack(NTPPacket._FORMAT, data)

        self.leap_indicator = unpacked_data[0] >> 6  # 2 bits
        self.version_number = unpacked_data[0] >> 3 & 0b111  # 3 bits
        self.mode = unpacked_data[0] & 0b111  # 3 bits

        self.stratum = unpacked_data[1]  # 1 byte
        self.pool = unpacked_data[2]  # 1 byte
        self.precision = unpacked_data[3]  # 1 byte

        # 2 bytes | 2 bytes
        self.root_delay = (unpacked_data[4] >> 16) +
            (unpacked_data[4] & 0xFFFF) / 2 ** 16
         # 2 bytes | 2 bytes
        self.root_dispersion = (unpacked_data[5] >> 16) +
            (unpacked_data[5] & 0xFFFF) / 2 ** 16 

        # 4 bytes
        self.ref_id = str((unpacked_data[6] >> 24) & 0xFF) + " " + 
                      str((unpacked_data[6] >> 16) & 0xFF) + " " +  
                      str((unpacked_data[6] >> 8) & 0xFF) + " " +  
                      str(unpacked_data[6] & 0xFF)

        self.reference = unpacked_data[7] + unpacked_data[8] / 2 ** 32  # 8 bytes
        self.originate = unpacked_data[9] + unpacked_data[10] / 2 ** 32  # 8 bytes
        self.receive = unpacked_data[11] + unpacked_data[12] / 2 ** 32  # 8 bytes
        self.transmit = unpacked_data[13] + unpacked_data[14] / 2 ** 32  # 8 bytes

        return self

For lazy people, as an application - code that turns the package into a beautiful string

def to_display(self):
        return "Leap indicator: {0.leap_indicator}n" 
                "Version number: {0.version_number}n" 
                "Mode: {0.mode}n" 
                "Stratum: {0.stratum}n" 
                "Pool: {0.pool}n" 
                "Precision: {0.precision}n" 
                "Root delay: {0.root_delay}n" 
                "Root dispersion: {0.root_dispersion}n" 
                "Ref id: {0.ref_id}n" 
                "Reference: {0.reference}n" 
                "Originate: {0.originate}n" 
                "Receive: {0.receive}n" 
                "Transmit: {0.transmit}"
                .format(self)

Sending a package to the server

Send a packet with filled fields to the server Version, Fashion ΠΈ Transmit. In Transmit you must specify the current time on the local machine (number of seconds since January 1, 1900), version - any of 1-4, mode - 3 (client mode).

The server, having received the request, fills in all the fields in the NTP packet, copying into the field originate value from Transmit, which came in the request. It is a mystery to me why the client cannot immediately fill in the value of his time in the field originate. As a result, when the packet comes back, the client has 4 time values ​​- the time the request was sent (originate), the time the server received the request (Receive), the time the server sent the response (Transmit) and the time of receipt of a response by the client - Arrive (not in the package). With these values ​​we can set the correct time.

Package sending and receiving code

# Time difference between 1970 and 1900, seconds
FORMAT_DIFF = (datetime.date(1970, 1, 1) - datetime.date(1900, 1, 1)).days * 24 * 3600
# Waiting time for recv (seconds)
WAITING_TIME = 5
server = "pool.ntp.org"
port = 123
    
packet = NTPPacket(version_number=2, mode=3, transmit=time.time() + FORMAT_DIFF)
answer = NTPPacket()
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
    s.settimeout(WAITING_TIME)
    s.sendto(packet.pack(), (server, port))
    data = s.recv(48)
    arrive_time = time.time() + FORMAT_DIFF
    answer.unpack(data)

Data processing from the server

The processing of data from the server is similar to the actions of the English gentleman from the old problem of Raymond M. Smallian (1978): β€œOne person did not have a wristwatch, but there was an accurate wall clock at home, which he sometimes forgot to wind. One day, forgetting to start the clock again, he went to visit his friend, spent the evening with him, and when he returned home, he managed to set the clock correctly. How did he manage to do this if the travel time was not known in advance? The answer is: β€œLeaving the house, a person winds up the clock and remembers the position of the hands. Coming to a friend and leaving the guests, he notes the time of his arrival and departure. This allows him to find out how long he was away. Returning home and looking at the clock, a person determines the duration of his absence. Subtracting from this time the time that he spent visiting, the person finds out the time spent on the road there and back. By adding half the time spent on the road to the time of leaving the guests, he gets the opportunity to find out the time of arrival home and adjust the hands of his clock accordingly.

Find the time the server was working on the request:

  1. Find the packet travel time from the client to the server: ((Arrive - Originate) - (Transmit - Receive)) / 2
  2. Find the difference between client and server time:
    Receive - Originate - ((Arrive - Originate) - (Transmit - Receive)) / 2 =
    2 * Receive - 2 * Originate - Arrive + Originate + Transmit - Receive =
    Receive - Originate - Arrive + Transmit

We add the received value to the local time and enjoy life.

Result Output

time_different = answer.get_time_different(arrive_time)
result = "Time difference: {}nServer time: {}n{}".format(
    time_different,
    datetime.datetime.fromtimestamp(time.time() + time_different).strftime("%c"),
    answer.to_display())
print(result)

Useful link.

Source: habr.com

Add a comment