Scriviamo un semplice client NTP

Ciao, utenti di Habr. Oggi voglio raccontarvi come scrivere un semplice client NTP. Principalmente, parleremo della struttura del pacchetto e del modo in cui elaborare la risposta dal server NTP. Il codice sarà scritto in Python, perché, a mio avviso, non c'è un linguaggio migliore per questo tipo di cose. Gli esperti noteranno la somiglianza del codice con quello di ntplib: mi sono 'ispirato' proprio a lui.

Quindi, cos'è esattamente l'NTP? L'NTP è un protocollo per l'interazione con i server di tempo preciso. Questo protocollo è utilizzato in molte macchine moderne. Ad esempio, il servizio w32tm in Windows.

Esistono in totale 5 versioni del protocollo NTP. La prima, la versione 0 (1985, RFC958), è attualmente considerata obsoleta. Oggi vengono utilizzate versioni più recenti, la 1 (1988, RFC1059), la 2 (1989, RFC1119), la 3 (1992, RFC1305) e la 4 (1996, RFC2030). Le versioni 1-4 sono compatibili tra loro, differiscono solo per gli algoritmi di funzionamento. server.

Formato del pacchetto

Scriviamo un semplice client NTP

Indicatore di salto (correttore di salto) — numero che indica un avviso di secondo coordinato. Valore:

  • 0 – nessuna correzione
  • 1 – l'ultimo minuto del giorno contiene 61 secondi
  • 2 – l'ultimo minuto del giorno contiene 59 secondi
  • 3 – guasto del server (tempo non sincronizzato)

Numero di versione (numero di versione) – numero della versione del protocollo NTP (1-4).

Modalità (modalità) — modalità operativa del mittente del pacchetto. Valore da 0 a 7, più comuni:

  • 3 – client
  • 4 – server
  • 5 – modalità broadcast

Stratum (livello di stratificazione) – numero di strati intermedi tra il server e gli orologi di riferimento (1 – il server prende i dati direttamente dagli orologi di riferimento, 2 – il server prende i dati da un server di livello 1, ecc.).
Pool — numero intero con segno che rappresenta l'intervallo massimo tra i messaggi consecutivi. Il client NTP indica qui l'intervallo con cui prevede di interpellare il server, e il server NTP – l'intervallo con cui prevede di essere interrogato. Il valore corrisponde al logaritmo binario dei secondi.
Precisione (precisione) — numero intero con segno che rappresenta la precisione degli orologi di sistema. Il valore corrisponde al logaritmo binario dei secondi.
Ritardo radice (ritardo del server) – tempo impiegato dalle rilevazioni degli orologi per raggiungere il server NTP, come numero di secondi con punto fisso.
Dispersione radice (disperazione delle rilevazioni del server) — dispersione delle rilevazioni degli orologi del server NTP come numero di secondi con punto fisso.
ID di riferimento (identificatore della sorgente) – id dell'orologio. Se il server ha stratificazione 1, ref id è il nome dell'orologio atomico (4 caratteri ASCII). Se il server utilizza un altro server, l'indirizzo di quest'ultimo è riportato in ref id.
Gli ultimi 4 campi rappresentano il tempo – 32 bit – parte intera, 32 bit – parte frazionaria.
Riferimento – ultime letture dell'orologio sul server.
Origina – tempo in cui il pacchetto è stato inviato (compilato dal server – di seguito ne parleremo).
Ricevi – tempo di ricezione del pacchetto da parte del server.
Trasmetti – tempo di invio del pacchetto dal server al client (compilato dal client, di questo parleremo anche più avanti).

Non consideriamo gli ultimi due campi.

Scriviamo il nostro pacchetto:

Codice del pacchetto

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

    def __init__(self, version_number=2, mode=3, transmit=0):
        # Necessario per inserire il secondo bisestile (2 bit)
        self.leap_indicator = 0
        # Versione del protocollo (3 bit)
        self.version_number = version_number
        # Modalità del mittente (3 bit)
        self.mode = mode
        # Il livello della lettura del tempo (1 byte)
        self.stratum = 0
        # Intervallo tra le richieste (1 byte)
        self.pool = 0
        # Precisione (log2) (1 byte)
        self.precision = 0
        # Intervallo per il raggiungimento del server NTP (4 byte)
        self.root_delay = 0
        # Dispersione dell'orologio NTP (4 byte)
        self.root_dispersion = 0
        # Indicatore degli orologi (4 byte)
        self.ref_id = 0
        # Ultimo tempo di aggiornamento sul server (8 byte)
        self.reference = 0
        # Tempo di invio del pacchetto dalla macchina locale (8 byte)
        self.originate = 0
        # Tempo di ricezione sul server (8 byte)
        self.receive = 0
        # Tempo di invio della risposta dal server (8 byte)
        self.transmit = transmit

