Let’s Make an NTP Client in Go

Vladimir Vivien
Jan 4, 2018 · 4 min read

While doing some research on network programming, I came across this blog post titled Let’s Make a NTP Client in C by David Lettier (Lettier). So, that got me inspired to do something similar in Go.

The code for this writeup can be found https://github.com/vladimirvivien/go-ntp-client.

This writeup shows the construction of a (really) trivial NTP client in Go. It uses the encoding/binary package to encode and decode NTP packets sent to and received from a remote NTP server over UDP.

You can learn more about NTP here, read the specs RFC5905, and find a (seemingly) way better Go NTP client, with many features implemented, at github.com/beevik/ntp.

A word about NTP

NTP v4 data format (abbreviated) — https://tools.ietf.org/html/rfc5905

NTP Packet

type packet struct {
Settings uint8 // leap yr indicator, ver number, and mode
Stratum uint8 // stratum of local clock
Poll int8 // poll exponent
Precision int8 // precision exponent
RootDelay uint32 // root delay
RootDispersion uint32 // root dispersion
ReferenceID uint32 // reference id
RefTimeSec uint32 // reference timestamp sec
RefTimeFrac uint32 // reference timestamp fractional
OrigTimeSec uint32 // origin time secs
OrigTimeFrac uint32 // origin time fractional
RxTimeSec uint32 // receive time secs
RxTimeFrac uint32 // receive time frac
TxTimeSec uint32 // transmit time secs
TxTimeFrac uint32 // transmit time frac
}

Setup UDP Connection

conn, err := net.Dial("udp", host)
if err != nil {
log.Fatal("failed to connect:", err)
}
defer conn.Close()
if err := conn.SetDeadline(
time.Now().Add(15 * time.Second)); err != nil {
log.Fatal("failed to set deadline: ", err)
}

Request time from server

// configure request settings by specifying the first byte as
// 00 011 011 (or 0x1B)
// | | +-- client mode (3)
// | + ----- version (3)
// + -------- leap year indicator, 0 no warning
req := &packet{Settings: 0x1B}

Next we use package binary to automatically encode the struct packet fields into its corresponding byte values and send them as big endian representation.

if err := binary.Write(conn, binary.BigEndian, req); err != nil {
log.Fatalf("failed to send request: %v", err)
}

Read time from server

rsp := &packet{}
if err := binary.Read(conn, binary.BigEndian, rsp); err != nil {
log.Fatalf("failed to read server response: %v", err)
}

Parse the time

Unix time uses an epoch that started in 1970 (or number of seconds since year 1970). NTP, however uses a different epoch that counts the number of seconds since 1900. Therefore, the time value from the NTP server must be corrected to convert NTP seconds to Unix time by removing 70 yrs of seconds (1970–1900) or 2208988800 seconds.

const ntpEpochOffset = 2208988800
...
secs := float64(rsp.TxTimeSec) - ntpEpochOffset
nanos := (int64(rsp.TxTimeFrac) * 1e9) >> 32

The fractional portion of the NTP value is converted into nanoseconds. In this trivial context this is optional and is shown here for completeness.

Display the time

fmt.Printf("%v\n", time.Unix(int64(secs), nanos))

Conclusion

The NTP client is not meant to be production-ready as it is missing many features specified by the NTP spec. The majority of fields coming back from the server are ignored. You can find a more complete NTP client written in Go here.

As always, if you find this writeup useful, please let me know by clicking on the clapping hands 👏 icon to recommend this post.

Also, don’t forget to checkout my book on Go, titled Learning Go Programming from Packt Publishing.

Learning the Go Programming Language

Short and insightful posts for newcomers learning the Go…