Files
lneto/ntp/ntp.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

317 lines
10 KiB
Go

// package ntp implements the NTP protocol as described in RFC 5905.
package ntp
import (
"encoding/binary"
"math"
"math/bits"
"time"
"github.com/soypat/lneto"
)
// NTP Global Parameters.
const (
SizeHeader = 48
ClientPort = 1023 // Typical Client port number.
ServerPort = 123 // NTP server port number
Version4 = 4 // Current NTP Version Number
MinPoll = 4 // Minimum poll exponent (16s)
MaxPoll = 17 // Maximum poll exponent (~36h)
MaxDisp = 16 // Maximum dispersion (16s)
MaxDist = 1 // Distance threshold (1s)
MaxStratum = 16 // Maximum stratum
MinDispDiv = 200 // Minimum dispersion divisor 1/(200) == 0.005
)
func NewFrame(buf []byte) (Frame, error) {
if len(buf) < SizeHeader {
return Frame{buf: nil}, lneto.ErrTruncatedFrame
}
return Frame{buf: buf}, nil
}
// Frame encapsulates the raw data of an NTP packet
// and provides methods for manipulating, validating and
// retrieving fields and payload data. See [RFC5905].
//
// [RFC5905]: https://tools.ietf.org/html/rfc5905
type Frame struct {
buf []byte
}
func (frm Frame) Flags() (mode Mode, version uint8, lp LeapIndicator) {
b := frm.buf[0]
mode = Mode(b & 0b111)
version = (b >> 3) & 0b111
lp = LeapIndicator(b >> 6)
return mode, version, lp
}
func (frm Frame) SetFlags(mode Mode, version uint8, lp LeapIndicator) {
b := uint8(mode)&0b111 | (version&0b111)<<3 | uint8(lp&0b11)<<6
frm.buf[0] = b
}
func (frm Frame) Stratum() Stratum { return Stratum(frm.buf[1]) }
func (frm Frame) SetStratum(stratum Stratum) { frm.buf[1] = byte(stratum) }
// Poll is 8-bit signed integer representing the maximum interval between
// successive messages, in log2 seconds. Suggested default limits for
// minimum and maximum poll intervals are 6 and 10, respectively.
func (frm Frame) Poll() int8 { return int8(frm.buf[2]) }
func (frm Frame) SetPoll(Poll int8) { frm.buf[2] = uint8(Poll) }
// Precision is 8-bit signed integer representing the precision of the
// system clock, in log2 seconds. For instance, a value of -18
// corresponds to a precision of about one microsecond. The precision
// can be determined when the service first starts up as the minimum
// time of several iterations to read the system clock.
func (frm Frame) Precision() int8 { return int8(frm.buf[3]) }
func (frm Frame) SetPrecision(Precision int8) { frm.buf[3] = uint8(Precision) }
// Total round-trip delay to the reference clock, in NTP short format.
func (frm Frame) RootDelay() Short {
return Short(binary.BigEndian.Uint32(frm.buf[4:8]))
}
func (frm Frame) SetRootDelay(rd Short) {
binary.BigEndian.PutUint32(frm.buf[4:8], uint32(rd))
}
// Total dispersion to the reference clock, in NTP short format.
func (frm Frame) RootDispersion() Short {
return Short(binary.BigEndian.Uint32(frm.buf[8:12]))
}
func (frm Frame) SetRootDispersion(rd Short) {
binary.BigEndian.PutUint32(frm.buf[8:12], uint32(rd))
}
// 32-bit code identifying the particular server or reference clock.
// The interpretation depends on the value in the stratum field.
// For packet stratum 0 (unspecified or invalid), this is a four-character
// ASCII [RFC1345] string, called the "kiss code", used for debugging and monitoring purposes.
// For stratum 1 (reference clock), this is a four-octet, left-justified,
// zero-padded ASCII string assigned to the reference clock.
// The authoritative list of Reference Identifiers is maintained by IANA; however, any string
// beginning with the ASCII character "X" is reserved for unregistered
// experimentation and development.
func (frm Frame) ReferenceID() *[4]byte {
return (*[4]byte)(frm.buf[12:16])
}
// ReferenceTime is when the system clock was last set or corrected, in NTP timestamp format.
func (frm Frame) ReferenceTime() Timestamp {
return TimestampFromUint64(binary.BigEndian.Uint64(frm.buf[16:24]))
}
func (frm Frame) SetReferenceTime(rt Timestamp) {
rt.Put(frm.buf[16:24])
}
// OriginTime is time at the client when the request departed for the server, in NTP timestamp format.
func (frm Frame) OriginTime() Timestamp {
return TimestampFromUint64(binary.BigEndian.Uint64(frm.buf[24:32]))
}
func (frm Frame) SetOriginTime(ot Timestamp) {
ot.Put(frm.buf[24:32])
}
// ReceiveTime time at the server when the request arrived from the client, in NTP timestamp format.
func (frm Frame) ReceiveTime() Timestamp {
return TimestampFromUint64(binary.BigEndian.Uint64(frm.buf[32:40]))
}
func (frm Frame) SetReceiveTime(rt Timestamp) {
rt.Put(frm.buf[32:40])
}
// TransmitTime at the server when the response left for the client, in NTP timestamp format.
func (frm Frame) TransmitTime() Timestamp {
return TimestampFromUint64(binary.BigEndian.Uint64(frm.buf[40:48]))
}
func (frm Frame) SetTransmitTime(rt Timestamp) {
rt.Put(frm.buf[40:48])
}
// RawData returns the underlying byte slice for the entire NTP packet.
func (frm Frame) RawData() []byte { return frm.buf }
// ExtensionFields returns the extension fields area of the NTP packet (all
// bytes following the fixed 48-byte NTP header). The RFC calls these
// "extension fields" (RFC 7822 §2).
func (frm Frame) ExtensionFields() []byte {
return frm.buf[SizeHeader:]
}
// ValidateSize checks that the NTP header is complete and that any extension
// fields are well-formed with valid lengths.
func (frm Frame) ValidateSize(v *lneto.Validator) {
if len(frm.buf) < SizeHeader {
v.AddError(lneto.ErrTruncatedFrame)
return
}
buf := frm.ExtensionFields()
for len(buf) > 0 {
_, n, err := NextExtField(buf)
if err != nil {
v.AddError(err)
return
}
buf = buf[n:]
}
}
// ClearHeader zeros out the header contents.
func (frm Frame) ClearHeader() {
for i := range frm.buf[:SizeHeader] {
frm.buf[i] = 0
}
}
type Short uint32
var baseTime = time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
// BaseTime returnsS the time that corresponds to the NTP base time.
// The zero value for [Timestamp] and [Date] types corresponds to this time.
func BaseTime() time.Time {
return baseTime
}
// In the date and timestamp formats, the prime epoch, or base date of
// era 0, is 0 h 1 January 1900 UTC, when all bits are zero. It should
// be noted that strictly speaking, UTC did not exist prior to 1 January
// 1972, but it is convenient to assume it has existed for all eternity,
// even if all knowledge of historic leap seconds has been lost. Dates
// are relative to the prime epoch; values greater than zero represent
// times after that date; values less than zero represent times before
// it. Note that the Era Offset field of the date format and the
// Seconds field of the timestamp format have the same interpretation.
// Timestamp format is used in packet headers and other
// places with limited word size. It includes a 32-bit unsigned seconds
// field spanning 136 years and a 32-bit fraction field resolving 232
// picoseconds. The 32-bit short format is used in delay and dispersion
// header fields where the full resolution and range of the other
// formats are not justified. It includes a 16-bit unsigned seconds
// field and a 16-bit fraction field.
type Timestamp struct {
sec uint32
fra uint32
}
func (t Timestamp) Put(b []byte) {
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
binary.BigEndian.PutUint32(b[:4], t.sec)
binary.BigEndian.PutUint32(b[4:], t.fra)
}
// IsZero reports whether t represents the zero time instant.
func (t Timestamp) IsZero() bool { return t.sec == 0 && t.fra == 0 }
func TimestampFromUint64(ts uint64) Timestamp {
return Timestamp{
sec: uint32(ts >> 32),
fra: uint32(ts),
}
}
func TimestampFromTime(t time.Time) (Timestamp, error) {
t = t.UTC()
if t.Before(baseTime) {
return Timestamp{}, lneto.ErrUnsupported
}
off := t.Sub(baseTime)
sec := uint64(off / time.Second)
if sec > math.MaxUint32 {
return Timestamp{}, lneto.ErrUnsupported
}
fra := uint64(off%time.Second) * math.MaxUint32 / uint64(time.Second)
return Timestamp{
sec: uint32(sec),
fra: uint32(fra),
}, nil
}
// The 128-bit date format is used where sufficient storage and word
// size are available. It includes a 64-bit signed seconds field
// spanning 584 billion years and a 64-bit fraction field resolving .05
// attosecond (i.e., 0.5e-18).
type Date struct {
sec int64
frac uint64
}
func (t Timestamp) Seconds() uint32 { return t.sec }
func (t Timestamp) Fractions() uint32 { return t.fra }
// Uint64 returns the full 64-bit NTP timestamp with seconds in the upper 32
// bits and fractions in the lower 32 bits. Suitable for logging and encoding.
func (t Timestamp) Uint64() uint64 { return uint64(t.sec)<<32 | uint64(t.fra) }
func (t Short) Seconds() uint16 { return uint16(t >> 16) }
func (t Short) Fractions() uint16 { return uint16(t) }
func (t Timestamp) Time() time.Time {
off := time.Second*time.Duration(t.Seconds()) + time.Second*time.Duration(t.Fractions())/math.MaxUint32
return baseTime.Add(off)
}
func (t Timestamp) Sub(v Timestamp) time.Duration {
dsec := time.Duration(t.sec) - time.Duration(v.sec)
dfra := time.Duration(t.fra) - time.Duration(v.fra)
// Work in uint64 to avoid overflow since fra is possibly MaxUint32-1
// which means the result of dfra*MaxUint32 would be MaxUint64-MaxUint32, overflowing time.Duration's
// underlying int64 representation by *a lot*.
dfraneg := dfra < 0
dfra = time.Duration(uint64(dfra.Abs()) * uint64(time.Second) / math.MaxUint32)
if dfraneg {
dfra = -dfra
}
return dsec*time.Second + dfra
}
func (t Timestamp) Add(d time.Duration) Timestamp {
add := uint32(uint64(d%time.Second) * math.MaxUint32 / uint64(time.Second))
add, carry := bits.Add32(t.fra, add, 0)
t.sec += uint32(d/time.Second) + carry
t.fra = add
return t
}
func (d Date) Time() (time.Time, error) {
sec := d.sec
neg := sec < 0
if neg {
sec = -sec
}
hi, seclo := bits.Mul64(uint64(sec), uint64(time.Second))
if hi != 0 || seclo > math.MaxInt64-uint64(time.Second)-1 {
return time.Time{}, lneto.ErrUnsupported
}
off := time.Duration(seclo)
off += time.Second * time.Duration(d.frac>>32) / math.MaxUint32
if neg {
off = -off
}
return baseTime.Add(off), nil
}
// CalculateSystemPrecision calculates the NTP system precision for a time source.
// If the time source is nil the default static call to [time.Now]->[time.Time.UnixNano] is used.
func CalculateSystemPrecision(nowNano func() int64, iters []int64) int8 {
maxIter := len(iters)
if nowNano == nil {
for i := range maxIter {
iters[i] = time.Now().UnixNano()
}
} else {
for i := range maxIter {
iters[i] = nowNano()
}
}
const seconds = 1_000_000_000 // nanoseconds
avg := (iters[maxIter-1] - iters[0]) / int64(maxIter)
avgSeconds := float64(avg) / seconds
return int8(math.Log2(avgSeconds))
}