Chrony-Time-Service(at)Work

Chrony-Time-Service(at)Work / Technical description

Chrony-Time-Service(at)Work

Architecture, time control, security, installation and operation of Chrony-Time-Service(at)Work – an NTP server with a web GUI for test clocks in closed networks

Version 9.0.0Revised 2026-10-03Server and web GUI

Chrony-Time-Service(at)Work is an NTP server with a web interface for testing in closed networks. The server’s clock can be set to any time or shifted, and every client that synchronises against it follows. This document describes the architecture, how the clock is controlled, the security mechanisms, the installation model and the interfaces. It is written for operations, security review and further development.

1. Purpose and scope

Product

Chrony-Time-Service(at)Work 9.0.0

Components

Web GUI (app.py, i18n.py, templates), privileged helper (ntpctl.py), installation scripts (install.sh, uninstall.sh, check-prereqs.sh), tools (ntpcheck.py, selftest.py)

Use

Testing time-dependent behaviour: certificate and licence expiry, daylight saving time, leap years, the year 2038, time drift alarms and loss of the time server

Not intended for

A precise production time server. In internal mode the clock is as right as you set it.

Target platforms

Rocky Linux 9 / RHEL 9 / AlmaLinux 9 / Oracle Linux 9 (primary) and VMware Photon OS, as a VM in VMware or Proxmox VE, or on a physical machine

Run model

chronyd from the operating system and a Flask application under gunicorn, both managed by systemd. No containers, no pip, no internet access.

Languages

English and Norwegian (bokmål) in the web interface, messages, user manual and installation scripts

License

MIT

2. Architecture

2.1. Overview

Components and data flow
Browser (administrator)
   │ HTTP :5032, Basic Auth, CSRF token on every POST
   ▼
gunicorn 23 + Flask 3.1 (app.py, i18n.py)       user chronygui (not root)
   │  reading:  chronyc -c via localhost:323 (no sudo)
   │  changes:  sudo -n /usr/local/sbin/ntpctl …   (the only sudo rule)
   ▼
ntpctl (ntpctl.py, root, python3 -I)            validates all input again
   ├─ clock_settime(CLOCK_REALTIME)              sets the system clock
   ├─ hwclock --systohc                          optional: writes the RTC
   ├─ /var/lib/ntpgui/settings.json → /etc/chrony.conf
   └─ systemctl start|stop|restart chronyd
   ▼
chronyd (from the OS)  ◀── UDP 123 ── NTP clients in the test network

2.2. Code modules

File Contents

app.py

The Flask application: routes, authentication, CSRF, security headers, parsing of chronyc -c, audit log, language choice, license and manual pages

i18n.py

Norwegian translations (English is the key), translation of ntpctl messages, platform names, texts for JavaScript

ntpctl.py

Privileged helper. Sub-commands overview, settings, configure, render, service, settime, shift, resync, logs. Always answers with one JSON line.

templates/

base.html (top bar, EN | NO, footer), index.html, logs.html, manual.html, licenses.html

static/

Bootstrap 5.3.7 (CSS), app.css, app.js (live clock and status), theme.js (dark mode), icon, screenshots for the manual

manual/

