Skip to main content

Secure Timeserver - NTP with NTS with Docker

TST, Hong Kong

Running an NTP Server in Docker

The docker image cturra/ntp is build on Alpine Linux and uses Chrony. Chrony is a versatile implementation of the Network Time Protocol (NTP). It can synchronise the system clock with NTP servers. It can also operate as an NTPv4 (RFC 5905) server and peer to provide a time service to other computers in the network.

We can run the container using Docker-Compose. Create the docker-compose.yml file:

cd /opt
git clone https://github.com/cturra/docker-ntp.git && cd docker-ntp
cat ./docker-compose.yml
version: '3.9'

services:
ntp:
build: .
image: cturra/ntp:latest
container_name: ntp
restart: always
ports:
- 123:123/udp
environment:
- NTP_SERVERS=time.cloudflare.com
- LOG_LEVEL=0
docker compose up -d ntp
docker compose logs ntp

Configuration

To configure more than one server, you must use a comma delimited list WITHOUT spaces:

# (default) cloudflare
NTP_SERVERS="time.cloudflare.com"

# google
NTP_SERVERS="time1.google.com,time2.google.com,time3.google.com,time4.google.com"

# alibaba
NTP_SERVERS="ntp1.aliyun.com,ntp2.aliyun.com,ntp3.aliyun.com,ntp4.aliyun.com"

# local (offline)
NTP_SERVER="127.127.1.1"

Logging

By default, this project logs informational messages to stdout. The LOG_LEVEL option matches the chrony -L option, which support the following levels can to specified: 0 (informational), 1 (warning), 2 (non-fatal error), and 3 (fatal error).

docker logs -f ntp 

2022-09-20T11:03:54Z chronyd version 4.1 starting (+CMDMON +NTP +REFCLOCK +RTC +PRIVDROP -SCFILTER +SIGND +ASYNCDNS +NTS +SECHASH +IPV6 -DEBUG)
2022-09-20T11:03:54Z Disabled control of system clock
2022-09-20T11:03:54Z Could not read valid frequency and skew from driftfile /var/lib/chrony/chrony.drift
2022-09-20T11:03:58Z Selected source 162.159.200.1 (time.cloudflare.com)

Testing

apt install sntp

sntp my.server.domain
sntp 4.2.8p15@1.3728-o Wed Sep 23 11:46:38 UTC 2020 (1)
2022-09-21 09:55:24.522074 (+0000) -0.002317 +/- 0.005741 my.server.domain my.server.domain s2 no-leap

ERROR message no server suitable for synchronization found - wait a while for your service to contact your selected upstream time server.

To see details on the ntp status of your container, you can check with the command below on your docker host:

docker exec ntp chronyc tracking

Reference ID : A29FC801 (time.cloudflare.com)
Stratum : 4
Ref time (UTC) : Tue Sep 20 11:13:43 2022
System time : 0.002932649 seconds fast of NTP time
Last offset : +0.000029569 seconds
RMS offset : 0.001531294 seconds
Frequency : 0.329 ppm slow
Residual freq : +0.009 ppm
Skew : 0.553 ppm
Root delay : 0.031447832 seconds
Root dispersion : 0.000492193 seconds
Update interval : 64.8 seconds
Leap status : Normal

Here is how you can see a peer list to verify the state of each ntp source configured:

docker exec ntp chronyc sources

MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^* time.cloudflare.com 3 7 377 34 +18us[ +24us] +/- 16ms

The Reach column should have a non-zero value; ideally 377. The value 377 shown above is an octal number. It indicates that the last eight requests all had a valid response. To see statistics about the collected measurements of each ntp source configured:

docker exec ntp chronyc sourcestats

Name/IP Address NP NR Span Frequency Freq Skew Offset Std Dev
==============================================================================
time.cloudflare.com 20 10 1106 +0.001 0.195 +121ns 78us

Enable NTS on the server

If you have your own NTP server running chronyd, you can enable server NTS support to allow its clients to be synchronized securely. The Chrony configuration file is located in /etc/chrony/chrony.conf inside the Docker container:

docker exec -ti ntp cat /etc/chrony/chrony.conf

But it is being generated by a shell script:

# https://github.com/cturra/docker-ntp

# chrony.conf file generated by startup script
# located at /opt/startup.sh

# time servers provided by NTP_SERVER environment variables.
server time.cloudflare.com iburst

driftfile /var/lib/chrony/chrony.drift
makestep 0.1 3
rtcsync

allow all
docker exec -ti ntp cat /opt/startup.sh
## dynamically populate chrony config file.
{
echo "# https://github.com/cturra/docker-ntp"
echo
echo "# chrony.conf file generated by startup script"
echo "# located at /opt/startup.sh"
echo
echo "# time servers provided by NTP_SERVER environment variables."
} > ${CHRONY_CONF_FILE}

...

# final bits for the config file
{
echo
echo "driftfile /var/lib/chrony/chrony.drift"
echo "makestep 0.1 3"
echo "rtcsync"
echo
echo "allow all"
} >> ${CHRONY_CONF_FILE}

You just need a private key and certificate. we need to add the following parameter to use encryption:

