Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ All notable changes to the project are documented in this file.

- Upgrade Linux kernel to 6.18.49 (LTS)
- Upgrade FRR to 10.5.5
- Add per-server `minpoll` and `maxpoll` options to the NTP client
configuration, for tuning how often each server is polled

[v26.08.0][] - 2026-09-01
-------------------------
Expand Down
11 changes: 11 additions & 0 deletions doc/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,17 @@ metrics (default config).
* `prefer true`: The NTP client will try to use the preferred server
as the primary source unless it becomes unreachable or unusable.

Each server also accepts `minpoll` and `maxpoll` options, bounding how
often it is polled, expressed as log2 seconds:

<pre class="cli"><code>admin@example:/config/system/ntp/> <b>set server ntp-pool minpoll 4</b>
admin@example:/config/system/ntp/> <b>set server ntp-pool maxpoll 8</b>
</code></pre>

The defaults, 6 (64 seconds) and 10 (1024 seconds), suit most
deployments. Lower values give faster convergence and failover on local
networks, at the cost of more NTP traffic.


### Show NTP Sources

Expand Down
20 changes: 15 additions & 5 deletions src/confd/src/system.c
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,18 @@ static int change_clock(sr_session_ctx_t *session, struct lyd_node *config, stru
return rc;
}

/* Emit ' OPTION VALUE' for a per-server leaf, if set */
static void ntp_option(FILE *fp, sr_session_ctx_t *session, const char *xpath,
const char *leaf, const char *option)
{
char *val = srx_get_str(session, "%s/%s", xpath, leaf);

if (val) {
fprintf(fp, " %s %s", option, val);
free(val);
}
}