Per inviare (e ricevere) un pacchetto dal server, dobbiamo essere in grado di convertirlo in un array di byte.
Per questa (e l'operazione inversa) scriviamo due funzioni — pack() e unpack():

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

Funzione unpack

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

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

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

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

        # 4 byte
        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 byte
        self.originate = unpacked_data[9] + unpacked_data[10] / 2 ** 32  # 8 byte
        self.receive = unpacked_data[11] + unpacked_data[12] / 2 ** 32  # 8 byte
        self.transmit = unpacked_data[13] + unpacked_data[14] / 2 ** 32  # 8 byte

        return self

Per i pigri, come allegato – codice che trasforma il pacchetto in una bella stringa

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)

Invio del pacchetto al server

È necessario inviare un pacchetto al server con i campi compilati Version, Modalità e Trasmetti. In Trasmetti è necessario specificare l'ora corrente sulla macchina locale (numero di secondi dal 1 gennaio 1900), versione – qualsiasi da 1 a 4, modalità – 3 (modalità client).

Il server, una volta ricevuta la richiesta, compila tutti i campi nel pacchetto NTP copiando nel campo Origina il valore da Trasmetti, ricevuto nella richiesta. Mi sembra strano perché il client non possa subito compilare il valore del suo tempo nel campo Origina. Alla fine, quando il pacchetto torna indietro, il client ha 4 valori di tempo – il tempo di invio della richiesta (Origina), il tempo di ricezione della richiesta da parte del server (Ricevi), il tempo di invio della risposta da parte del server (Trasmetti) e il tempo di ricezione della risposta da parte del client – Arrivo (non presente nel pacchetto). Con questi valori possiamo determinare l'ora corretta.

Codice per l'invio e la ricezione del pacchetto

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

Elaborazione dei dati dal server

L'elaborazione dei dati dal server è simile alle azioni di un gentiluomo inglese in un vecchio problema di Raymond M. Smullyan (1978): «Un uomo non aveva un orologio da polso, ma a casa aveva un orologio a muro preciso, che dimenticava di caricare ogni tanto. Un giorno, dimenticandosi di caricare di nuovo l'orologio, andò a trovare un amico, trascorse la serata da lui e al suo ritorno a casa riuscì a impostare correttamente l'orologio. Come ha fatto? Poiché il tempo di viaggio non era noto in anticipo?» La risposta è: «Uscendo di casa, l'uomo carica l'orologio e si ricorda in quale posizione si trovano le lancette. Giunto dall'amico e partendo, segna l'orario del suo arrivo e della sua partenza. Questo gli consente di sapere per quanto tempo è rimasto in visita. Tornato a casa e guardando l'orologio, l'uomo determina la durata della sua assenza. Sottraendo da questo tempo il tempo che ha trascorso in visita, scopre il tempo impiegato per il viaggio di andata e ritorno. Aggiungendo alla durata della visita la metà del tempo impiegato per il viaggio, ottiene la possibilità di conoscere l'orario di ritorno a casa e di riportare le lancette del suo orologio di conseguenza.»

Calcoliamo il tempo di lavoro del server sulla richiesta:

  1. Calcoliamo il tempo di viaggio del pacchetto dal client al server: ((Arrivo – Origine) — (Trasmetti – Ricevi)) / 2
  2. Calcoliamo la differenza tra l'orario del client e del server:
    Ricevi — Origine — ((Arrivo – Origine) — (Trasmetti – Ricevi)) / 2 =
    2 * Ricevi — 2 * Origine – Arrivo + Origine + Trasmetti – Ricevi =
    Ricevi – Origine – Arrivo + Trasmetti

Aggiungiamo il valore ottenuto all'orario locale e godiamoci la vita.

Mostra il risultato

time_different = answer.get_time_different(arrive_time)
result = "Differenza di tempo: {}nOrario del server: {}n{}".format(
    time_different,
    datetime.datetime.fromtimestamp(time.time() + time_different).strftime("%c"),
    answer.to_display())
print(result)

Utilità link.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS - ProHoster