Writing a Simple NTP Client

Hello, habrauzers. Today I want to talk about how to write my simple NTP client. Basically, we’ll talk about the structure of the packet and how to process the response from the NTP server. The code will be written in python, because, it seems to me, the best language for such things simply can not be found. Connoisseurs will pay attention to the similarity of the code with the ntplib code - I was “inspired” by it.

So what exactly is NTP? NTP - protocol of interaction with exact time servers. This protocol is used in many modern machines. For example, the w32tm service in windows.

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

Package format




Leap indicator (correction indicator) - a number showing a warning about the second coordination. Value:

  • 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 malfunction (time not synchronized)

Version number - The version number of the NTP protocol (1-4).

Mode - mode of operation of the packet sender. Value from 0 to 7, the 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.).
Poll is a signed integer representing the maximum interval between consecutive messages. Here, the NTP client indicates the interval at which it expects to poll the server, and the NTP server indicates the interval at which it expects to be polled. The value is the binary logarithm of seconds.
Precision is a signed integer representing the accuracy of the system clock. The value is the binary logarithm of seconds.
Root delay(server delay) - the time taken for the clock to reach the NTP server, as the number of seconds with a fixed point.
Root dispersion (scatter of server readings) - the scatter of the clock of the NTP server as the number of seconds with a fixed point.
Ref id (source identifier) ​​- id of the watch. If the server has a stratum of 1, then ref id is the name of the atomic clock (4 ASCII characters). If the server uses another server, then the address of this server is written in the ref id.
The last 4 fields are time - 32 bits - the integer part, 32 bits - fractional part.
Reference - the latest clock on the server.
Originate - the time when the packet was sent (filled by the server - more on that below).
Receive- time the packet was received by the server.
Transmit - the time the packet was sent from the server to the client (filled by the client, more on that below).

We will not consider the last two fields.

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))


To select the fractional part of the number for writing to the package, we need the get_fraction () function:
get_fraction ()
def get_fraction(number, precision):
    return int((number - int(number)) * 2 ** precision)


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 - a code that turns a 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 packet to the server


It is necessary to send a packet to the server with the filled Version , Mode and Transmit fields . In Transmit, you need to specify the current time on the local machine (number of seconds since January 1, 1900), version - any of 1-4, mode - 3 (client mode).

Having accepted the request, the server fills in all the fields in the NTP packet by copying the Transmit value from the request into the Originate field . It is a mystery to me why the client cannot immediately fill in the value of his time in the Originate field . As a result, when the packet arrives back, the client has 4 times - the time the request was sent ( Originate ), the time the server received the request (Receive ), the time the response was sent by the server ( Transmit ), and the time the client received the response is Arrive (not in the packet). Using 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)


Processing data from the server


Processing data from the server is similar to the actions of the English gentleman from the old task of Raymond M. Sullian (1978): “One person did not have a watch, but on the other hand there was an exact wall clock that he sometimes forgot to start. Once, having forgotten to start the watch again, he went to visit his friend, spent the evening at that place, 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 starts the clock and remembers what position the hands are in. Coming to a friend and leaving the guests, he notes the time of his arrival and departure. This allows him to find out how much he was visiting. Returning home and looking at the clock, a person determines the duration of his absence. Subtracting from this time the time which he spent on a visit, a person learns the time spent on the round trip. Having added to the time of leaving the guests half the time spent on the trip, he gets the opportunity to find out the time of arrival home and translate the clock hands accordingly. ”

We find the server working time on the request:

  1. We find the packet path 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

Add the obtained value to the local time and enjoy life.

Output result
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 .

Also popular now: