Le protocole NTP

Voir la vidéo

Il m'arrive régulièrement de m'interroger sur le fonctionnement des systèmes fondamentaux qui nous entourent. Récemment, je me suis penché sur la question de la synchronisation horaire entre un client et un serveur. Comment nos systèmes d'exploitation parviennent-ils à maintenir une heure précise sans intervention manuelle ? En creusant, j'ai découvert que le protocol utilisé est le protocole NTP (Network Time Protocol).

Le réseau NTP

NTP ne se résume pas à un simple protocole, c'est aussi un réseau, organisés en strates hiérarchiques, qui se synchronisent entre eux.

  • Strate 0 : Horloges références (ex: horloges atomiques, GPS).
  • Strate 1 : Serveurs directement connectés aux horloges de strate 0.
  • Strate 2, 3, etc. : Serveurs qui se synchronisent avec les strates 1 ou supérieurs.
  • Clients finaux : Nos machines qui interagissent avec les strate 2 ou supérieurs pour obtenir l'heure.

Par exemple, sur macOS, la synchronisation se fait auprès des serveurs NTP time.apple.com, mais il existe de nombreux autres serveurs publics comme NTP Pool.

Fonctionnement du protocole NTP

NTP repose sur le protocole UDP et utilise par défaut le port 123. Les échanges de données se font en binaire avec une structure bien définie. L'objectif est d'estimer le décalage temporel entre le client et le serveur en s'appuyant sur quatre timestamps :

  • T1 : Moment où le client envoie sa requête.
  • T'1 : Moment où le serveur reçoit la requête.
  • T'2 : Moment où le serveur renvoie sa réponse.
  • T2 : Moment où le client reçoit la réponse.

Avec ces valeurs, on peut calculer :

  • Le temps d'aller-retour ("ping") : (T2 - T1) - (T'2 - T'1)
  • Le décalage horaire : (T'1 - T1 + T'2 - T2) / 2

On peut avec ces deux données ajustée le temps du client pour offrir une horloge la plus précise possible.

Exemple d'appel client (NodeJS)

import { createSocket } from "node:dgram";
import { Buffer } from "node:buffer";

const client = createSocket("udp4");
const ntpData = Buffer.alloc(48);
const formatter = new Intl.DateTimeFormat("fr-FR", {
  timeStyle: "medium",
  dateStyle: "medium",
});

const timer = setTimeout(() => {
  client.close();
  throw new Error("Délai de réponse dépassé");
}, 5_000);

ntpData.writeUint8(35); // Leap Indicator: 0, Version 4, Mode 3 (client)
writeDate(ntpData, new Date(), 40);

client.on("message", (msg) => {
  const now = new Date();
  clearTimeout(timer);
  client.close();
  const ot = readDate(msg, 6 * 4); // Origin Timestamp
  const rt = readDate(msg, 8 * 4); // Receive Timestamp
  const tt = readDate(msg, 10 * 4); // Transmit Timestamp

  const ping = now.getTime() - ot.getTime() - (tt.getTime() - rt.getTime());
  const offset =
    (rt.getTime() - ot.getTime() + (tt.getTime() - now.getTime())) / 2;
  const offsetWithoutPing = offset - ping / 2;
  console.table({
    Ping: ping,
    offset: msToString(offset),
    offsetWithoutPing: msToString(offsetWithoutPing),
    now: formatter.format(now),
    nowCorrected: formatter.format(new Date(now.getTime() + offsetWithoutPing)),
  });
});

client.on("error", (err) => {
  clearTimeout(timer);
  client.close();
  throw err;
});

client.send(ntpData, 0, ntpData.length, 123, "time.apple.com", (err) => {
  if (err) {
    client.close();
    throw err;
  }
});

/**
 * Génère une date JavaScript depuis un timestamp NTP
 */
function readDate(msg: Buffer<ArrayBufferLike>, offset: number): Date {
  const seconds = msg.readUInt32BE(offset);
  const fraction = msg.readUInt32BE(offset + 4);

  // NTP epoch commence le 1er janvier 1900
  // JavaScript epoch commence le 1er janvier 1970
  // Différence : 2208988800 secondes
  return new Date((seconds - 2208988800 + fraction / 0x100000000) * 1_000);
}

/**
 * Écrit une date JavaScript dans le buffer
 */
function writeDate(msg: Buffer<ArrayBufferLike>, date: Date, offset: number) {
  const epoch = date.getTime() / 1_000 + 2208988800;
  const seconds = Math.floor(epoch);
  const fraction = Math.floor((epoch - seconds) * 0x100000000);
  msg.writeUInt32BE(seconds, offset);
  msg.writeUInt32BE(fraction, offset + 4);
}

/**
 * Affiche le temps actuel (MM:SS.mmm)
 */
function msToString(ms: number): string {
  const minutes = Math.floor(ms / 60000);
  const seconds = Math.floor((ms % 60000) / 1000);
  const milliseconds = ms % 1000;

  return `${minutes}:${seconds.toString().padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}`;
}

L'horloge du client est alors ajustée en fonction de ce décalage pour assurer une synchronisation précise.

Application au web

Sur le web, on ne peut pas directement interroger un serveur NTP mais on peut utiliser le principe de ce protocole avec un serveur HTTP pour synchroniser un client avec l'heure de notre serveur. C'est par exemple ce que font des services comme time.is utilisent des méthodes similaires basées sur des requêtes HTTP ultra-légères pour estimer le décalage horaire.

Publié
Technologies utilisées
Auteur :
Grafikart
Partager