Einen einfachen NTP-Client schreiben

Hallo Habrauser. Heute möchte ich darüber sprechen, wie Sie Ihren eigenen einfachen NTP-Client schreiben. Im Wesentlichen dreht sich das Gespräch um die Struktur des Pakets und die Methode zur Verarbeitung der Antwort vom NTP-Server. Der Code wird in Python geschrieben, da es meiner Meinung nach einfach keine bessere Sprache für solche Dinge gibt. Kennern wird die Ähnlichkeit des Codes mit dem ntplib-Code auffallen – er hat mich „inspiriert“.

Was genau ist NTP? NTP ist ein Protokoll zur Interaktion mit exakten Zeitservern. Dieses Protokoll wird in vielen modernen Maschinen verwendet. Zum Beispiel der w32tm-Dienst in Windows.

Insgesamt gibt es 5 Versionen des NTP-Protokolls. Die erste Version 0 (1985, RFC958) gilt derzeit als veraltet. Jetzt werden die neueren verwendet, 1. (1988, RFC1059), 2. (1989, RFC1119), 3. (1992, RFC1305) und 4. (1996, RFC2030). Die Versionen 1-4 sind untereinander kompatibel; sie unterscheiden sich lediglich in den Serverbetriebsalgorithmen.

Paketformat

Einen einfachen NTP-Client schreiben

Sprunganzeige (Korrekturanzeige) – eine Zahl, die eine Warnung vor der Koordinationssekunde anzeigt. Bedeutung:

  • 0 – keine Korrektur
  • 1 – die letzte Minute des Tages umfasst 61 Sekunden
  • 2 – die letzte Minute des Tages umfasst 59 Sekunden
  • 3 – Serverstörung (Zeit ist nicht synchronisiert)

Versionsnummer (Versionsnummer) – Versionsnummer des NTP-Protokolls (1-4).

Model (Modus) – Betriebsmodus des Paketsenders. Wert von 0 bis 7, am häufigsten:

  • 3 – Kunde
  • 4 – Server
  • 5 – Broadcast-Modus

Schicht (Schichtebene) – die Anzahl der Zwischenschichten zwischen dem Server und der Referenzuhr (1 – der Server übernimmt Daten direkt von der Referenzuhr, 2 – der Server übernimmt Daten von einem Server mit Schicht 1 usw.).
Pool ist eine vorzeichenbehaftete Ganzzahl, die das maximale Intervall zwischen aufeinanderfolgenden Nachrichten darstellt. Der NTP-Client gibt hier das Intervall an, in dem er voraussichtlich den Server abfragen wird, und der NTP-Server gibt das Intervall an, in dem er voraussichtlich abgefragt wird. Der Wert entspricht dem binären Logarithmus von Sekunden.
Präzision (Genauigkeit) ist eine vorzeichenbehaftete Ganzzahl, die die Genauigkeit der Systemuhr angibt. Der Wert entspricht dem binären Logarithmus von Sekunden.
Root-Verzögerung (Serververzögerung) – die Zeit, die die Uhr benötigt, um den NTP-Server zu erreichen, als Festkommazahl in Sekunden.
Wurzelverteilung (Serverstreuung) – Streuung der NTP-Serveruhrwerte als Anzahl von Sekunden mit einem festen Punkt.
Referenz-ID (Quellenkennung) ​​– Uhr-ID. Wenn der Server Stratum 1 hat, ist die Ref-ID der Name der Atomuhr (4 ASCII-Zeichen). Wenn der Server einen anderen Server verwendet, enthält die Ref-ID die Adresse dieses Servers.
Die letzten 4 Felder stellen die Zeit dar – 32 Bit – der ganzzahlige Teil, 32 Bit – der Bruchteil.
Referenz — die neuesten Uhrstände auf dem Server.
Entstehen – Zeitpunkt, zu dem das Paket gesendet wurde (wird vom Server ausgefüllt – mehr dazu weiter unten).
Eingang – Zeitpunkt, zu dem das Paket vom Server empfangen wurde.
Senden – Zeitpunkt des Sendens des Pakets vom Server an den Client (vom Client ausgefüllt, mehr dazu weiter unten).

