Skip to main content

Hashicorp Nomad Secure & Balanced NTS Time Service

Shen Zhen, China

Building the Timeserver

I am using a slightly modified version of the docker-ntp by @cturra repository to build my Chrony Docker image. When running the container with Nomad the service can be configured using environment variables:

env {
    NTP_SERVERS = "0.de.pool.ntp.org,time.cloudflare.com,time1.google.com"
    LOG_LEVEL = "1"
}

Changes made to the repository - adding TLS certificates to be able to use the NTS Key Exchange for a secured time service:

Original Start-up Script

# 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}

Changed to:

# 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/letsencrypt/live/my.domain.com/privkey.pem"
  echo "ntsservercert /opt/letsencrypt/live/my.domain.com/fullchain.pem"
  echo "ntsprocesses 3"
  echo "maxntsconnections 512"
  echo "ntsdumpdir /var/lib/chrony"
  echo
  echo "allow all"
} >> ${CHRONY_CONF_FILE}

These certificates need to be generated on the host system, e.g. using certbot and then mounted into the container on runtime:

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

Docker-Compose

To test the container we can use docker compose up -d chrony:

version: '3.9'

services:
  chrony:
    build: .
    image: chrony/nts:latest
    container_name: chrony
    restart: unless-stopped
    volumes:
      - type: bind
        source: /etc/letsencrypt/live/my.domain.com/fullchain.pem
        target: /opt/letsencrypt/live/my.domain.com/fullchain.pem
      - type: bind
        source: /etc/letsencrypt/live/my.domain.com/privkey.pem
        target: /opt/letsencrypt/live/my.domain.com/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=1

Nomad Job

In Nomad we first need to create the volume on our host and then define it here:

volume "letsencrypt" {
    type      = "host"
    read_only = false
    source    = "letsencrypt"
}

It then can be mounted into the container:

volume_mount {
    volume      = "letsencrypt"
    destination = "/opt/letsencrypt"
    read_only   = false
}

Complete Job File

job "chrony_nts_server" {
    datacenters = ["dc1"]
    type = "service"

    group "docker" {
        count = 2

        network {
            port "ntp" {
                to = "123"
            }
            port "nts" {
                to = "4460"
            }
        }

        update {
            max_parallel = 1
            min_healthy_time = "10s"
            healthy_deadline = "60s"
            progress_deadline = "2m"
            auto_revert = true
            auto_promote = true
            canary = 1
        }

        service {
            name = "chrony-ntp"
            port = "ntp"
        }

        service {
            name = "chrony-nts"
            port = "nts"

            check {
                name = "NTS Service"
                port = "nts"
                type = "tcp"
                interval = "30s"
                timeout = "1s"
            }
        }

        volume "letsencrypt" {
            type      = "host"
            read_only = false
            source    = "letsencrypt"
        }

        task "chrony-container" {
            driver = "docker"
            volume_mount {
                volume      = "letsencrypt"
                destination = "/opt/letsencrypt"
                read_only   = false
           }

            env {
                NTP_SERVERS = "0.de.pool.ntp.org,time.cloudflare.com,time1.google.com"
                LOG_LEVEL = "1"
            }

            config {
                image = "my.gitlab.com:12345/server_management/chrony-nts:latest"
                ports = ["ntp", "nts"]
                network_mode = "default"
                force_pull = true

                auth {
                    username = "myuser"
                    password = "mypassword"
                }
            }
        }
    }
}

Load-Balancing

Now I want to be able to balance a pool of Chrony servers behind an NGINX proxy:

Related:

job "chrony_ingress" {
  datacenters = ["dc1"]

  group "nginx" {
    count = 1

    network {
      mode = "host"
      port "http" {
          static = "80"
      }
      port "https" {
          static = "443"
      }
      port "ntp" {
          static = "123"
      }
      port "nts" {
          static = "4460"
      }
    }

    service {
        name = "chrony-ingress-http"
        port = "http"

        check {
            name     = "HTTP Health"
            port     = "http"
            path     = "/"
            type     = "http"
            protocol = "http"
            interval = "10s"
            timeout  = "2s"
        }
    }

    service {
        name = "chrony-ingress-https"
        port = "https"
    }

    service {
        name = "chrony-ingress-ntp"
        port = "ntp"
    }

    service {
        name = "chrony-ingress-nts"
        port = "nts"
    }

    volume "letsencrypt" {
        type      = "host"
        read_only = true
        source    = "letsencrypt"
    }

    task "ingress-container" {
      driver = "docker"

      volume_mount {
            volume      = "letsencrypt"
            destination = "/opt/letsencrypt" #in the container
            read_only   = false
      }

      config {
        network_mode = "host"
        image = "nginx:alpine"
        ports = ["http","https", "ntp", "nts"]
        volumes = [
          "local/nginx/nginx.conf:/etc/nginx/nginx.conf",
          "local/nginx/dhparam.pem:/etc/nginx/ssl/dhparam.pem",
          "local/nginx/ssl-params.conf:/etc/nginx/ssl/ssl-params.conf",
          "local/nginx/default.conf:/etc/nginx/conf.d/default.conf",
          "local/nginx/stream.conf:/etc/nginx/conf.d/stream.conf",
          "local/nginx/buffers.conf:/etc/nginx/conf.d/buffers.conf",
          "local/nginx/timeouts.conf:/etc/nginx/conf.d/timeouts.conf",
          "local/nginx/header.conf:/etc/nginx/conf.d/header.conf",
          "local/nginx/cache.conf:/etc/nginx/conf.d/cache.conf",
          "local/nginx/gzip.conf:/etc/nginx/conf.d/gzip.conf",
          #  Generate/serve HTML that can be used to monitor the certificate (e.g. Zabbix)
          "local/nginx/index.html:/usr/share/nginx/html/index.html"
        ]
      }


      # nginx.conf
      template {
        data = <<EOH
user  nginx;
worker_processes  auto;
worker_rlimit_nofile  15000;
pid  /var/run/nginx.pid;
include /usr/share/nginx/modules/*.conf;


events {
    worker_connections  2048;
    multi_accept on;
    use epoll;
}

stream {
    include /etc/nginx/conf.d/stream.conf;
}


http {
    default_type   application/octet-stream;
    # access_log   /var/log/nginx/access.log;
    # activate the server access log only when needed
    access_log     off;
    error_log      /var/log/nginx/error.log;
    # don't display server version on error pages
    server_tokens  off;
    server_names_hash_bucket_size 64;
    include        /etc/nginx/mime.types;
    sendfile       on;
    tcp_nopush     on;
    tcp_nodelay    on;

    charset utf-8;
    source_charset utf-8;
    charset_types text/xml text/plain text/vnd.wap.wml application/javascript application/rss+xml;
    
    include /etc/nginx/conf.d/default.conf;
    include /etc/nginx/conf.d/buffers.conf;
    include /etc/nginx/conf.d/timeouts.conf;
    include /etc/nginx/conf.d/cache.conf;
    include /etc/nginx/conf.d/gzip.conf;
}
        EOH

        destination = "local/nginx/nginx.conf"
      }


      # default.conf
      template {
        data = <<EOH
server {
    listen 80;
    listen [::]:80;

    server_name my.nts-server.com;

    return 301 https://$server_name$request_uri;
}


server {
    listen 443 ssl http2 default_server;
    listen [::]:443 ssl;
    ssl_certificate /opt/letsencrypt/live/my.nts-server.com/fullchain.pem;
    ssl_certificate_key /opt/letsencrypt/live/my.nts-server.com/privkey.pem;
    include ssl/ssl-params.conf;
    include /etc/nginx/conf.d/header.conf;

    server_name  my.nts-server.com;

    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}
        EOH

        destination = "local/nginx/default.conf"
      }


      # stream.conf
      template {
        data = <<EOH
upstream chrony-ntp {
  {{ range service "chrony-ntp" }}
    server {{ .Address }}:{{ .Port }};
  {{ else }}server 127.0.0.1:65535; # force a 502
  {{ end }}
}

upstream chrony-nts {
  {{ range service "chrony-nts" }}
    server {{ .Address }}:{{ .Port }};
  {{ else }}server 127.0.0.1:65535; # force a 502
  {{ end }}
}

server {
        listen 123 udp;
        listen 123; #tcp
        proxy_pass chrony-ntp;
        error_log  /var/log/nginx/ntp.log info;
        proxy_responses 1;
        proxy_timeout   1s;
}

server {
      listen 4460 udp;
      listen 4460; #tcp
      proxy_pass chrony-nts;
      error_log  /var/log/nginx/nts.log info;
      proxy_responses 1;
      proxy_timeout   1s;
}
        EOH

        destination = "local/nginx/stream.conf"
      }


      # index.html
      template {
        data = <<EOH
<!DOCTYPE html>
<html>
<head>
    <title>Welcome to my.nts-server.com!</title>
</head>
<body>
    <h1>Welcome to my.nts-server.com!</h1>
</body>
</html>
        EOH

        destination = "local/nginx/index.html"
      }


      # dhparam.pem
      template {
        data = <<EOH
-----BEGIN DH PARAMETERS-----
MIICCAKCA...

....

...CAQI=
-----END DH PARAMETERS-----
        EOH

        destination = "local/nginx/dhparam.pem"
      }


      # ssl-params.conf
      template {
        data = <<EOH
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
ssl_ciphers ECDH+AESGCM:ECDH+CHACHA20:ECDH+AES256:ECDH+AES128:!aNULL:!SHA1:!AESCCM;
ssl_conf_command Options PrioritizeChaCha;
ssl_conf_command Ciphersuites TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256;
ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0
ssl_session_timeout  10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off; # Requires nginx >= 1.5.9
ssl_stapling on; # Requires nginx >= 1.3.7
ssl_stapling_verify on; # Requires nginx => 1.3.7
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
        EOH

        destination = "local/nginx/ssl-params.conf"
      }


      # buffers.conf
      template {
        data = <<EOH
client_body_buffer_size 10k;
client_header_buffer_size 1k;
client_max_body_size 8m;
large_client_header_buffers 2 1k;
# Directive needs to be increased for certain site types to prevent ERROR 400
# large_client_header_buffers 4 32k;
        EOH

        destination = "local/nginx/buffers.conf"
      }


      # header.conf
      template {
        data = <<EOH
add_header                Cache-Control  "public, must-revalidate, proxy-revalidate, max-age=0";
proxy_set_header          X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header          X-NginX-Proxy true;
proxy_set_header          X-Real-IP $remote_addr;
proxy_set_header          X-Forwarded-Proto http;
proxy_hide_header         X-Frame-Options;
proxy_set_header          Accept-Encoding "";
proxy_http_version        1.1;
proxy_set_header          Upgrade $http_upgrade;
proxy_set_header          Connection "upgrade";
proxy_set_header          Host $host;
proxy_cache_bypass        $http_upgrade;
proxy_max_temp_file_size  0;
proxy_redirect            off;
proxy_read_timeout        240s;
        EOH

        destination = "local/nginx/header.conf"
      }


      # cache.conf
      template {
        data = <<EOH
open_file_cache max=1500 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 5;
open_file_cache_errors off;
        EOH

        destination = "local/nginx/cache.conf"
      }


      # timeouts.conf
      template {
        data = <<EOH
client_header_timeout 3m;
client_body_timeout 3m;
keepalive_timeout 100;
keepalive_requests 1000;
send_timeout 3m;
        EOH

        destination = "local/nginx/timeouts.conf"
      }


      # gzip.conf
      template {
        data = <<EOH
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
image/svg+xml application/xhtml+xml application/atom+xml;
        EOH

        destination = "local/nginx/gzip.conf"
      }

    }
  }
}