The user manual as HTML, built from docs/*.md with tools/build-manuals.py

lib/common.sh

Shared shell functions: language, output, platform detection, CAP_SYS_TIME check

2.3. Process model and privileges

Process User Privileges

chronyd

chrony (drops root after start)

CAP_SYS_TIME to adjust the clock. Runs with -x where the clock cannot be set.

gunicorn (ntpgui.service)

chronygui

None. One worker with four threads, --timeout 0.

ntpctl

root via sudo

Started for each action and exits afterwards. python3 -I (isolated: ignores environment variables and user site-packages).

--timeout 0 is needed because gunicorn’s worker watchdog uses the wall clock. When the clock is set forwards, the worker would otherwise be killed in the middle of the request. Each sub-command has its own time limit instead.

3. Time control

3.1. Modes and chrony.conf

ntpctl writes the whole /etc/chrony.conf from settings.json. Only these directives can occur:

Mode Directives

Internal

local stratum N (1–15)

External

server <host> iburst, pool <host> iburst maxsources 4, makestep 1.0 3, optionally local stratum N orphan

Both

allow 127.0.0.1, allow ::1, allow <network> (max 32), ratelimit interval 1 burst 16, driftfile, logdir, log tracking measurements statistics, keyfile if present

The file is written atomically (temporary file and rename), and the SELinux label is restored with restorecon.

3.2. Setting and shifting the clock

  • Set time and Sync to this computer’s time send an epoch value to ntpctl settime. Shift sends seconds to ntpctl shift, which adds them to the current time.

  • ntpctl calls time.clock_settime(CLOCK_REALTIME, …). The clock jumps; chronyd sees the jump and serves the new time from the next answer. In internal mode the system clock itself is the reference (LOCAL, ref ID 7F7F0101).

  • Valid range: 2000-01-01 to 2099-12-31 UTC. Largest shift: 10 years.

  • In external mode chronyd pulls a manually changed clock back towards the upstream servers. Resync from upstream runs chronyc burst 4/4, waits, checks that an upstream source is selected and runs chronyc makestep. Without a reachable source the clock is not changed.

  • With Also write the hardware clock hwclock --systohc runs afterwards.

3.3. Status reading

The page fetches /api/status every 5 seconds. app.py reads chronyc -c -n tracking and sources as user chronygui through chronyd’s command port (UDP 323 on localhost), without sudo. The client list (chronyc clients) needs root and is fetched through ntpctl overview when the page is loaded. The live clock in the browser computes the difference between server and browser time from the midpoint of the request, and ticks locally between readings.

3.4. Hypervisor time sync

ntpctl looks for agents that can change the clock outside chrony, and the GUI shows a warning for each:

  • qemu-guest-agent, vboxadd-service, systemd-timesyncd, ntpd, ptp4l, phc2sys (running systemd units)

  • periodic time sync in VMware Tools (vmware-toolbox-cmd timesync status)

  • Hyper-V (hv_utils)

install.sh disables the periodic sync in VMware Tools and prints the .vmx parameters that must also be set. For Proxmox VE 9.2.5 and newer, the agent option sync-time-on-resume decides whether Proxmox sets the guest clock after resume, migration and rollback. It is off by default.

4. Web interface

4.1. Pages and HTTP API

Path Method Contents

/

GET

Main page: clock, service, system, set clock, configuration, tables

/action

POST

All actions (action=start|stop|restart|set_time|shift|sync_browser|resync|configure). Answers with a redirect to / and a message.

/api/status

GET

JSON: epoch, tz, utc_offset, service, tracking, sources

/logs

GET

Audit log, chronyd journal, tracking.log, active chrony.conf

/manual

GET

The user manual in the chosen language

/licenses

GET

LICENSE.txt (#license) and THIRD_PARTY_NOTICES.txt (#third-party)

/licenses/third_party_inventory.json

GET

Machine-readable list of third-party components

/lang/<en|no>?next=<path>

GET

Stores the language in the cookie ntpgui_lang and returns to next (local paths only)

/healthz

GET

200 ok when chronyd runs and answers, otherwise 503. No login required.

4.2. Language (i18n)

  • The English text is the key. i18n.NO holds the Norwegian translation. Templates use _("…") and the Python code tr("…"). A missing translation is shown in English.

  • The language of a request is the cookie ntpgui_lang, otherwise DEFAULT_LANG from ntpgui.env (install.sh --lang).

  • Messages from ntpctl are always English and are translated with a list of regular expressions in i18n.MESSAGE_PATTERNS.

  • Texts for app.js are sent in <body data-i18n="…">, since the Content Security Policy does not allow inline scripts.

  • The test tests/test_i18n.py finds all texts in the templates and app.py and fails if any lacks a Norwegian translation or has different placeholders.

4.3. Licenses and manual

The footer of every page has the links Licenses · Third-party notices · User manual. The license files are read from NTPGUI_DOC_DIR (/opt/ntpgui). The user manual is pre-built HTML in manual/manual.en.html and manual.no.html, made with pandoc from docs/en/User-Manual.md and docs/Brukermanual.md. The images are in static/img/.

5. Security

5.1. Access control

  • All pages except /healthz and /static/ require HTTP Basic Auth. User name and password are compared with hmac.compare_digest. Without a password in ntpgui.env nobody gets in.

  • Every POST requires a CSRF token stored in a signed session cookie (SameSite=Strict, HttpOnly).

  • Security headers: Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self', X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: same-origin, Cache-Control: no-store.

  • The language switch only accepts local paths in next (not //host, not https://…), so it cannot be used to send the user on to another site.

  • --admin-net limits the GUI port in firewalld to one network.

5.2. Validation in ntpctl

ntpctl does not trust the GUI and checks everything again:

  • time as a number between 2000 and 2099 (UTC), shift at most 10 years, not NaN

  • mode internal/external, stratum 1–15

  • servers: IP address (ipaddress) or host name matching a strict pattern (RFC 1123), optionally prefixed with `pool `, at most 8

  • networks: ipaddress.ip_network or all, at most 32

  • chrony.conf is built from an allow list, so custom directives cannot be injected

  • all commands run as argument lists without a shell

5.3. Known limitations

  • Basic Auth over HTTP sends the password in clear text. The GUI is meant for a closed test network. Use --admin-net, or --bind 127.0.0.1 with an SSH tunnel.

  • NTP is unauthenticated. Anyone in the allowed networks can query the server, and an attacker in the network can forge answers. That is acceptable for a test clock.

  • ntpgui.service cannot use NoNewPrivileges or ProtectSystem, since ntpctl is started through sudo.

  • click 8.1.8 has a known finding (PYSEC-2026-2132) in click.edit(), which is not used. The fixed version requires Python 3.10 (see the audit report).

6. Installation and file layout

6.1. The installer

install.sh is idempotent: it can be run again to upgrade or to change password, port and language. The order of the steps is described in the installation guide, chapter 5.1. Important details:

  • SHA256SUMS is verified before anything is changed.

  • The platform is found from /etc/os-release and systemd-detect-virt. CAP_SYS_TIME is read from CapBnd in /proc/self/status. If it is missing, a drop-in for chronyd adds -x.

  • The application is replaced as a whole (app.new → app), so files removed in a new version disappear.

  • The sudo rule is checked with visudo -cf before it is put in place.

  • Finally /healthz and an NTP answer on UDP 123 (ntpcheck.py) are checked. The exit code is 2 if the self test fails.

6.2. Installed layout

Path Contents

/opt/ntpgui/app/

app.py, i18n.py, templates/, static/, manual/

/opt/ntpgui/lib/python/

The Python packages, unpacked from the wheels

/opt/ntpgui/lib/sh/common.sh

Used by uninstall.sh

/opt/ntpgui/docs/

The documents

/opt/ntpgui/{LICENSE.txt,THIRD_PARTY_NOTICES.txt,third_party_inventory.json,VERSION}

Licenses and version

/opt/ntpgui/uninstall.sh, /opt/ntpgui/ntpcheck.py

Uninstall and NTP check

/usr/local/sbin/ntpctl

The helper (root:root 0755)

/etc/sudoers.d/ntpgui

chronygui ALL=(root) NOPASSWD: /usr/local/sbin/ntpctl

/etc/systemd/system/ntpgui.service

The service

/etc/ntpgui/ntpgui.env

User, password, SECRET_KEY, address, port, language, PYTHONPATH (root:chronygui 0640)

/etc/ntpgui/runtime

host (or container in the Docker variant)

/var/lib/ntpgui/settings.json

The settings from the GUI

/var/log/ntpgui/audit.log

Audit log (time, monotonic time, IP, user, action)

/etc/chrony.conf.ntpgui-orig

Original chrony.conf, restored on uninstall

6.3. Python packages

The wheels in vendor/wheels are unpacked with python3 -m zipfile -e into /opt/ntpgui/lib/python. Pure Python wheels (py3-none-any) work everywhere. For MarkupSafe the wheel for the current Python version and architecture is chosen (Python 3.9 and 3.11, x86_64 and aarch64). If there is none, any MarkupSafe wheel is used: the C extension is then not loaded, and MarkupSafe uses its pure Python implementation. After unpacking, the installer checks that flask, gunicorn, jinja2 and markupsafe can be imported before the old directory is replaced.

Package Version Purpose

Flask

3.1.3

Web framework

Werkzeug

3.1.9

HTTP and routing

Jinja2

3.1.6

Templates

MarkupSafe

3.0.4

HTML escaping

itsdangerous

2.2.0

Signed session cookie

click

8.1.8

Required by Flask, not used

blinker

1.9.0

Required by Flask

gunicorn

23.0.0

WSGI server, newest with Python 3.9

packaging

26.3

Required by gunicorn

importlib_metadata

8.7.1

Required by Flask on Python 3.9

zipp

3.23.1

Required by importlib_metadata

6.4. Upgrading from v8

v8 installed the Python packages in a venv (/opt/ntpgui/venv). 9.0.0 uses the same paths for settings, logs, user and helper. install.sh recognises v8, keeps ntpgui.env (password, port), settings.json and /etc/chrony.conf.ntpgui-orig, adds DEFAULT_LANG, GUI_LISTEN and PYTHONPATH, and removes the venv directory.

7. Network and external resources

7.1. Ports

Port Protocol Use

123

UDP

NTP. Opened in firewalld (service ntp). chrony only answers the allowed networks.

5032

TCP

The web GUI (--port). Opened in firewalld, optionally only for --admin-net.

323

UDP

chronyd’s command port, localhost only

7.2. External resources

Situation External resources

install.sh

None. The wheels are unpacked locally.

install.sh when chrony, python3 or sudo is missing

dnf/tdnf from the machine’s repositories (or a mounted ISO)

Operation in internal mode (default)

None. The server contacts nothing outside the machine.

Operation in external mode

NTP (UDP 123) to the configured servers, and DNS lookups of their names

The browser

None. Everything is loaded from the server itself (CSP default-src 'self').

The Docker variant (optional)

python:3.11-slim, Debian packages and possibly pypi.org when building

8. Operation

Task Command

Status

systemctl status ntpgui chronyd, chronyc tracking

Logs

journalctl -u ntpgui -f, journalctl -u chronyd -f, /var/log/ntpgui/audit.log

Health check

curl -s http://127.0.0.1:5032/healthz

NTP check

python3 /opt/ntpgui/ntpcheck.py <server>

Self test

sudo python3 tools/selftest.py (changes the clock) or --readonly

Password, port, language

sudo ./install.sh --password … --port … --lang …

From the shell

sudo ntpctl shift --seconds 3600, sudo ntpctl settime --epoch …

Uninstall

sudo /opt/ntpgui/uninstall.sh [--purge]

The audit log records both the system time (which may have been changed) and mono= (seconds since boot). The order of and the time between events can therefore be read even after big jumps.

9. Quality assurance

Check Contents

Unit tests (pytest, Python 3.9)

53 tests: login, CSRF, all actions, validation, language switch, open redirect, license pages, manual in both languages, message translation, ntpctl validation and chrony.conf

tests/test_i18n.py

Every text has a Norwegian translation with the same placeholders

Self test (tools/selftest.py)

37 checks against an installed server with real chronyd, sudo and clock control

Installation tests

New install, upgrade from v8, re-run, uninstall, VMware and KVM detection, LXC without CAP_SYS_TIME

Static analysis

shellcheck on all scripts, pip-audit on all wheels

Build check

tools/build-package.sh runs the tests, checks the third-party files and creates SHA256SUMS

The results are in the audit report (docs/Revisjonsrapport.md, Norwegian).

Screenshots, installation and test scenarios: Back to the front page