ntsThis option enables authentication using the Network Time Security (NTS) mechanism. Unlike with the key option, the server and client do not need to share a key in a key file. NTS has a Key Establishment (NTS-KE) protocol using the Transport Layer Security (TLS) protocol to get the keys and cookies required by NTS for authentication of NTP packets.
certset IDThis option specifies which set of trusted certificates should be used to verify the server’s certificate when the nts option is enabled. Sets of certificates can be specified with the ntstrustedcerts directive. The default set is 0, which by default contains certificates of the system’s default trusted certificate authorities.
ntsserverkey /etc/letsencrypt/live/my.server.domain/privkey.pem
ntsservercert /etc/letsencrypt/live/my.server.domain/fullchain.pem

Make sure the ntsdumpdir directive is present in chrony.conf. It allows the server to save its keys to disk, so the clients of the server don’t have to get new keys and cookies when the server is restarted.

ntsdumpdir /var/lib/chrony
# final bits for the config file
{
echo
echo "driftfile /var/lib/chrony/chrony.drift"
echo "makestep 0.1 3"
echo "rtcsync"
echo
echo "ntsserverkey /opt/privkey.pem"
echo "ntsservercert /opt/fullchain.pem"
echo "ntsprocesses 3"
echo "maxntsconnections 512"
echo "ntsdumpdir /var/lib/chrony"
echo
echo "allow all"
} >> ${CHRONY_CONF_FILE}

If the server has a firewall, it needs to allow both the UDP 123 and TCP 4460 ports for NTP and NTS-KE respectively.

Preparing the Certificate

Certbot

apt install certbot python3-certbot-nginx
certbot certonly --standalone

Once the certificates are created we have to link them in using the Docker Compose file - Note I am also binding the startup script I just edited. This will over-write the original one inside the docker container.

version: '3.9'

services:
chrony:
build: .
image: cturra/ntp:latest
container_name: chrony
restart: unless-stopped
volumes:
- type: bind
source: /opt/docker-ntp/assets/startup.sh
target: /opt/startup.sh
- type: bind
source: /etc/letsencrypt/live/my.server.domain/fullchain.pem
target: /opt/fullchain.pem
- type: bind
source: /etc/letsencrypt/live/my.server.domain/privkey.pem
target: /opt/privkey.pem
ports:
- 123:123/udp
- 4460:4460/tcp
environment:
- NTP_SERVERS=0.de.pool.ntp.org,time.cloudflare.com,time1.google.com
- LOG_LEVEL=0
docker compose up -d chrony

ERROR message after restarting the docker container I was unable to connect to using the TLS port. This is because the container was unable to read the certificate files:

docker logs -f chrony
2022-09-21T10:19:47Z chronyd version 4.1 starting (+CMDMON +NTP +REFCLOCK +RTC +PRIVDROP -SCFILTER +SIGND +ASYNCDNS +NTS +SECHASH +IPV6 -DEBUG)
2022-09-21T10:19:47Z Disabled control of system clock
2022-09-21T10:19:47Z Could not read valid frequency and skew from driftfile /var/lib/chrony/chrony.drift
2022-09-21T10:19:47Z Could not set credentials : Error while reading file.
2022-09-21T10:19:47Z Could not set credentials : Error while reading file.
2022-09-21T10:19:47Z Could not set credentials : Error while reading file.
2022-09-21T10:19:52Z Selected source 216.239.35.0 (time1.google.com)

I had to adjust the permissions to match the ones that were given by default to the startup script:

-rw-r--r--    1 root     root          5595 Sep 21 08:37 fullchain.pem
-rw------- 1 root root 1704 Sep 21 08:37 privkey.pem
-rwxr-xr-x 1 root root 2277 Sep 21 08:48 startup.sh

This time the container starts without error messages and I am able to query the NTS service without an TLS error message:

docker exec -ti chrony chronyd -Q -t 3 'server my.server.domain iburst nts maxsamples 1'

2022-09-21T10:29:37Z chronyd version 4.1 starting (+CMDMON +NTP +REFCLOCK +RTC +PRIVDROP -SCFILTER +SIGND +ASYNCDNS +NTS +SECHASH +IPV6 -DEBUG)
2022-09-21T10:29:37Z Disabled control of system clock
2022-09-21T10:29:40Z chronyd exiting

Checking the server stats tells me that he accepted the NTS connection and received an authenticated NTP package - looks alright?

docker exec -ti chrony chronyc serverstats

NTP packets received : 1
NTP packets dropped : 0
Command packets received : 4
Command packets dropped : 0
Client log records dropped : 0
NTS-KE connections accepted: 1
NTS-KE connections dropped : 0
Authenticated NTP packets : 1

Public NTS-capable servers

Location/CountryServersNotes
Globaltime.cloudflare.comAnycast
Brasila…d.st1.ntp.br
Canadatime.0xt.caTanner Ryan
Germanyptbtime1…4.ptb.de
Germanynts.ntstime.dePatrick Jansen
Germanywww.jabber-germany.de www.masters-of-cloud.deJörg Morbitzer
Germanyntp0.fau.de, ntp0.ipv6.fau.de, ntp3.fau.de, ntp3.ipv6.fau.de≤3 clients per user/org; DCF77
Netherlandsntppool1…2.time.nl
Singaporentpmon.dcs1.bizSanjeev Gupta
Swedennts.netnod.seAnycast
Swedensth1…2.nts.netnod.seSTH area use only
Switzerland(Zurich)ntp.3eck.net
Switzerland (Winterthurntp.trifence.ch ntp.zeitgitter.netMarcel Waldvogel
Switzerland (Ticino)time.signorini.chAttilio Signorini (Dynamic, Chrony-only)
USA{virginia,ohio,oregon}.time.system76.comMike Cifelli