static int change_ntp_client(sr_session_ctx_t *session, struct lyd_node *config, struct lyd_node *diff, sr_event_t event, struct confd *confd)
{
sr_change_iter_t *iter = NULL;
Expand Down Expand Up @@ -392,18 +404,16 @@ static int change_ntp_client(sr_session_ctx_t *session, struct lyd_node *config,
free(type);
free(ptr);

ptr = srx_get_str(session, "%s/udp/port", xpath);
if (ptr) {
fprintf(fp, " port %s", ptr);
free(ptr);
}
ntp_option(fp, session, xpath, "udp/port", "port");
}

if (server) {
if (srx_enabled(session, "%s/iburst", xpath) > 0)
fprintf(fp, " iburst");
if (srx_enabled(session, "%s/prefer", xpath) > 0)
fprintf(fp, " prefer");
ntp_option(fp, session, xpath, "infix-system:minpoll", "minpoll");
ntp_option(fp, session, xpath, "infix-system:maxpoll", "maxpoll");
}
fprintf(fp, "\n");
fclose(fp);
Expand Down
2 changes: 1 addition & 1 deletion src/confd/yang/confd.inc
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ MODULES=(
"infix-firewall-icmp-types@2025-04-26.yang"
"infix-meta@2025-12-10.yang"
"infix-services@2026-06-17.yang"
"infix-system@2026-06-17.yang"
"infix-system@2026-09-01.yang"
"ieee802-ethernet-interface@2025-09-10.yang"
"ieee802-ethernet-phy-type@2025-09-10.yang"
"infix-ethernet-interface@2026-05-21.yang"
Expand Down
40 changes: 40 additions & 0 deletions src/confd/yang/confd/infix-system.yang
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ module infix-system {
contact "kernelkit@googlegroups.com";
description "Infix augments and deviations to ietf-system.";

revision 2026-09-01 {
description "Add per-server minpoll/maxpoll to NTP client configuration.";
reference "internal";
}
revision 2026-06-17 {
description "Add scheduled-reboot, triggered from a referenced schedule.";
reference "internal";
Expand Down Expand Up @@ -205,6 +209,14 @@ module infix-system {
* Typedefs
*/

typedef poll-interval {
type int8 {
range "-7..24";
}
units "log2 seconds";
description "NTP poll interval, as log2 seconds.";
}

typedef crypt-hash {
type string {
pattern
Expand Down Expand Up @@ -370,6 +382,34 @@ module infix-system {
}
}

augment "/sys:system/sys:ntp/sys:server" {
description "Per-server poll interval limits for the NTP client.";

leaf minpoll {
type poll-interval;
default "6";
description
"Minimum poll interval, default 6 (64 seconds). Values below
6 should not be used with public servers on the Internet.";
reference
"RFC 5905: Network Time Protocol Version 4: Protocol and
Algorithms Specification, Section 7.2";
}

leaf maxpoll {
must ". >= ../minpoll" {
error-message "maxpoll must be greater than or equal to minpoll";
}
type poll-interval;
default "10";
description
"Maximum poll interval, default 10 (1024 seconds).";
reference
"RFC 5905: Network Time Protocol Version 4: Protocol and
Algorithms Specification, Section 7.2";
}
}

augment "/sys:system/sys:authentication/sys:user" {
description "Augment of ietf-system to support setting login shell for users.";
leaf shell {
Expand Down
2 changes: 1 addition & 1 deletion test/.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# shellcheck disable=SC2034,SC2154

# Current container image
INFIX_TEST=ghcr.io/kernelkit/infix-test:2.10
INFIX_TEST=ghcr.io/kernelkit/infix-test:2.11

ixdir=$(readlink -f "$testdir/..")
logdir=$(readlink -f "$testdir/.log")
Expand Down
12 changes: 10 additions & 2 deletions test/case/ntp/client_stratum_selection/test.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@ stratum level.
This test validates NTP clock selection algorithm by configuring a client
to sync from two servers with different stratum levels:

- srv1: Test PC running BusyBox ntpd (stratum ~1 via -l flag)
- srv2: NTP server DUT syncing from srv1 (stratum ~2)
- srv1: Test PC running chronyd, serving its local clock at stratum 5
with an honest root distance so clients tolerate startup transients
- srv2: NTP server DUT syncing from srv1 (stratum 6)
- client: NTP client DUT syncing from both servers

Both servers sync to the same time source (srv2 syncs from srv1),
ensuring time agreement and avoiding the "falseticker" problem. The client
should then select srv1 (lower stratum) as its sync source.

NOTE: srv1 serves the test PC's system clock, so the test depends on that
clock being stable for the duration of the run. A host time daemon that
applies discrete corrections, e.g. systemd-timesyncd, makes the client
flag srv1 as unstable and refuse to select it. Test PCs should keep the
clock free-running during the test, or discipline it with a slewing
daemon such as chronyd.

==== Topology

image::topology.svg[NTP Client Stratum Selection topology, align=center, scaledwidth=75%]
Expand Down
79 changes: 52 additions & 27 deletions test/case/ntp/client_stratum_selection/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,22 @@
This test validates NTP clock selection algorithm by configuring a client
to sync from two servers with different stratum levels:

- srv1: Test PC running BusyBox ntpd (stratum ~1 via -l flag)
- srv2: NTP server DUT syncing from srv1 (stratum ~2)
- srv1: Test PC running chronyd, serving its local clock at stratum 5
with an honest root distance so clients tolerate startup transients
- srv2: NTP server DUT syncing from srv1 (stratum 6)
- client: NTP client DUT syncing from both servers

Both servers sync to the same time source (srv2 syncs from srv1),
ensuring time agreement and avoiding the "falseticker" problem. The client
should then select srv1 (lower stratum) as its sync source.

NOTE: srv1 serves the test PC's system clock, so the test depends on that
clock being stable for the duration of the run. A host time daemon that
applies discrete corrections, e.g. systemd-timesyncd, makes the client
flag srv1 as unstable and refuse to select it. Test PCs should keep the
clock free-running during the test, or discipline it with a slewing
daemon such as chronyd.

"""

import infamy
Expand All @@ -24,11 +32,14 @@

# Network configuration
ips = {
"srv1": "192.168.1.1", # BusyBox ntpd on test PC
"srv1": "192.168.1.1", # chronyd on test PC
"srv2": "192.168.1.2", # Infix NTP server
"client": "192.168.1.3" # Infix NTP client
}

# 16 s polls so selection re-evaluates quickly after iburst
POLL = {"infix-system:minpoll": 4, "infix-system:maxpoll": 6}

with infamy.Test() as test:
with test.step("Set up topology and attach to devices"):
env = infamy.Env()
Expand Down Expand Up @@ -78,14 +89,29 @@
"unicast-configuration": [{
"address": ips["srv1"], # Sync from srv1
"type": "uc-server",
"iburst": True
"iburst": True,
# Poll every 16 s so sub-threshold
# offsets drain quickly (corrections
# are spread over ~3 poll intervals)
"minpoll": 4,
"maxpoll": 6
}]
}
}
})

with test.step("Wait for srv2 to sync from srv1"):
until(lambda: ntp.server_has_associations(srv2), attempts=60)
# Converged, not just associated, see the docstring of
# server_source_synced. iburst + makestep take 10-20 s,
# the rest of the budget is only used when broken
try:
until(lambda: ntp.server_source_synced(srv2, ips["srv1"]),
attempts=60)
except Exception:
print("DEBUG: srv2 did not converge on srv1. Associations:")
for assoc in ntp.server_get_associations(srv2):
print(f" {assoc}")
raise

with test.step("Configure client to sync from both servers"):
client.put_config_dicts({
Expand Down Expand Up @@ -113,42 +139,41 @@
"udp": {
"address": ips["srv1"]
},
"iburst": True
"iburst": True,
**POLL
}, {
"name": "srv2",
"udp": {
"address": ips["srv2"]
},
"iburst": True
"iburst": True,
**POLL
}]
}
}
}
})

with test.step("Wait for client to see both servers"):
until(lambda: ntp.number_of_sources(client) == 2, attempts=60)
until(lambda: ntp.number_of_sources(client) == 2, attempts=30)

with test.step("Wait for srv2 stratum to stabilize"):
# Ensure srv2 has synced with srv1 and is advertising
# stratum 2. This prevents race where both advertise
# stratum 1, causing wrong selection
def check_stratums():
srv1 = ntp.get_source_by_address(client, ips["srv1"])
srv2 = ntp.get_source_by_address(client, ips["srv2"])

if not srv1 or not srv2:
return False

srv1_stratum = srv1.get("stratum")
srv2_stratum = srv2.get("stratum")
stratum = {src.get("address"): src.get("stratum")
for src in ntp.get_sources(client)}
srv1_stratum = stratum.get(ips["srv1"])
srv2_stratum = stratum.get(ips["srv2"])

# Both must have valid stratums and srv1 < srv2
if srv1_stratum and srv2_stratum and srv1_stratum < srv2_stratum:
return True
return False
return bool(srv1_stratum and srv2_stratum
and srv1_stratum < srv2_stratum)

until(check_stratums, attempts=60)
# srv2 synced before the client was configured, so the
# client's iburst samples already carry stratum 6
until(check_stratums, attempts=30)
print(f"srv1 and srv2 stratums verified as different")

with test.step("Verify client selects srv1 (lower stratum)"):
Expand All @@ -159,15 +184,15 @@ def srv1_selected():
return None

try:
selected = until(srv1_selected, attempts=120)
# Selection normally happens at the end of iburst;
# with minpoll 4 this covers two extra 16 s poll
# cycles plus slack
selected = until(srv1_selected, attempts=45)
except Exception:
# Timeout - print diagnostic info
sources = ntp.get_sources(client)
print("DEBUG: Failed to select srv1. Source details:")
for src in sources:
print(f" {src.get('address')}: stratum={src.get('stratum')}, "
f"state={src.get('state')}, poll={src.get('poll')}, "
f"offset={src.get('offset')}")
print("DEBUG: Failed to select srv1. Sources:")
for src in ntp.get_sources(client):
print(f" {src}")
raise

assert selected is not None, "srv1 was not selected"
Expand Down
1 change: 1 addition & 0 deletions test/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ FROM alpine:3.18.0
# NOTE: please add packages alphabetically!
RUN apk add --no-cache \
busybox-extras \
chrony \
curl \
dhcp-server-vanilla \
dnsmasq \
Expand Down
Loading
Loading