Die letzten beiden Felder werden wir nicht berücksichtigen.

Schreiben wir unser Paket:

Paketcode

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

Um ein Paket an den Server zu senden (und zu empfangen), müssen wir es in ein Byte-Array umwandeln können.
Für diesen (und umgekehrten) Vorgang schreiben wir zwei Funktionen – pack() und unpack():

Packfunktion

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

Auspackfunktion

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

Für faule Leute, als Anwendungscode, der ein Paket in einen schönen String verwandelt

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)

Senden eines Pakets an den Server

Ein Paket mit ausgefüllten Feldern muss an den Server gesendet werden Version, Model и Senden. In Senden Sie müssen die aktuelle Uhrzeit auf dem lokalen Computer angeben (die Anzahl der Sekunden seit dem 1. Januar 1900), die Version – eine von 1–4, den Modus – 3 (Client-Modus).

Nachdem der Server die Anfrage angenommen hat, füllt er alle Felder im NTP-Paket aus und kopiert sie in das Feld Entstehen Wert von Senden, was in der Anfrage enthalten war. Es ist mir ein Rätsel, warum der Kunde den Wert seiner Zeit vor Ort nicht sofort angeben kann Entstehen. Wenn das Paket zurückkommt, hat der Client daher 4 Zeitwerte – den Zeitpunkt, zu dem die Anfrage gesendet wurde (Entstehen), Zeitpunkt, zu dem der Server die Anfrage empfangen hat (Eingang), Zeitpunkt, zu dem der Server die Antwort gesendet hat (Senden) und der Zeitpunkt, zu dem der Kunde die Antwort erhielt – Ankommen (nicht im Paket). Anhand dieser Werte können wir die richtige Zeit einstellen.

Paket-Sende- und Empfangscode

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

Verarbeitung von Daten vom Server

Die Verarbeitung von Daten vom Server ähnelt den Aktionen des englischen Gentleman aus dem alten Problem von Raymond M. Smullyan (1978): „Ein Mann hatte keine Armbanduhr, aber zu Hause gab es eine genaue Wanduhr, die er manchmal vergaß.“ winden. Als er eines Tages vergessen hatte, seine Uhr wieder aufzuziehen, besuchte er seinen Freund, verbrachte den Abend mit ihm und als er nach Hause zurückkehrte, gelang es ihm, die Uhr richtig einzustellen. Wie schaffte er das, wenn die Reisezeit nicht im Voraus bekannt war? Die Antwort lautet: „Wenn man das Haus verlässt, zieht man seine Uhr auf und merkt sich, in welcher Position sich die Zeiger befinden. Nachdem er zu einem Freund gekommen ist und die Gäste verlassen hat, notiert er die Zeit seiner Ankunft und Abreise. Dadurch kann er herausfinden, wie lange er zu Besuch war. Wenn eine Person nach Hause zurückkehrt und auf die Uhr schaut, bestimmt sie die Dauer ihrer Abwesenheit. Indem man von dieser Zeit die Besuchszeit abzieht, erhält man die Zeit, die man für die Hin- und Rückreise aufgewendet hat. Indem er die Hälfte der Zeit, die er unterwegs verbringt, zu der Zeit addiert, in der er die Gäste verlässt, erhält er die Möglichkeit, die Ankunftszeit zu Hause herauszufinden und die Zeiger seiner Uhr entsprechend anzupassen.“

Ermitteln Sie die Zeit, zu der der Server an einer Anfrage arbeitet:

  1. Ermitteln Sie die Laufzeit des Pakets vom Client zum Server: ((Ankommen – Ursprung) – (Senden – Empfangen)) / 2
  2. Finden Sie den Unterschied zwischen Client- und Serverzeit:
    Empfangen – Ursprung – ((Ankommen – Ursprung) – (Senden – Empfangen)) / 2 =
    2 * Empfangen – 2 * Ursprung – Ankommen + Ursprung + Senden – Empfangen =
    Empfangen – Entstehen – Ankommen + Senden

Wir addieren den daraus resultierenden Mehrwert zur Ortszeit und genießen das Leben.

Ausgabe des Ergebnisses

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)

Nützlich Link.

Source: habr.com

Kommentar hinzufügen