Пишем простой NTP-клиент

Здравствуйте, хабраюзеры. Сегодня я хочу рассказать о том, как написать свой простенький NTP клиент. В основном, разговор зайдет о структуре пакета и способе обработки ответа с NTP сервера. Код будет написан на питоне, потому что, как мне кажется, лучшего языка для подобных вещей просто не найти. Знатоки обратят внимание на схожесть кода с кодом ntplib — я «вдохновлялся» именно им.

Итак, что вообще такое NTP? NTP – протокол взаимодействия с серверами точного времени. Этот протокол используется во многих современных машинах. Например, служба w32tm в windows.

Всего существует 5 версий NTP протокола. Первая, 0-я версия (1985 г, RFC958)), в данный момент считается устаревшей. Сейчас используются более новые, 1-я (1988, RFC1059), 2-я(1989, RFC1119), 3-я(1992, RFC1305) и 4-я(1996, RFC2030). 1-4 версии являются совместимыми друг с другом, они отличаются лишь алгоритмами работы серверов.

Формат пакета




Leap indicator (индикатор коррекции) — число, показывающее предупреждение о секунде координации. Значение:

  • 0 – нет коррекции
  • 1 – последняя минута дня содержит 61 секунду
  • 2 – последняя минута дня содержит 59 секунд
  • 3 – неисправность сервера (время не синхронизировано)

Version number (номер версии) – номер версии протокола NTP (1-4).

Mode (режим) — режим работы отправителя пакета. Значение от 0 до 7, наиболее частые:

  • 3 – клиент
  • 4 – сервер
  • 5 – широковещательный режим

Stratum (уровень наслоения) – количество промежуточных слоев между сервером и эталонными часами (1 – сервер берет данные непосредственно с эталонных часов, 2 – сервер берет данные с сервера с уровнем 1 и т.д.).
Poll — целое число со знаком, представляющее максимальный интервал между последовательными сообщениями. NTP-клиент указывает здесь интервал, с которым он предполагает опрашивать сервер, а NTP-сервер – интервал, с которым он предполагает, чтобы его опрашивали. Значение равно двоичному логарифму секунд.
Precision (точность) — целое число со знаком, представляющее точность системных часов. Значение равно двоичному логарифму секунд.
Root delay (задержка сервера) – время, за которое показания часов доходят до NTP-сервера, как число секунд с фиксированной точкой.
Root dispersion (разброс показаний сервера) — разброс показаний часов NTP-сервера как число секунд с фиксированной точкой.
Ref id (идентификатор источника) – id часов. Если сервер имеет стратум 1, то ref id – название атомных часов (4 ASCII символа). Если сервер использует другой сервер, то в ref id записан адрес этого сервера.
Последние 4 поля представляют из себя время – 32 бита – целая часть, 32 бита – дробная часть.
Reference — последние показания часов на сервере.
Originate – время, когда пакет был отправлен (заполняется сервером – об этом ниже).
Receive – время получения пакета сервером.
Transmit – время отправки пакета с сервера клиенту (заполняется клиентом, об этом тоже ниже).

Два последних поля рассматривать не будем.

Напишем наш пакет:

Код пакета
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


Чтобы слать (и принимать) пакет на сервер, мы должны уметь превращать его в массив байт.
Для этой (и обратной) операции напишем две функции — pack() и unpack():

Функция pack
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))


Чтобы выделить дробную часть числа для записи в пакет, нам понадобится функция get_fraction():
get_fraction()
def get_fraction(number, precision):
    return int((number - int(number)) * 2 ** precision)


Функция unpack
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


Для лентяев, в качестве приложения – код, превращающий пакет в красивую строку
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)


Отправка пакета на сервер


На сервер необходимо отправить пакет с заполненными полями Version, Mode и Transmit. В Transmit необходимо указать текущее время на локальной машине (количество секунд с 1 января 1900 г), версия — любая из 1-4, режим — 3 (режим клиента).

