Files
lneto/ntp/client_test.go
T
Marvin Drees bba913751a feat(ntp): add two-exchange client, server handler, and extension fields (#101)
Implement full two-exchange NTP client state machine (RFC 5905 §8).
First exchange stores offset/RTT, second averages both for improved
accuracy. Client places T1 in TransmitTime per RFC 5905 §8; server
echoes it as OriginTime in the response.

Add NTP extension field codec (RFC 7822) with NextExtField iterator
and AppendExtField builder. Add NTS extension type constants from
RFC 8915.

Add NTP Server (StackNode) that receives client requests via Demux
and builds server responses via Encapsulate with configurable
stratum, precision, reference ID, and pending request queue.

Add Frame accessor methods: RawData(), ExtensionFields(),
ValidateSize(), and Timestamp.Uint64().

Add ntp-client and ntp-server example programs with
CalculateSystemPrecision, retry limits, and backoff.

Use internal.LogAttrs pattern for non-allocating structured logging
in the client, matching the tcp/debug.go convention.

Generated with Claude assistance.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-04-27 13:27:08 -03:00

283 lines
7.7 KiB
Go

package ntp
import (
"testing"
"time"
)
// simulateServerResponse builds a server NTP response that echoes the client's
// TransmitTime as the response's OriginTime (RFC 5905 §8), then sets server
// receive and transmit timestamps.
func simulateServerResponse(t *testing.T, reqBuf []byte, serverRecv, serverXmt time.Time) []byte {
t.Helper()
reqFrm, _ := NewFrame(reqBuf)
respBuf := make([]byte, SizeHeader)
respFrm, _ := NewFrame(respBuf)
respFrm.SetFlags(ModeServer, Version4, LeapNoWarning)
respFrm.SetStratum(StratumPrimary)
respFrm.SetPrecision(-20)
// Server echoes client's TransmitTime as response OriginTime per RFC 5905 §8.
respFrm.SetOriginTime(reqFrm.TransmitTime())
recvTS, err := TimestampFromTime(serverRecv)
if err != nil {
t.Fatal(err)
}
xmtTS, err := TimestampFromTime(serverXmt)
if err != nil {
t.Fatal(err)
}
respFrm.SetReceiveTime(recvTS)
respFrm.SetTransmitTime(xmtTS)
return respBuf
}
func TestClient_FullExchange(t *testing.T) {
// Simulate a NTP client-server exchange without network.
baseTime := BaseTime()
clientStart := baseTime.Add(10 * time.Second)
serverOffset := 500 * time.Millisecond // server is 500ms ahead
clockTime := clientStart
client := Client{}
client.Reset(-18, func() time.Time { return clockTime })
if client.IsDone() {
t.Fatal("client should not be done before exchange")
}
// Step 1: Client encapsulates first request.
reqBuf := make([]byte, SizeHeader)
n, err := client.Encapsulate(reqBuf, 0, 0)
if err != nil {
t.Fatal(err)
}
if n != SizeHeader {
t.Fatalf("expected %d bytes, got %d", SizeHeader, n)
}
// Verify request frame fields.
reqFrm, err := NewFrame(reqBuf)
if err != nil {
t.Fatal(err)
}
mode, version, _ := reqFrm.Flags()
if mode != ModeClient {
t.Errorf("request mode = %d; want ModeClient (%d)", mode, ModeClient)
}
if version != Version4 {
t.Errorf("request version = %d; want %d", version, Version4)
}
if reqFrm.Stratum() != StratumUnsync {
t.Errorf("request stratum = %d; want StratumUnsync", reqFrm.Stratum())
}
// Step 2: Simulate server response.
// Server receives at clientStart + serverOffset, sends response at clientStart + serverOffset + 10ms processing.
serverRecvTime := clientStart.Add(serverOffset)
serverXmtTime := serverRecvTime.Add(10 * time.Millisecond)
respBuf := simulateServerResponse(t, reqBuf, serverRecvTime, serverXmtTime)
// Advance client clock to simulate network delay.
clockTime = clientStart.Add(100 * time.Millisecond)
// Step 3: Client demuxes first response.
err = client.Demux(respBuf, 0)
if err != nil {
t.Fatal(err)
}
if client.IsDone() {
t.Fatal("client should not be done after first exchange only")
}
if client.ServerStratum() != StratumPrimary {
t.Errorf("server stratum = %s; want primary", client.ServerStratum())
}
// Step 4: Client encapsulates second request.
req2Buf := make([]byte, SizeHeader)
clockTime = clientStart.Add(200 * time.Millisecond)
n, err = client.Encapsulate(req2Buf, 0, 0)
if err != nil {
t.Fatal(err)
}
if n != SizeHeader {
t.Fatalf("second request: expected %d bytes, got %d", SizeHeader, n)
}
// Step 5: Simulate second server response.
serverRecv2 := clientStart.Add(serverOffset + 200*time.Millisecond)
serverXmt2 := serverRecv2.Add(10 * time.Millisecond)
resp2Buf := simulateServerResponse(t, req2Buf, serverRecv2, serverXmt2)
clockTime = clientStart.Add(300 * time.Millisecond)
// Step 6: Client demuxes second response.
err = client.Demux(resp2Buf, 0)
if err != nil {
t.Fatal(err)
}
if !client.IsDone() {
t.Fatal("client should be done after second exchange")
}
// Step 7: Verify results.
if client.ServerStratum() != StratumPrimary {
t.Errorf("server stratum = %s; want primary", client.ServerStratum())
}
offset := client.Offset()
if offset == 0 {
t.Fatal("offset should be non-zero")
}
rtd := client.RoundTripDelay()
if rtd < 0 {
t.Fatalf("round trip delay should be non-negative, got %s", rtd)
}
// Verify Now() returns a reasonable time.
ntpNow := client.Now()
if ntpNow.Before(baseTime) {
t.Errorf("NTP-corrected time %v is before base time %v", ntpNow, baseTime)
}
}
func TestClient_Reset(t *testing.T) {
var c Client
c.Reset(-18, time.Now)
if c.IsDone() {
t.Fatal("should not be done after Reset")
}
if c.LocalPort() != ClientPort {
t.Fatalf("expected port %d, got %d", ClientPort, c.LocalPort())
}
if c.Protocol() != 0 {
t.Fatalf("expected protocol 0, got %d", c.Protocol())
}
id1 := *c.ConnectionID()
c.Reset(-18, time.Now)
id2 := *c.ConnectionID()
if id2 <= id1 {
t.Fatal("ConnectionID should increment on Reset")
}
}
func TestClient_Encapsulate_WhenDone(t *testing.T) {
var c Client
// Not reset, state is closed/done.
buf := make([]byte, SizeHeader)
n, err := c.Encapsulate(buf, 0, 0)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("expected 0 bytes when done, got %d", n)
}
}
func TestClient_Demux_WhenDone(t *testing.T) {
var c Client
buf := make([]byte, SizeHeader)
err := c.Demux(buf, 0)
if err != nil {
t.Fatal("Demux when done should be no-op")
}
}
func TestClient_Demux_ShortBuffer(t *testing.T) {
var c Client
c.Reset(-18, time.Now)
// Trigger encapsulate first to move to stateAwait1.
buf := make([]byte, SizeHeader)
c.Encapsulate(buf, 0, 0)
// Short buffer should error.
err := c.Demux(make([]byte, 10), 0)
if err == nil {
t.Fatal("expected error for short buffer")
}
}
func TestClient_OffsetBeforeDone(t *testing.T) {
var c Client
c.Reset(-18, time.Now)
if c.Offset() != 0 {
t.Fatal("Offset should be 0 before exchange completes")
}
if c.RoundTripDelay() != -1 {
t.Fatal("RoundTripDelay should be -1 before done")
}
}
func TestClient_SecondExchangeRejection(t *testing.T) {
baseTime := BaseTime()
clientStart := baseTime.Add(10 * time.Second)
serverOffset := 500 * time.Millisecond
clockTime := clientStart
var client Client
client.Reset(-18, func() time.Time { return clockTime })
// Complete first exchange.
reqBuf := make([]byte, SizeHeader)
client.Encapsulate(reqBuf, 0, 0)
serverRecv1 := clientStart.Add(serverOffset)
serverXmt1 := serverRecv1.Add(10 * time.Millisecond)
resp1Buf := simulateServerResponse(t, reqBuf, serverRecv1, serverXmt1)
clockTime = clientStart.Add(100 * time.Millisecond)
client.Demux(resp1Buf, 0)
// Start second exchange.
req2Buf := make([]byte, SizeHeader)
clockTime = clientStart.Add(200 * time.Millisecond)
client.Encapsulate(req2Buf, 0, 0)
// Build bogus response with wrong origin time.
bogus := make([]byte, SizeHeader)
frm, _ := NewFrame(bogus)
frm.SetFlags(ModeServer, Version4, LeapNoWarning)
frm.SetOriginTime(TimestampFromUint64(99999))
xmt, _ := TimestampFromTime(clockTime.Add(time.Second))
frm.SetTransmitTime(xmt)
frm.SetReceiveTime(xmt)
clockTime = clientStart.Add(300 * time.Millisecond)
err := client.Demux(bogus, 0)
if err == nil {
t.Fatal("second exchange should reject mismatched origin")
}
if client.IsDone() {
t.Fatal("should not be done after rejected second response")
}
}
func TestClient_DemuxRejectsBogusResponse(t *testing.T) {
var c Client
clockTime := BaseTime().Add(time.Second)
c.Reset(-18, func() time.Time { return clockTime })
// Encapsulate to move to stateAwait1.
buf := make([]byte, SizeHeader)
c.Encapsulate(buf, 0, 0)
// Build response with wrong origin time (not echoed correctly).
resp := make([]byte, SizeHeader)
frm, _ := NewFrame(resp)
frm.SetFlags(ModeServer, Version4, LeapNoWarning)
frm.SetOriginTime(TimestampFromUint64(99999)) // wrong origin
xmt, _ := TimestampFromTime(clockTime.Add(time.Second))
frm.SetTransmitTime(xmt)
frm.SetReceiveTime(xmt)
err := c.Demux(resp, 0)
if err == nil {
t.Fatal("should reject response with mismatched origin time")
}
if c.IsDone() {
t.Fatal("should not be done after rejected response")
}
}