Skip to main content

Build a NTP Timeserver Client in Go

Shenzhen, China

I have been looking into setting up a secure time server, connecting NTS clients and deploying the server using Hashicorp Nomad. Now I want to see if I can find a client written in Go that can easily be compiled for different operating systems.

Go NTP Client

This code is based on the simple ntp client by Vladimir Vivien.

The following lines shows the packet format for NTP v4. For the client we only use the first 48 bytes and ignoring the v4-specific extensions:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|LI | VN  |Mode |    Stratum     |     Poll      |  Precision   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Root Delay                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         Root Dispersion                       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                          Reference ID                         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                     Reference Timestamp (64)                  +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                      Origin Timestamp (64)                    +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                      Receive Timestamp (64)                   +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                      Transmit Timestamp (64)                  +
|                                                               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

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
}

Sending a Request

The NTP server provides its "insecure" - not NTS - service on Port 123 and expects communications using UDP. For this test - since I already "NTS + key-secured" my server - I will use a public NTP server to test the program:

// Define ntp server address
var host string
flag.StringVar(&host, "e", "0.de.pool.ntp.org:123", "NTP host")
flag.Parse()

The following function opens a socket to communicate with the public NTP server over UDP and configure the connection’s read and write deadline to 15 seconds:

// Open UDP connection
conn, err := net.Dial("udp", host)
if err != nil {
  log.Fatalf("ERROR :: Connection failed with message: %v", err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(15 * time.Second)); err != nil {
  log.Fatalf("ERROR :: Failed to set deadline: %v", err)
}

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:

// Specify the first byte of the request 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:

// Send NTP time request
if err := binary.Write(conn, binary.BigEndian, req); err != nil {
    log.Fatalf("ERROR :: Sending request failed with message: %v", err)
}

Parse the Response

The response will be identical in format to the request and we can again use the binary package to decode the response bytes from the server into the packet struct value:

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

On POSIX-compliant OS, time is expressed using the Unix time epoch (or secs since year 1970). NTP seconds are counted since 1900 and therefore must be corrected with an epoch offset to convert NTP seconds to Unix time by removing 70 yrs of seconds (1970-1900) or 2208988800 seconds:

// NTP seconds to Unix time (1900 - 1970 in seconds)
const ntpEpochOffset = 2208988800
// Parse the time and print to console
secs := float64(rsp.TxTimeSec) - ntpEpochOffset
nanos := (int64(rsp.TxTimeFrac) * 1e9) >> 32 // convert fractional to nanos
fmt.Printf("%v\n", time.Unix(int64(secs), nanos))

Run the Client

go run ./time.go
2022-10-04 18:10:13.827640375 +0800 HKT
go build ./time.go
./time -e "time.kriss.re.kr:123"
2022-10-04 18:14:59.364579986 +0800 HKT

And to make sure that the error catching is actually working here an example that should fail using the NTS port:

./time -e "time.nist.gov:4460"
2022/10/04 18:18:40 ERROR :: Failed to read server response: read udp 211.72.35.109:59692->132.163.96.6:4460: i/o timeout

A full NTP Client written in Go can be found here: beevik/ntp.

Go NTS Client

A fully featured NTP and NTS client can be found here: ntsclient. This NTS client written in Go to can query for authenticated time using SNTP with NTS extension fields. It is build on the following libraries:

  • ntske: NTS Key Exchange Go library
  • ntp: NTP/NTS Go library

Build the Client

git clone https://gitlab.com/hacklunch/ntsclient.git
cd ntsclient
go mod tidy

The repository contains a makefile we can use with the make command to build and install the client binary and configuration file. I will shorten the Makefile to the following to skip the installation part of it:

PREFIX = /usr/local

BINARY := ntsclient
SRCS := client.go version.go go.mod go.sum

$(BINARY): $(SRCS)
	go build -o $@

lint:
# see: .golangci.yml
	-golangci-lint run
	-golangci-lint run bump-version.go

bump-major:
	go run bump-version.go version.go major
bump-minor:
	go run bump-version.go version.go minor
bump-patch:
	go run bump-version.go version.go patch

Now I can run the make command and end up with a binary ntsclient. To use the file I will have to create a configuration file ntsclient.toml:

# Ask this server on this port about time
# If :port is omitted, the default NTS-KE port 4460 will be used.

# https://netnod.se
server="nts.ntp.se"

# Trust *only* this Certificate Authority certificate (PEM) to sign certficates
# for server above.
#cacert="cacert.crt"

# Interval in seconds between queries
interval=1000

Run

ntsclient does not output anything when querying and setting the time, unless something goes wrong (or debug output is turned on).

./ntsclient --config ./ntsclient.toml
Could not set system time: operation not permitted

sudo ./ntsclient --config ./ntsclient.toml

Ok, now I got it running but of course I am not seeing anything anymore. So let's switch on the debug mode:

sudo ./ntsclient --config ./ntsclient.toml --debug

Conf: &main.Config{Server:"nts.ntp.se", CACert:"", Interval:1000}
Connecting to KE server nts.ntp.se:4460
Using resolved KE server as NTP default: 194.58.207.77:123
Record type 1
Critical set
Record type 4
Critical set
Record type 6
Critical set
(got negotiated NTP server: 194.58.207.80)
Record type 7
Critical set
(got negotiated NTP port: 4123)
Record type 5
Record type 5
Record type 5
Record type 5
Record type 5
Record type 5
Record type 5
Record type 5
Record type 0
Critical set
NTSKE exchange yielded:
  c2s: 7185b6d9658ae862f9f80027a4b4f56702fdcca16bd77064daf9fc12428cacb5
  s2c: 3ca3feda3ce5cd55129bf21c51d576476284dbfe3910ca95e1966aff8fd79d49
  server: 194.58.207.80
  port: 4123
  algo: 15
  8 cookies:
  #1: 1d1f7bdc87a2c92ada1416006314cba9106a3b8a0957b404e8b3b9299cf0892eccb910fa8c071429a72e77c1a850ea018a17b1272101cbb45a32b90293788b1ec2aa5e013e3c5e15f5cdcb4bd1d30ac84780f8765b56fa419d085a36d66efe04adf4f67e
  #2: 1d1f7bdcb1a894e3fa5dfc00c63b3c9c14ed4761716900feeec48bcadc3591ea8addfe9dac46a630f5dedc513c741f977f5f7c3d62858b28158650323eea3c9a678627da31fff0e1ecc83fc0b8d632a87988ab60b690fe38ebb2e864bcddd3ad541dab00
  #3: 1d1f7bdc5d1741b60c1b355843c82a48533032adb99f67577366c9fe6172b946dc11863db7d12b7e4d52368944ee88f2ebc25086fccbc446d6ffbe75546aac65f4a56ecfde4852f77247178a0f0063bdd228205bd30fe35c32d4ad1b4e8e9f8704c726e7
  #4: 1d1f7bdc764e5dd5423656aaff1c69147a15cb9105484a1549183efefb27ce6890448f803ae4a558bfc93da2ee811362f67f7516e8dc01a4a56c28caccaedab2c861995b30b1b0000e125c2de251232b177af1f3ee2fe1b87e56c3d6ec8769c4ba1d3010
  #5: 1d1f7bdc06c0d51832ceb31a23fc3dcacf352cd0f8532a43967089678c48eb4ffc6142e57c66af26c2c730a709a7ebb7b25742c07775e0747cb690ffa67271b478fb7773262f786db97ee1e34d02b9d9f8146fa91bd6945804d4cf00d3dcd3da30efcb03
  #6: 1d1f7bdca7cde8901057519e738be42e6f35fd8cf6457069a79c474ce6c3ca00641cdc2a5b29daff557337b2654b0aa89c0999ff820e0a63bdae90c68164acbdac649263d6b193c2bb2bc974a7fa1b4f8e0bab4c7e7592e397945d996e741a9e7d58546d
  #7: 1d1f7bdc6ad2682416f848678e130931b67368209d929b92120e78c020ccda671ad7e33c3d826f5d33d67f9ae1f5de64ddd0498c2df4f5b45a186b91d8da2549c3e06ca35820b6227bbacfd5356fac996f11f44830f57d3788e23bcebdf3386f291fccd7
  #8: 1d1f7bdc897ee754dcf978b180253e47c1a77055a1f149fbd61131187db0f03a3a45b35424d9a209902b254b212270fa325322ef2adb61de0f1fae5863e2312fa8fad64bedda1d4cc0deaa470eec485acf1bddf6e65788b407188b12a870a6cce274ef2a
Sending: 
Version: 4
Mode: 3
LeapIndicator: 3
Stratum: 0
Poll: 0
Precision: 0
RootDelay: 0s
RootDispersion: 0s
ReferenceID: 0
ReferenceTime: 0001-01-01 00:00:00 +0000 UTC
OriginTime: 0001-01-01 00:00:00 +0000 UTC
ReceiveTime: 0001-01-01 00:00:00 +0000 UTC
TransmitTime: 0001-01-01 00:00:00 +0000 UTC
SpoofCookie: 2249980270020620447

-- UniqueIdentifier EF
  ID: c4c7e91b9ca840a0b7e4e41c22bd143e2dfeb2768f9875e748a2a512613ac81c

-- Cookie EF
  1d1f7bdc87a2c92ada1416006314cba9106a3b8a0957b404e8b3b9299cf0892eccb910fa8c071429a72e77c1a850ea018a17b1272101cbb45a32b90293788b1ec2aa5e013e3c5e15f5cdcb4bd1d30ac84780f8765b56fa419d085a36d66efe04adf4f67e

-- Authenticator EF
  NonceLen: 0
  CipherTextLen: 0
  Nonce: 
  Ciphertext: 
  Key: 7185b6d9658ae862f9f80027a4b4f56702fdcca16bd77064daf9fc12428cacb5

wire: e300000000000000000000000000000025c17d04dad2965d25c17d04dad2965d25c17d04dad2965d1f3989227a37709f01040024c4c7e91b9ca840a0b7e4e41c22bd143e2dfeb2768f9875e748a2a512613ac81c020400681d1f7bdc87a2c92ada1416006314cba9106a3b8a0957b404e8b3b9299cf0892eccb910fa8c071429a72e77c1a850ea018a17b1272101cbb45a32b90293788b1ec2aa5e013e3c5e15f5cdcb4bd1d30ac84780f8765b56fa419d085a36d66efe04adf4f67e0404002800100010190d1ba7a999908f80b79652288ee53445c8bc790d21bc2c31454e823a659f9e
Received: 
Version: 4
Mode: 4
LeapIndicator: 0
Stratum: 1
Poll: 0
Precision: -24
RootDelay: 0s
RootDispersion: 0s
ReferenceID: 1347441408
ReferenceTime: 2022-10-04 11:39:51 +0000 UTC
OriginTime: 0001-01-01 00:00:00 +0000 UTC
ReceiveTime: 2022-10-04 11:39:52.23693832 +0000 UTC
TransmitTime: 2022-10-04 11:39:52.236942742 +0000 UTC
SpoofCookie: 2249980270020620447

-- UniqueIdentifier EF
  ID: c4c7e91b9ca840a0b7e4e41c22bd143e2dfeb2768f9875e748a2a512613ac81c

-- Authenticator EF
  NonceLen: 16
  CipherTextLen: 120
  Nonce: 9d9ebcc66e8a825179b83334f20c1513
  Ciphertext: 8d60b8bf3f5a4a15ec1ea0e9fa01e3abaa75c34c6735a69da940158970f1798bbc9836467d829469e4930a82fdbfd6898147192f4aad60e61d54d3b518ef1792df3cba2f610b876200cc94e7c5472c015a0fe94081327de006f3c607a0c19becc756044317db5fb94d3c60d9f408ef7c234d834de1429827
  Key: 

Received wire: {36 1 0 -24 0 0 1347441408 16638155228222324736 2249980270020620447 16638155233534934369 16638155233534953361}
response: &ntp.Response{Time:time.Date(2022, time.October, 4, 11, 39, 52, 236942742, time.UTC), ClockOffset:12618768, RTT:339863256, Precision:59, Stratum:0x1, ReferenceID:0x50505300, ReferenceTime:time.Date(2022, time.October, 4, 11, 39, 51, 0, time.UTC), RootDelay:0, RootDispersion:0, RootDistance:169931628, Leap:0x0, MinError:0, KissCode:"", Poll:1000000000}
Network time on 194.58.207.80:4123 2022-10-04 11:39:52.236942742 +0000 UTC. Local clock off by 12.618768ms.

This seems to be working - I can verify it by manually setting my PC time. Starting the ntsclient will correct the mistake immediately. Nice!

Running as a Service with SystemD

Installing ntsclient as a systemd service manually:

cp ntsclient /usr/bin/
cp ntsclient.toml /etc/
cp contrib/ntsclient.service /etc/systemd/system
systemctl enable ntsclient
systemctl start ntsclient