Сервер, приняв запрос, заполняет в NTP-пакете все поля, скопировав в поле Originate значение из Transmit, пришедшее в запросе. Для меня является загадкой, почему клиент не может сразу заполнить значение своего времени в поле Originate. В итоге, когда пакет приходит обратно, у клиента есть 4 значения времени – время отправки запроса (Originate), время получения запроса сервером (Receive), время отправки ответа сервером (Transmit) и время получения ответа клиентом – Arrive (нет в пакете). С помощью этих значений мы можем установить корректное время.

Код отправки и получения пакета
# 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)


Обработка данных с сервера


Обработка данных с сервера аналогична действиям английского джентльмена из старой задачи Рэймонда М. Смаллиана (1978): «У одного человека не было наручных часов, но зато дома висели точные настенные часы, которые он иногда забывал заводить. Однажды, забыв очередной раз завести часы, он отправился в гости к своему другу, провел у того вечер, а вернувшись домой, сумел правильно поставить часы. Каким образом ему удалось это сделать, если время в пути заранее известно не было?» Ответ таков: «Выходя из дома, человек заводит часы и запоминает, в каком положении находятся стрелки. Придя к другу и уходя из гостей, он отмечает время своего прихода и ухода. Это позволяет ему узнать, сколько он находился в гостях. Вернувшись домой и взглянув на часы, человек определяет продолжительность своего отсутствия. Вычитая из этого времени то время, которое он провел в гостях, человек узнает время, затраченное на дорогу туда и обратно. Прибавив ко времени выхода из гостей половину времени, затраченного на дорогу, он получает возможность узнать время прихода домой и перевести соответствующим образом стрелки своих часов.»

Находим время работы сервера над запросом:

  1. Находим время пути пакета от клиента к серверу: ((Arrive – Originate) — (Transmit – Receive)) / 2
  2. Находим разницу между временем клиента и сервера:
    Receive — Originate — ((Arrive – Originate) — (Transmit – Receive)) / 2 =
    2 * Receive — 2 * Originate – Arrive + Originate + Transmit – Receive =
    Receive – Originate – Arrive + Transmit

Добавляем полученное значение к локальному времени и радуемся жизни.

Вывод результата
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)


Полезная ссылка.
Поделиться публикацией

Комментарии 12

    0
    Вот бы ещё понять, почему время в пути пакета в одну сторону равно времени в пути пакета в обратную сторону? Как учитывается время на обработку самого пакета?

    В любом случае было интересно почитать, с нетерпением ждем разбор более современного протокола PTP (IEEE 1588).
      0
      Формула нахождения времени пакета в пути — ((Arrive – Originate) — (Transmit – Receive)) / 2.
      (Transmit — Receive) — как раз время обработки пакета на сервере.
        +1

        Я думаю, что речь о том, что в общем случае, если сервер и клиент не в одной сети, то гарантировать идентичность времени "туда" и "обратно" в принципе невозможно

      0
      более современного протокола PTP
      У PTP иное предназначение и время там используется другое (международное атомное).
        0
        Просто и понятно, спасибо.
          0

          Ещё интересный вопрос — насколько корректно использовать битовую арифметику, учитывая, что интерпретатор python может быть запущен на множестве разных платформ с различным порядком байтов и различным размером типов переменных.

            0
            Вполне корректно: модуль struct позволяет явно задавать порядок байт, в данном случае использован модификатор ! в дескрипторе _FORMAT.
            The form '!' is available for those poor souls who claim they can’t remember whether network byte order is big-endian or little-endian.

            Размеры полей указаны там же (в _FORMAT).
            0

            get_fraction не забыли? :)

              0
              Действительно, забыл. Сейчас добавлю.
                +1
                К чему такие сложности:
                def get_fraction(number, precision):
                    return int(number % 1. * 2. ** precision)
                

                И зачем вообще так мучаться, ведь можно то же самое сделать проще и надёжней:
                f = 12345.6789
                bb = struct.pack('!II', int(f), get_fraction(f, 32))
                g = struct.unpack('!Q', bb)[0] / 2 ** 32
                print(g)
                

                Разбивая октет на два 32-разрядных, вы вручную задаёте порядок их следования — это прямая дорожка в ад )
              +1
              А разве в результате обработки пакета надо менять системное время? Всегда думал, что надо корректировать ход системных часов…
                0
                Я не рассматривал корректировку времени, только работу с протоколом.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое