Let’s Make an NTP Client in Go

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

The concepts behind network time synchronization is complex and are beyond me and this writeup. Luckily however, the data packet format used for NTP is simple and meant to be consumed by clients small and large. The following figure shows the packet format for NTP v4. For this writeup, we are only interested in the first 48 bytes and ignoring the v4-specific extensions.

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

NTP Packet

Both a client request and its associated server response use the same packet format above. The structure below defines the NTP packet and its fields representing the format above.

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

Next, use the net.Dial function to setup our socket to communicate with NTP server over UDP and configure the connection’s read and write deadline to 15 seconds.

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

Before sending the request packet to the server, the first byte is used to specify configuration settings with a value of 0x1B (or 00011011 binary) which specifies client mode of 3, NTP version 3, leap year indicator of 0 as shown below:

// 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

Next we can use the binary package again to stream the response bytes from the server and automatically decode the incoming them into the packet struct value.

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

Parse the time

For this super trivial example, we are only interested in field Transmit Time (rsp.TxTimeSec and rspTxTimeFrac) which is the time since the packet left the server. But, we can’t use these values as they are, they must be converted to Unix 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

Lastly function time.Unix is used to create a time value using the seconds value secs and the fractional value nanos. Then the time is printed to the console.

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

Conclusion

This writeup shows a very trivial example of an NTP client. It shows how to use the package encoding/binary to easily encode a struct of numeric fields into their byte representation. Conversely, we used the binary package to decode a stream of bytes into numeric types stored in a struct.

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.