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 ( |
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
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 |
|---|---|
|
The Flask application: routes, authentication, CSRF, security headers, parsing of |
|
Norwegian translations (English is the key), translation of |
|
Privileged helper. Sub-commands |
|
|
|
Bootstrap 5.3.7 (CSS), |
|
The user manual as HTML, built from |
|
Shared shell functions: language, output, platform detection, |
2.3. Process model and privileges
| Process | User | Privileges |
|---|---|---|
|
|
|
|
|
None. One worker with four threads, |
|
root via sudo |
Started for each action and exits afterwards. |
--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 |
|
External |
|
Both |
|
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 tontpctl shift, which adds them to the current time. -
ntpctlcallstime.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 ID7F7F0101). -
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 runschronyc makestep. Without a reachable source the clock is not changed. -
With Also write the hardware clock
hwclock --systohcruns 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 |
|
POST |
All actions ( |
|
GET |
JSON: |
|
GET |
Audit log, chronyd journal, |
|
GET |
The user manual in the chosen language |
|
GET |
|
|
GET |
Machine-readable list of third-party components |
|
GET |
Stores the language in the cookie |
|
GET |
|
4.2. Language (i18n)
-
The English text is the key.
i18n.NOholds the Norwegian translation. Templates use_("…")and the Python codetr("…"). A missing translation is shown in English. -
The language of a request is the cookie
ntpgui_lang, otherwiseDEFAULT_LANGfromntpgui.env(install.sh --lang). -
Messages from
ntpctlare always English and are translated with a list of regular expressions ini18n.MESSAGE_PATTERNS. -
Texts for
app.jsare sent in<body data-i18n="…">, since the Content Security Policy does not allow inline scripts. -
The test
tests/test_i18n.pyfinds all texts in the templates andapp.pyand 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
/healthzand/static/require HTTP Basic Auth. User name and password are compared withhmac.compare_digest. Without a password inntpgui.envnobody 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, nothttps://…), so it cannot be used to send the user on to another site. -
--admin-netlimits 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_networkorall, at most 32 -
chrony.confis 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.1with 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.servicecannot useNoNewPrivilegesorProtectSystem, sincentpctlis 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:
-
SHA256SUMSis verified before anything is changed. -
The platform is found from
/etc/os-releaseandsystemd-detect-virt.CAP_SYS_TIMEis read fromCapBndin/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 -cfbefore it is put in place. -
Finally
/healthzand 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 |
|---|---|
|
|
|
The Python packages, unpacked from the wheels |
|
Used by |
|
The documents |
|
Licenses and version |
|
Uninstall and NTP check |
|
The helper (root:root 0755) |
|
|
|
The service |
|
User, password, |
|
|
|
The settings from the GUI |
|
Audit log (time, monotonic time, IP, user, action) |
|
Original |
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 |
5032 |
TCP |
The web GUI ( |
323 |
UDP |
chronyd’s command port, localhost only |
7.2. External resources
| Situation | External resources |
|---|---|
|
None. The wheels are unpacked locally. |
|
|
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 |
The Docker variant (optional) |
|
8. Operation
| Task | Command |
|---|---|
Status |
|
Logs |
|
Health check |
|
NTP check |
|
Self test |
|
Password, port, language |
|
From the shell |
|
Uninstall |
|
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 ( |
53 tests: login, CSRF, all actions, validation, language switch, open redirect, license pages, manual in both languages, message translation, |
|
Every text has a Norwegian translation with the same placeholders |
Self test ( |
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 |
Static analysis |
|
Build check |
|
The results are in the audit report (docs/Revisjonsrapport.md, Norwegian).