feat: add NTS (Network Time Security) support for NTP time sync

Implement optional NTS (RFC 8915) support for authenticated and encrypted
time synchronization, using the beevik/nts library.

Signed-off-by: Erwan Leboucher <erwanleboucher@gmail.com>
Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
This commit is contained in:
Erwan Leboucher 2026-03-25 15:14:45 +00:00 committed by Andrey Smirnov
parent 6830a8b97d
commit 0198eedc2b
No known key found for this signature in database
GPG Key ID: 322C6F63F594CE7C
31 changed files with 846 additions and 315 deletions

View File

@ -547,11 +547,13 @@ message TCPProbeSpec {
message TimeServerSpecSpec {
repeated string ntp_servers = 1;
talos.resource.definitions.enums.NetworkConfigLayer config_layer = 2;
bool use_nts = 3;
}
// TimeServerStatusSpec describes NTP servers.
message TimeServerStatusSpec {
repeated string ntp_servers = 1;
bool use_nts = 2;
}
// VIPEquinixMetalSpec describes virtual (elastic) IP settings for Equinix Metal.

3
go.mod
View File

@ -52,6 +52,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4
github.com/aws/smithy-go v1.25.0
github.com/beevik/ntp v1.5.0
github.com/beevik/nts v0.3.0
github.com/blang/semver/v4 v4.0.0
github.com/cenkalti/backoff/v4 v4.3.0
github.com/containerd/cgroups/v3 v3.1.3
@ -210,6 +211,7 @@ require (
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/gopenpgp/v2 v2.10.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apparentlymart/go-cidr v1.1.0 // indirect
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
@ -361,6 +363,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sasha-s/go-deadlock v0.3.6 // indirect
github.com/sassoftware/relic v7.2.1+incompatible // indirect
github.com/secure-io/siv-go v0.0.0-20180922214919-5ff40651e2c4 // indirect
github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/siderolabs/protoenc v0.2.4 // indirect

6
go.sum
View File

@ -64,6 +64,8 @@ github.com/ProtonMail/gopenpgp/v2 v2.10.0 h1:llCzLvntC9+iH+if/na4AgKTef/Zm4vpaRr
github.com/ProtonMail/gopenpgp/v2 v2.10.0/go.mod h1:dc0h9Pg3ftfN0U4pfRzujilfh61A2R52wgMkZWcWm2I=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1 h1:+JkXLHME8vLJafGhOH4aoV2Iu8bR55nU6iKMVfYVLjY=
github.com/aead/cmac v0.0.0-20160719120800-7af84192f0b1/go.mod h1:nuudZmJhzWtx2212z+pkuy7B6nkBqa+xwNXZHL1j8cg=
github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM=
github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@ -113,6 +115,8 @@ github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4=
github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow=
github.com/beevik/nts v0.3.0 h1:d3kfEtiE45gu9pf/MabhX5Ti24cFQwASPy8cPmDK1ZA=
github.com/beevik/nts v0.3.0/go.mod h1:PsbzM+nBJv639oFfcsTmD5gyVYtcnaCRSAGKZbwkjHg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
@ -777,6 +781,8 @@ github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgm
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
github.com/secure-io/siv-go v0.0.0-20180922214919-5ff40651e2c4 h1:zOjq+1/uLzn/Xo40stbvjIY/yehG0+mfmlsiEmc0xmQ=
github.com/secure-io/siv-go v0.0.0-20180922214919-5ff40651e2c4/go.mod h1:aI+8yClBW+1uovkHw6HM01YXnYB8vohtB9C83wzx34E=
github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14=
github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=

View File

@ -7,7 +7,7 @@ match_deps = "^github.com/((talos-systems|siderolabs)/[a-zA-Z0-9-]+)$"
ignore_deps = ["github.com/coredns/coredns"]
# previous release
previous = "v1.12.0"
previous = "v1.13.0-rc.0"
pre_release = true
@ -19,292 +19,21 @@ preface = """
title = "Component Updates"
description = """\
Linux: 6.18.22
containerd: 2.2.3
etcd: 3.6.9
CoreDNS: 1.14.2
Kubernetes: 1.36.0-rc.1
CNI: 1.9.1
Flannel CNI plugin: v1.9.0-flannel1
Flannel: 0.28.4
LVM2: 2_03_38
runc: 1.4.2
systemd: 259.5
cryptsetup: 2.8.3
Tenstorrent: 2.7.0
iptables: 1.8.12
musl: 1.2.6
Talos is built with Go 1.26.2.
"""
[notes.external_volumes]
title = "External Volumes"
[notes.nts]
title = "NTS for Time Synchronization"
description = """\
Talos now supports virtiofs-based external volumes via the new
[ExternalVolumeConfig](https://www.talos.dev/v1.13/reference/configuration/block/externalvolumeconfig/)
document.
Talos now supports Network Time Security (NTS) for secure time synchronization.
This feature enhances the security of NTP by providing cryptographic authentication of time sources.
These virtiofs external volumes are not supported when SELinux is running
in enforcing mode.
NTS is enabled by default (without any configuration sources) for the default `time.cloudflare.com` time server
NTS can be enabled for custom time servers via the new `useNTS` field in the `TimeServerConfig` document.
"""
[notes.procpidmem]
title = "/proc/PID/mem Access Hardening"
description = """\
A new kernel parameter `proc_mem.force_override=never` has been introduced by default to enhance system security
by preventing unwanted writes to protected process memory via `/proc/PID/mem`.
If the kernel parameter is removed, default behavior is restored, allowing access only if the process is traced.
"""
[notes.pigz]
title = "Container Image Decompression"
description = """\
Talos now ships with `igzip` (amd64) and `pigz` (arm64) to speed up container image decompression.
"""
[notes.imager]
title = "Talos Imager Enhancements"
description = """\
Talos imager now supports running rootless. `--privileged` and `-v /dev:/dev` are no longer required.
"""
[notes.reproducible_images]
title = "Reproducible Disk Images"
description = """\
Talos disk images are now reproducible. Building the same version of Talos multiple times will yield
identical disk images.
Note: VHD and VMDK (Azure and VMware) images are not currently reproducible due to limitations in the underlying image creation tools.
Users verifying reproducible images should use raw images, verify checksums, and convert them to VHD/VMDK as needed.
"""
[notes.vm]
title = "VM Hot-Add Support"
description = """\
Talos now includes udev rules to support hot-adding of CPUs in virtualized environments.
"""
[notest.interactive-installer]
title = "Interactive Installer Removal"
description = """\
The interactive installer mode has been removed from `talosctl apply-config` (`--mode=interactive`).
It has been deprecated since Talos v1.12.0, and now fully removed.
The related `GenerateConfiguration` API method has also been removed.
Users are encouraged to use other installation methods, such as using pre-generated configuration files, or using Omni.
"""
[notes.k8s_ssa]
title = "Kubernetes server-side apply"
description = """\
Talos now uses inventory backed server-side apply when applying bootsrap manifests (including `extraManifests` and `inlineManifests`).
Purging of unneeded manifests is automatically performed.
The switch and inventory backfill is automatic and no action is needed from the user.
"""
[notes.talosctl_images_talos_bundle]
title = "`talosctl images talos-bundle` can ignore reaching to the registry"
description = """\
The `talosctl images talos-bundle` command now accepts optional `--overlays` and `--extensions` flags.
If those are set to `false`, the command will not attempt to reach out to the container registry to fetch the latest versions and digests of the overlays and extensions.
"""
[notes.images_k8s_bundle]
title = "Talosctl images k8s-bundle subcommand accepts version parameter"
description = """\
The `talosctl images k8s-bundle` command now accepts an optional version overrides arguments.
"""
[notes.environment_config]
title = "Environment Configuration Document"
description = """\
A new `EnvironmentConfig` document has been introduced to allow users to specify environment variables for Talos components.
It replaces and deprecates the previous method of setting environment variables via the `.machine.env` field.
Multiple values for the same environment variable will replace previous values, with the last one taking precedence.
To remove an environment variable, remove it from the `EnvironmentConfig` document and restart the node.
"""
[notes.kubespan]
title = "KubeSpan Configuration"
description = """\
A new `KubeSpanConfig` document has been introduced to configure KubeSpan settings.
It replaces and deprecates the previous method of configuring KubeSpan via the `.machine.network.kubespan` field.
The old configuration field will continue to work for backward compatibility.
"""
[notes.link_alias_config]
title = "LinkAliasConfig Pattern-Based Multi-Alias"
description = """\
`LinkAliasConfig` now supports pattern-based alias names using `%d` format verb (e.g. `net%d`).
When the alias name contains a `%d` format verb, the selector is allowed to match multiple links.
Each matched link receives a sequential alias (e.g. `net0`, `net1`, ...) based on hardware address order
of the links. Links already aliased by a previous config are automatically skipped.
This enables creating stable aliases from any N links using a single config document,
useful for `BondConfig` and `BridgeConfig` member interfaces on varying hardware.
"""
[notes.extraArgs]
title = "Extra Arguments accept slices in addition to strings"
description = """\
Several Talos configuration fields that previously accepted single string values for extra arguments have been updated to accept slices of strings as well.
This includes fields such as `.cluster.apiServer.extraArgs`.
BREAKING: If you were relying on the resources EtcdConfigs, KubeletConfigs, ControllerManagerConfigs, SchedulerConfigs or APIServerConfigs, the protobuf format has changed from `map<string,string>` to `map<string,message>`.
"""
[notes.serviceAccountIssuer]
title = "Service Account Issuer configuration"
description = """\
In API Server, passing extra args with `service-account-issuer` will append them after default value.
This allows easy migration, e.g. by changing `.cluster.controlPlane.endpoint` to new value, and keeping the old value in
`.cluster.apiServer.extraArgs["service-account-issuer"]`.
"""
[notes.negativeMaxVolumeSize]
title = "Negative Max Volume Size"
description = """\
Negative max size represents the amount of space to be left free on the device, rather than the size the volume should consume.
For example:
* a max size of "-10GiB" means the volume can grow to the available space minus 10GiB.
* a max size of "-25%" means the volume can grow to the available space minus 25%.
"""
[notes.resolver_config]
title = "ResolverConfig"
description = """\
The nameservers configuration in machine configuration now overwrites any previous layers (defaults, platform, etc.) when specified.
Previously a smart merge was performed to keep IPv4/IPv6 nameservers from lower layers if the machine configuration specified only one type.
"""
[notes.kernel_preempt]
title = "Dynamic Linux Kernel Preemption Model"
description = """\
Talos Linux now defaults to dynamic Linux kernel preemption model, the default value `none` matches
previous version, but now with kernel argument `preempt=` the preemption model can be changed.
See [Linux kernel documentation](https://docs.kernel.org/admin-guide/kernel-parameters.html) for more
information on supported values.
This change only applies to amd64 (x86_64) architecture.
"""
[notes.probe_config]
title = "ProbeConfig"
description = """\
The TCPProbeConfig configuration document allows to configure TCP probes for network reachability checks.
This allows to define a custom connectivity condition.
"""
[notes.images]
title = "Image APIs Updated"
description = """\
Talos Linux provides new APIs to manage container images on the node: listing, pulling, importing and removing images.
The new pull APIs provides pull progress notifications.
The CLI commands `talosctl image pull`, `talosctl image list` and `talosctl image remove` have been updated to interact with the new APIs.
"""
[notes.debug]
title = "talosctl debug"
description = """\
Talos Linux now provides a way to run and attach to the privileged debug container with a user-provided container image.
The debug container might be used for troubleshooting and debugging purposes.
"""
[notes.network-policy]
title = "Flannel CNI with Network Policy Support"
description = """\
Talos Linux now supports optionally deploying Flannel CNI with [network policy support](https://kubernetes.io/docs/concepts/services-networking/network-policies/) enabled.
The network policy implementation is [kube-network-policies](https://github.com/kubernetes-sigs/kube-network-policies/).
To enable Flannel CNI with network policy support, use the following machine configuration patch:
```yaml
cluster:
network:
cni:
name: flannel
flannel:
kubeNetworkPoliciesEnabled: true
```
(If the cluster is already running, sync the bootstrap manifests after applying the patch to deploy the new CNI configuration.)
"""
[notes.kubespan-filters]
title = "KubeSpan Advertised Network Filters"
description = """\
KubeSpan now supports filtering of advertised networks using the `excludeAdvertisedNetworks` field in the `KubeSpanConfig` document.
This allows users to specify a list of CIDRs to exclude from the advertised networks. Please note that routing must be symmetric for any
pair of peers, so if one peer excludes a certain network, the other peer must also exclude it. In other words, for any given pair of peers,
and any pair of their addresses, the traffic should either go through KubeSpan or not, but not one way or the other.
"""
[notes.clang-thinlto]
title = "Clang built kernel and ThinLTO"
description = """\
Talos now uses a kernel built using Clang compiler, and optimized using ThinLTO. This should bring a small performance improvement,
alongside some hardening features, such as BTI on supported ARM systems.
"""
[notes.vrf]
title = "VRF Support"
description = """\
Talos now supports VRF (Virtual Routing and Forwarding) via the new `VRFConfig` machine config document.
"""
[notes.image_signatures]
title = "Container Image Signature Verification"
description = """\
Talos now supports machine-wide container image signature verification via the new `ImageVerificationConfig` machine config document.
Any image which gets pulled on the node will be verified against the configured rules, and if no rule matches, it will be pulled without verification.
"""
[notest.blackhole_routes]
title = "Blackhole Route Support"
description = """\
Talos now supports blackhole routes via the new `BlackholeRouteConfig` machine config document.
"""
[notes.install_upgrade_api]
title = "Install and Upgrade API"
description = """\
Talos now exposes install and upgrade operations via the `LifecycleService` API, enabling programmatic installs and upgrades through a single, consistent interface.
The legacy upgrade API is deprecated; new integrations should migrate to `LifecycleService` for future compatibility.
"""
[notes.talosctl_upgrade_lifecycle]
title = "Lifecycle Upgrade in talosctl"
description = """\
`talosctl` upgrades now route through `LifecycleService`, aligning CLI behavior with the new install/upgrade API and unifying the upgrade path.
This change is transparent to users but standardizes the backend used for upgrades.
"""
[notes.container_device_interface]
title = "Container Device Interface"
description = """\
Talos now enables [CDI](https://github.com/cncf-tags/container-device-interface) by default and extension/extension services can bring in dynamic
CDI spec files under `/run/cdi`.
"""
[notes.nvidia]
title = "NVIDIA GPU Support"
description = """\
Talos switched to using CDI and now supports configuring NVIDIA GPU via the gpu-operator helm chart.
See the documentation on [upgrade notes](https://docs.siderolabs.com/talos/v1.13/configure-your-talos-cluster/lifecycle-management/upgrading-talos#after-upgrade-to)
for more details on how to configure NVIDIA GPU support in Talos.
"""
[notes.routing_rules]
title = "Routing Rules Support"
description = """\
Talos now supports routing rules via the new `RoutingRuleConfig` machine config document.
"""
[make_deps]

View File

@ -162,6 +162,7 @@ func (ctrl *TimeServerConfigController) apply(ctx context.Context, r controller.
func (ctrl *TimeServerConfigController) getDefault() (spec network.TimeServerSpecSpec) {
spec.NTPServers = []string{constants.DefaultNTPServer}
spec.ConfigLayer = network.ConfigDefault
spec.UseNTS = true
return spec
}
@ -197,7 +198,10 @@ func (ctrl *TimeServerConfigController) parseMachineConfiguration(cfgProvider ta
return spec
}
spec.NTPServers = slices.Clone(cfgProvider.NetworkTimeSyncConfig().Servers())
cfg := cfgProvider.NetworkTimeSyncConfig()
spec.NTPServers = slices.Clone(cfg.Servers())
spec.UseNTS = cfg.UseNTS()
spec.ConfigLayer = network.ConfigMachineConfiguration
return spec

View File

@ -37,6 +37,7 @@ func (suite *TimeServerConfigSuite) TestDefaults() {
"default/timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal([]string{constants.DefaultNTPServer}, r.TypedSpec().NTPServers)
asrt.True(r.TypedSpec().UseNTS)
asrt.Equal(network.ConfigDefault, r.TypedSpec().ConfigLayer)
},
rtestutils.WithNamespace(network.ConfigNamespaceName),
@ -58,6 +59,7 @@ func (suite *TimeServerConfigSuite) TestCmdline() {
"cmdline/timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal([]string{"10.0.0.1"}, r.TypedSpec().NTPServers)
asrt.False(r.TypedSpec().UseNTS)
},
rtestutils.WithNamespace(network.ConfigNamespaceName),
)
@ -97,6 +99,7 @@ func (suite *TimeServerConfigSuite) TestMachineConfigurationLegacy() {
"configuration/timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal([]string{"za.pool.ntp.org", "pool.ntp.org"}, r.TypedSpec().NTPServers)
asrt.False(r.TypedSpec().UseNTS)
},
rtestutils.WithNamespace(network.ConfigNamespaceName),
)
@ -116,6 +119,7 @@ func (suite *TimeServerConfigSuite) TestMachineConfigurationNewStyle() {
tsc := networkcfg.NewTimeSyncConfigV1Alpha1()
tsc.TimeNTP = &networkcfg.NTPConfig{
Servers: []string{"za.pool.ntp.org", "pool.ntp.org"},
UseNTS: new(true),
}
ctr, err := container.New(tsc)
@ -130,6 +134,7 @@ func (suite *TimeServerConfigSuite) TestMachineConfigurationNewStyle() {
"configuration/timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal([]string{"za.pool.ntp.org", "pool.ntp.org"}, r.TypedSpec().NTPServers)
asrt.True(r.TypedSpec().UseNTS)
},
rtestutils.WithNamespace(network.ConfigNamespaceName),
)

View File

@ -31,9 +31,10 @@ func NewTimeServerMergeController() controller.Controller {
continue
}
if spec.TypedSpec().ConfigLayer == final.ConfigLayer {
if spec.TypedSpec().ConfigLayer == final.ConfigLayer && final.NTPServers != nil {
// merge server lists on the same level
final.NTPServers = append(final.NTPServers, spec.TypedSpec().NTPServers...)
final.UseNTS = final.UseNTS && spec.TypedSpec().UseNTS
} else {
// otherwise, replace the lists
final = *spec.TypedSpec()

View File

@ -33,18 +33,21 @@ func (suite *TimeServerMergeSuite) TestMerge() {
def := network.NewTimeServerSpec(network.ConfigNamespaceName, "default/timeservers")
*def.TypedSpec() = network.TimeServerSpecSpec{
NTPServers: []string{constants.DefaultNTPServer},
UseNTS: true,
ConfigLayer: network.ConfigDefault,
}
dhcp1 := network.NewTimeServerSpec(network.ConfigNamespaceName, "dhcp/eth0")
*dhcp1.TypedSpec() = network.TimeServerSpecSpec{
NTPServers: []string{"ntp.eth0"},
UseNTS: true,
ConfigLayer: network.ConfigOperator,
}
dhcp2 := network.NewTimeServerSpec(network.ConfigNamespaceName, "dhcp/eth1")
*dhcp2.TypedSpec() = network.TimeServerSpecSpec{
NTPServers: []string{"ntp.eth1"},
UseNTS: true,
ConfigLayer: network.ConfigOperator,
}
@ -73,6 +76,26 @@ func (suite *TimeServerMergeSuite) TestMerge() {
"timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal([]string{"ntp.eth0", "ntp.eth1"}, r.TypedSpec().NTPServers)
asrt.True(r.TypedSpec().UseNTS)
},
)
}
func (suite *TimeServerMergeSuite) TestMergeJustDefault() {
def := network.NewTimeServerSpec(network.ConfigNamespaceName, "default/timeservers")
*def.TypedSpec() = network.TimeServerSpecSpec{
NTPServers: []string{constants.DefaultNTPServer},
UseNTS: true,
ConfigLayer: network.ConfigDefault,
}
suite.Create(def)
suite.assertTimeServers(
[]string{
"timeservers",
}, func(r *network.TimeServerSpec, asrt *assert.Assertions) {
asrt.Equal(*def.TypedSpec(), *r.TypedSpec())
},
)
}

View File

@ -96,10 +96,11 @@ func (ctrl *TimeServerSpecController) Run(ctx context.Context, r controller.Runt
ntps[i] = spec.TypedSpec().NTPServers[i]
}
logger.Info("setting time servers", zap.Strings("addresses", ntps))
logger.Info("setting time servers", zap.Strings("addresses", ntps), zap.Bool("nts", spec.TypedSpec().UseNTS))
if err = safe.WriterModify(ctx, r, network.NewTimeServerStatus(network.NamespaceName, spec.Metadata().ID()), func(status *network.TimeServerStatus) error {
status.TypedSpec().NTPServers = spec.TypedSpec().NTPServers
status.TypedSpec().UseNTS = spec.TypedSpec().UseNTS
return nil
}); err != nil {

View File

@ -26,6 +26,7 @@ func (suite *TimeServerSpecSuite) TestSpec() {
spec := network.NewTimeServerSpec(network.NamespaceName, "timeservers")
*spec.TypedSpec() = network.TimeServerSpecSpec{
NTPServers: []string{constants.DefaultNTPServer},
UseNTS: true,
ConfigLayer: network.ConfigDefault,
}
@ -36,6 +37,7 @@ func (suite *TimeServerSpecSuite) TestSpec() {
"timeservers",
func(status *network.TimeServerStatus, asrt *assert.Assertions) {
asrt.Equal([]string{constants.DefaultNTPServer}, status.TypedSpec().NTPServers)
asrt.True(status.TypedSpec().UseNTS)
},
)
}

View File

@ -62,7 +62,7 @@ type NTPSyncer interface {
}
// NewNTPSyncerFunc function allows to replace ntp.Syncer with the mock.
type NewNTPSyncerFunc func(*zap.Logger, []string) NTPSyncer
type NewNTPSyncerFunc func(*zap.Logger, []string, bool) NTPSyncer
// Run implements controller.Controller interface.
//
@ -73,8 +73,8 @@ func (ctrl *SyncController) Run(ctx context.Context, r controller.Runtime, logge
}
if ctrl.NewNTPSyncer == nil {
ctrl.NewNTPSyncer = func(logger *zap.Logger, timeServers []string) NTPSyncer {
return ntp.NewSyncer(logger, timeServers)
ctrl.NewNTPSyncer = func(logger *zap.Logger, timeServers []string, useNTS bool) NTPSyncer {
return ntp.NewSyncer(logger, timeServers, useNTS)
}
}
@ -108,6 +108,7 @@ func (ctrl *SyncController) Run(ctx context.Context, r controller.Runtime, logge
timeSynced bool
epoch int
useNTS bool
timeSyncTimeoutTimer *stdtime.Timer
timeSyncTimeoutCh <-chan stdtime.Time
@ -166,6 +167,7 @@ func (ctrl *SyncController) Run(ctx context.Context, r controller.Runtime, logge
var syncTimeout stdtime.Duration
syncDisabled := false
newUseNTS := timeServersStatus.TypedSpec().UseNTS
if ctrl.V1Alpha1Mode == v1alpha1runtime.ModeContainer {
syncDisabled = true
@ -212,9 +214,32 @@ func (ctrl *SyncController) Run(ctx context.Context, r controller.Runtime, logge
syncer = nil
syncCh = nil
epochCh = nil
case !syncDisabled && syncer != nil && newUseNTS != useNTS:
// NTS setting changed, restart the syncer
logger.Info("NTS setting changed, restarting syncer", zap.Bool("useNTS", newUseNTS))
syncCtxCancel()
syncWg.Wait()
useNTS = newUseNTS
syncer = ctrl.NewNTPSyncer(logger, timeServers, useNTS)
syncCh = syncer.Synced()
epochCh = syncer.EpochChange()
timeSynced = false
syncCtx, syncCtxCancel = context.WithCancel(ctx) //nolint:govet,fatcontext
syncWg.Go(func() {
syncer.Run(syncCtx)
})
case !syncDisabled && syncer == nil:
// start syncing
syncer = ctrl.NewNTPSyncer(logger, timeServers)
useNTS = newUseNTS
syncer = ctrl.NewNTPSyncer(logger, timeServers, useNTS)
syncCh = syncer.Synced()
epochCh = syncer.EpochChange()

View File

@ -445,6 +445,78 @@ func (suite *SyncSuite) TestReconcileSyncBootTimeout() {
)
}
func (suite *SyncSuite) TestReconcileSyncWithNTS() {
suite.Require().NoError(
suite.runtime.RegisterController(
&timectrl.SyncController{
V1Alpha1Mode: v1alpha1runtime.ModeMetal,
NewNTPSyncer: suite.newMockSyncer,
},
),
)
suite.startRuntime()
timeServers := network.NewTimeServerStatus(network.NamespaceName, network.TimeServerID)
timeServers.TypedSpec().NTPServers = []string{constants.DefaultNTPServer}
suite.Require().NoError(suite.state.Create(suite.ctx, timeServers))
suite.Assert().NoError(
retry.Constant(10*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
func() error {
return suite.assertTimeStatus(
timeresource.StatusSpec{
Synced: false,
Epoch: 0,
SyncDisabled: false,
},
)
},
),
)
var mockSyncer *mockSyncer
suite.Assert().NoError(
retry.Constant(10*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
func() error {
mockSyncer = suite.getMockSyncer()
if mockSyncer == nil {
return retry.ExpectedErrorf("syncer not created yet")
}
return nil
},
),
)
suite.Assert().False(mockSyncer.getUseNTS(), "syncer should start without NTS")
ctest.UpdateWithConflicts(suite, timeServers, func(r *network.TimeServerStatus) error {
r.TypedSpec().UseNTS = true
return nil
})
suite.Assert().NoError(
retry.Constant(10*time.Second, retry.WithUnits(100*time.Millisecond)).Retry(
func() error {
s := suite.getMockSyncer()
if s == nil {
return retry.ExpectedErrorf("syncer not created yet")
}
if !s.getUseNTS() {
return retry.ExpectedErrorf("syncer not yet recreated with NTS")
}
return nil
},
),
)
}
func (suite *SyncSuite) TearDownTest() {
suite.T().Log("tear down")
@ -453,11 +525,11 @@ func (suite *SyncSuite) TearDownTest() {
suite.wg.Wait()
}
func (suite *SyncSuite) newMockSyncer(logger *zap.Logger, servers []string) timectrl.NTPSyncer {
func (suite *SyncSuite) newMockSyncer(logger *zap.Logger, servers []string, useNTS bool) timectrl.NTPSyncer {
suite.syncerMu.Lock()
defer suite.syncerMu.Unlock()
suite.syncer = newMockSyncer(logger, servers)
suite.syncer = newMockSyncer(logger, servers, useNTS)
return suite.syncer
}
@ -477,6 +549,7 @@ type mockSyncer struct {
mu sync.Mutex
timeServers []string
useNTS bool
syncedCh chan struct{}
epochCh chan struct{}
}
@ -500,6 +573,13 @@ func (mock *mockSyncer) getTimeServers() (servers []string) {
return slices.Clone(mock.timeServers)
}
func (mock *mockSyncer) getUseNTS() bool {
mock.mu.Lock()
defer mock.mu.Unlock()
return mock.useNTS
}
func (mock *mockSyncer) SetTimeServers(servers []string) {
mock.mu.Lock()
defer mock.mu.Unlock()
@ -507,9 +587,10 @@ func (mock *mockSyncer) SetTimeServers(servers []string) {
mock.timeServers = slices.Clone(servers)
}
func newMockSyncer(_ *zap.Logger, servers []string) *mockSyncer {
func newMockSyncer(_ *zap.Logger, servers []string, useNTS bool) *mockSyncer {
return &mockSyncer{
timeServers: slices.Clone(servers),
useNTS: useNTS,
syncedCh: make(chan struct{}, 1),
epochCh: make(chan struct{}, 1),
}

View File

@ -25,3 +25,12 @@ type SetTimeFunc func(tv *syscall.Timeval) error
// AdjustTimeFunc provides a function to adjust time.
type AdjustTimeFunc func(buf *unix.Timex) (state timex.State, err error)
// NTSSession abstracts the beevik/nts Session for testability.
type NTSSession interface {
Query() (*ntp.Response, error)
}
// NTSNewSessionFunc creates an NTS session for a given server address.
// Defaults to nts.NewSession wrapper; injectable for testing.
type NTSNewSessionFunc func(address string) (NTSSession, error)

View File

@ -31,9 +31,11 @@ import (
type Syncer struct {
logger *zap.Logger
timeServersMu sync.Mutex
timeServers []string
lastSyncServer string
timeServersMu sync.Mutex
timeServers []string
lastSyncServer string
ntsSession NTSSession
ntsSessionServer string
timeSyncNotified bool
timeSynced chan struct{}
@ -52,6 +54,10 @@ type Syncer struct {
NTPQuery QueryFunc
AdjustTime AdjustTimeFunc
DisableRTC bool
// NTS (Network Time Security) support
UseNTS bool
NTSNewSession NTSNewSessionFunc
}
// Measurement is a struct containing correction data based on a time request.
@ -62,7 +68,7 @@ type Measurement struct {
}
// NewSyncer creates new Syncer with default configuration.
func NewSyncer(logger *zap.Logger, timeServers []string) *Syncer {
func NewSyncer(logger *zap.Logger, timeServers []string, useNTS bool) *Syncer {
syncer := &Syncer{
logger: logger,
@ -83,6 +89,9 @@ func NewSyncer(logger *zap.Logger, timeServers []string) *Syncer {
CurrentTime: time.Now,
NTPQuery: ntp.Query,
AdjustTime: timex.Adjtimex,
UseNTS: useNTS,
NTSNewSession: DefaultNTSNewSession,
}
return syncer
@ -309,9 +318,19 @@ func (syncer *Syncer) resolveServers(ctx context.Context) ([]string, error) {
var serverList []string
for _, server := range syncer.getTimeServers() {
if IsPTPDevice(server) {
switch {
case IsPTPDevice(server):
serverList = append(serverList, server)
} else {
case syncer.UseNTS:
// NTS requires hostnames for TLS SNI; skip raw IP addresses.
if net.ParseIP(server) != nil {
syncer.logger.Warn("skipping IP address for NTS (hostname required for TLS)", zap.String("server", server))
continue
}
serverList = append(serverList, server)
default:
ips, err := syncer.lookupIPAddrWithTimeout(ctx, server, 5*time.Second)
if err != nil {
syncer.logger.Error(fmt.Sprintf("failed looking up %q, ignored", server), zap.Error(err))
@ -344,6 +363,10 @@ func (syncer *Syncer) queryServer(server string) (*Measurement, error) {
return syncer.queryPTP(server)
}
if syncer.UseNTS {
return syncer.queryNTS(server)
}
return syncer.queryNTP(server)
}
@ -428,6 +451,87 @@ func (syncer *Syncer) queryNTP(server string) (*Measurement, error) {
}, nil
}
func (syncer *Syncer) queryNTS(server string) (*Measurement, error) {
session, err := syncer.getNTSSession(server)
if err != nil {
return nil, fmt.Errorf("NTS session for %s: %w", server, err)
}
resp, err := session.Query()
if err != nil {
// Session may be stale (expired cookies, TLS error); drop cache and retry once.
syncer.logger.Warn("NTS query failed, refreshing session", zap.String("server", server), zap.Error(err))
syncer.timeServersMu.Lock()
syncer.ntsSession = nil
syncer.ntsSessionServer = ""
syncer.timeServersMu.Unlock()
session, err = syncer.getNTSSession(server)
if err != nil {
return nil, fmt.Errorf("NTS session refresh for %s: %w", server, err)
}
resp, err = session.Query()
if err != nil {
return nil, fmt.Errorf("NTS query retry for %s: %w", server, err)
}
}
syncer.logger.Debug("NTS response",
zap.Duration("clock_offset", resp.ClockOffset),
zap.Duration("rtt", resp.RTT),
zap.Uint8("leap", uint8(resp.Leap)),
zap.Uint8("stratum", resp.Stratum),
zap.Duration("precision", resp.Precision),
zap.Duration("root_delay", resp.RootDelay),
zap.Duration("root_dispersion", resp.RootDispersion),
zap.Duration("root_distance", resp.RootDistance),
zap.String("server", server),
)
validationError := resp.Validate()
if validationError != nil {
return nil, validationError
}
return &Measurement{
ClockOffset: resp.ClockOffset,
Leap: resp.Leap,
Spike: syncer.isSpike(resp),
}, nil
}
func (syncer *Syncer) getNTSSession(server string) (NTSSession, error) {
syncer.timeServersMu.Lock()
if syncer.ntsSessionServer == server && syncer.ntsSession != nil {
session := syncer.ntsSession
syncer.timeServersMu.Unlock()
return session, nil
}
syncer.timeServersMu.Unlock()
if syncer.NTSNewSession == nil {
return nil, fmt.Errorf("NTS session factory not configured")
}
session, err := syncer.NTSNewSession(server)
if err != nil {
return nil, err
}
syncer.timeServersMu.Lock()
syncer.ntsSession = session
syncer.ntsSessionServer = server
syncer.timeServersMu.Unlock()
syncer.logger.Info("established NTS session", zap.String("server", server))
return session, nil
}
// log2i returns 0 for v == 0 and v == 1.
func log2i(v uint64) int {
if v == 0 {

View File

@ -9,11 +9,13 @@ import (
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
beevikntp "github.com/beevik/ntp"
"github.com/siderolabs/go-retry/retry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
@ -185,7 +187,7 @@ func (suite *NTPSuite) fakeQuery(host string) (resp *beevikntp.Response, err err
}
func (suite *NTPSuite) TestSync() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{constants.DefaultNTPServer})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{constants.DefaultNTPServer}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -212,7 +214,7 @@ func (suite *NTPSuite) TestSync() {
}
func (suite *NTPSuite) TestSyncContinuous() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.3"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.3"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -257,7 +259,7 @@ func (suite *NTPSuite) TestSyncContinuous() {
//nolint:dupl
func (suite *NTPSuite) TestSyncKissOfDeath() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.8"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.8"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -307,7 +309,7 @@ func (suite *NTPSuite) TestSyncKissOfDeath() {
//nolint:dupl
func (suite *NTPSuite) TestSyncWithSpikes() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.7"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.7"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -356,7 +358,7 @@ func (suite *NTPSuite) TestSyncWithSpikes() {
}
func (suite *NTPSuite) TestSyncChangeTimeservers() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.1"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.1"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -391,7 +393,7 @@ func (suite *NTPSuite) TestSyncChangeTimeservers() {
}
func (suite *NTPSuite) TestSyncIterateTimeservers() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.1", "127.0.0.2", "127.0.0.3", "127.0.0.4"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.1", "127.0.0.2", "127.0.0.3", "127.0.0.4"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -440,7 +442,7 @@ func (suite *NTPSuite) TestSyncIterateTimeservers() {
}
func (suite *NTPSuite) TestSyncEpochChange() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.5"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.5"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -476,7 +478,7 @@ func (suite *NTPSuite) TestSyncEpochChange() {
}
func (suite *NTPSuite) TestSyncSwitchTimeservers() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.6", "127.0.0.4"})
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"127.0.0.6", "127.0.0.4"}, false)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
@ -525,3 +527,230 @@ func (suite *NTPSuite) TestSyncSwitchTimeservers() {
suite.Assert().Equal(2*time.Millisecond, suite.clockAdjustments[i])
}
}
// mockNTSSession implements ntp.NTSSession for testing.
type mockNTSSession struct {
queryCount int
failFirst bool
resp *beevikntp.Response
err error
}
func (m *mockNTSSession) Query() (*beevikntp.Response, error) {
m.queryCount++
if m.failFirst && m.queryCount == 1 {
return nil, errors.New("NTS session expired")
}
return m.resp, m.err
}
func (suite *NTPSuite) TestNTSQueryBasic() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"time.cloudflare.com"}, true)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
syncer.DisableRTC = true
mockSession := &mockNTSSession{
resp: &beevikntp.Response{
Stratum: 1,
Time: suite.systemClock,
ReferenceTime: suite.systemClock,
ClockOffset: time.Millisecond,
RTT: time.Millisecond / 2,
},
}
syncer.NTSNewSession = func(address string) (ntp.NTSSession, error) {
suite.Assert().Equal("time.cloudflare.com", address)
return mockSession, nil
}
syncer.MinPoll = time.Second
syncer.MaxPoll = time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Go(func() {
syncer.Run(ctx)
})
select {
case <-syncer.Synced():
case <-time.After(10 * time.Second):
suite.Assert().Fail("NTS time sync timeout")
}
cancel()
wg.Wait()
// Session should have been queried at least once
suite.Assert().Greater(mockSession.queryCount, 0)
}
func (suite *NTPSuite) TestNTSQuerySessionRefresh() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"time.cloudflare.com"}, true)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
syncer.DisableRTC = true
sessionCreateCount := 0
goodResp := &beevikntp.Response{
Stratum: 1,
Time: suite.systemClock,
ReferenceTime: suite.systemClock,
ClockOffset: time.Millisecond,
RTT: time.Millisecond / 2,
}
syncer.NTSNewSession = func(address string) (ntp.NTSSession, error) {
sessionCreateCount++
if sessionCreateCount == 1 {
// First session fails on Query
return &mockNTSSession{failFirst: true, resp: goodResp}, nil
}
// Second session works
return &mockNTSSession{resp: goodResp}, nil
}
syncer.MinPoll = time.Second
syncer.MaxPoll = time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Go(func() {
syncer.Run(ctx)
})
select {
case <-syncer.Synced():
case <-time.After(10 * time.Second):
suite.Assert().Fail("NTS time sync timeout after session refresh")
}
cancel()
wg.Wait()
// Should have created at least 2 sessions (first failed, second succeeded)
suite.Assert().GreaterOrEqual(sessionCreateCount, 2)
}
func (suite *NTPSuite) TestNTSSkipsIPAddresses() {
// NTS with IP addresses should skip them (hostnames required for TLS)
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"192.168.1.1", "time.cloudflare.com"}, true)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
syncer.DisableRTC = true
queriedServer := ""
syncer.NTSNewSession = func(address string) (ntp.NTSSession, error) {
queriedServer = address
return &mockNTSSession{
resp: &beevikntp.Response{
Stratum: 1,
Time: suite.systemClock,
ReferenceTime: suite.systemClock,
ClockOffset: time.Millisecond,
RTT: time.Millisecond / 2,
},
}, nil
}
syncer.MinPoll = time.Second
syncer.MaxPoll = time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Go(func() {
syncer.Run(ctx)
})
select {
case <-syncer.Synced():
case <-time.After(10 * time.Second):
suite.Assert().Fail("NTS time sync timeout")
}
cancel()
wg.Wait()
// Should have used the hostname, not the IP
suite.Assert().Equal("time.cloudflare.com", queriedServer)
}
func (suite *NTPSuite) TestNTSSessionCacheCleanup() {
syncer := ntp.NewSyncer(zaptest.NewLogger(suite.T()).With(zap.String("controller", "ntp")), []string{"time.cloudflare.com"}, true)
syncer.AdjustTime = suite.adjustSystemClock
syncer.CurrentTime = suite.getSystemClock
syncer.DisableRTC = true
var sessionCreateCount atomic.Int32
goodResp := &beevikntp.Response{
Stratum: 1,
Time: suite.systemClock,
ReferenceTime: suite.systemClock,
ClockOffset: time.Millisecond,
RTT: time.Millisecond / 2,
}
syncer.NTSNewSession = func(address string) (ntp.NTSSession, error) {
sessionCreateCount.Add(1)
return &mockNTSSession{resp: goodResp}, nil
}
syncer.MinPoll = time.Second
syncer.MaxPoll = time.Second
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
wg.Go(func() {
syncer.Run(ctx)
})
select {
case <-syncer.Synced():
case <-time.After(10 * time.Second):
suite.Assert().Fail("NTS time sync timeout")
}
// Change time servers — old session for "time.cloudflare.com" should be evicted
syncer.SetTimeServers([]string{"time.google.com"})
// Wait for the new server's session to be created
suite.Assert().EventuallyWithT(func(collect *assert.CollectT) {
asrt := assert.New(collect)
asrt.Equal(sessionCreateCount.Load(), int32(2), "expected a new NTS session to be created after switching servers")
}, 10*time.Second, 100*time.Millisecond)
cancel()
wg.Wait()
}

25
internal/pkg/ntp/nts.go Normal file
View File

@ -0,0 +1,25 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package ntp
import (
"crypto/tls"
"github.com/beevik/nts"
"github.com/siderolabs/talos/pkg/httpdefaults"
)
// DefaultNTSNewSession creates a real NTS session using beevik/nts.
// This is the default NTSNewSessionFunc used in production.
func DefaultNTSNewSession(address string) (NTSSession, error) {
return nts.NewSessionWithOptions(address,
&nts.SessionOptions{
TLSConfig: &tls.Config{
RootCAs: httpdefaults.RootCAs(),
},
},
)
}

View File

@ -4461,6 +4461,7 @@ type TimeServerSpecSpec struct {
state protoimpl.MessageState `protogen:"open.v1"`
NtpServers []string `protobuf:"bytes,1,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"`
ConfigLayer enums.NetworkConfigLayer `protobuf:"varint,2,opt,name=config_layer,json=configLayer,proto3,enum=talos.resource.definitions.enums.NetworkConfigLayer" json:"config_layer,omitempty"`
UseNts bool `protobuf:"varint,3,opt,name=use_nts,json=useNts,proto3" json:"use_nts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4509,10 +4510,18 @@ func (x *TimeServerSpecSpec) GetConfigLayer() enums.NetworkConfigLayer {
return enums.NetworkConfigLayer(0)
}
func (x *TimeServerSpecSpec) GetUseNts() bool {
if x != nil {
return x.UseNts
}
return false
}
// TimeServerStatusSpec describes NTP servers.
type TimeServerStatusSpec struct {
state protoimpl.MessageState `protogen:"open.v1"`
NtpServers []string `protobuf:"bytes,1,rep,name=ntp_servers,json=ntpServers,proto3" json:"ntp_servers,omitempty"`
UseNts bool `protobuf:"varint,2,opt,name=use_nts,json=useNts,proto3" json:"use_nts,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4554,6 +4563,13 @@ func (x *TimeServerStatusSpec) GetNtpServers() []string {
return nil
}
func (x *TimeServerStatusSpec) GetUseNts() bool {
if x != nil {
return x.UseNts
}
return false
}
// VIPEquinixMetalSpec describes virtual (elastic) IP settings for Equinix Metal.
type VIPEquinixMetalSpec struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -5458,14 +5474,16 @@ const file_resource_definitions_network_network_proto_rawDesc = "" +
"\x0fetc_files_ready\x18\x04 \x01(\bR\retcFilesReady\"_\n" +
"\fTCPProbeSpec\x12\x1a\n" +
"\bendpoint\x18\x01 \x01(\tR\bendpoint\x123\n" +
"\atimeout\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x8e\x01\n" +
"\atimeout\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\xa7\x01\n" +
"\x12TimeServerSpecSpec\x12\x1f\n" +
"\vntp_servers\x18\x01 \x03(\tR\n" +
"ntpServers\x12W\n" +
"\fconfig_layer\x18\x02 \x01(\x0e24.talos.resource.definitions.enums.NetworkConfigLayerR\vconfigLayer\"7\n" +
"\fconfig_layer\x18\x02 \x01(\x0e24.talos.resource.definitions.enums.NetworkConfigLayerR\vconfigLayer\x12\x17\n" +
"\ause_nts\x18\x03 \x01(\bR\x06useNts\"P\n" +
"\x14TimeServerStatusSpec\x12\x1f\n" +
"\vntp_servers\x18\x01 \x03(\tR\n" +
"ntpServers\"n\n" +
"ntpServers\x12\x17\n" +
"\ause_nts\x18\x02 \x01(\bR\x06useNts\"n\n" +
"\x13VIPEquinixMetalSpec\x12\x1d\n" +
"\n" +
"project_id\x18\x01 \x01(\tR\tprojectId\x12\x1b\n" +

View File

@ -4538,6 +4538,16 @@ func (m *TimeServerSpecSpec) MarshalToSizedBufferVT(dAtA []byte) (int, error) {
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.UseNts {
i--
if m.UseNts {
dAtA[i] = 1
} else {
dAtA[i] = 0
}
i--
dAtA[i] = 0x18
}
if m.ConfigLayer != 0 {
i = protohelpers.EncodeVarint(dAtA, i, uint64(m.ConfigLayer))
i--
@ -4585,6 +4595,16 @@ func (m *TimeServerStatusSpec) MarshalToSizedBufferVT(dAtA []byte) (int, error)
i -= len(m.unknownFields)
copy(dAtA[i:], m.unknownFields)
}
if m.UseNts {
i--
if m.UseNts {
dAtA[i] = 1
} else {
dAtA[i] = 0
}
i--
dAtA[i] = 0x10
}
if len(m.NtpServers) > 0 {
for iNdEx := len(m.NtpServers) - 1; iNdEx >= 0; iNdEx-- {
i -= len(m.NtpServers[iNdEx])
@ -6907,6 +6927,9 @@ func (m *TimeServerSpecSpec) SizeVT() (n int) {
if m.ConfigLayer != 0 {
n += 1 + protohelpers.SizeOfVarint(uint64(m.ConfigLayer))
}
if m.UseNts {
n += 2
}
n += len(m.unknownFields)
return n
}
@ -6923,6 +6946,9 @@ func (m *TimeServerStatusSpec) SizeVT() (n int) {
n += 1 + l + protohelpers.SizeOfVarint(uint64(l))
}
}
if m.UseNts {
n += 2
}
n += len(m.unknownFields)
return n
}
@ -18227,6 +18253,26 @@ func (m *TimeServerSpecSpec) UnmarshalVT(dAtA []byte) error {
break
}
}
case 3:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field UseNts", wireType)
}
var v int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.UseNts = bool(v != 0)
default:
iNdEx = preIndex
skippy, err := protohelpers.Skip(dAtA[iNdEx:])
@ -18310,6 +18356,26 @@ func (m *TimeServerStatusSpec) UnmarshalVT(dAtA []byte) error {
}
m.NtpServers = append(m.NtpServers, string(dAtA[iNdEx:postIndex]))
iNdEx = postIndex
case 2:
if wireType != 0 {
return fmt.Errorf("proto: wrong wireType = %d for field UseNts", wireType)
}
var v int
for shift := uint(0); ; shift += 7 {
if shift >= 64 {
return protohelpers.ErrIntOverflow
}
if iNdEx >= l {
return io.ErrUnexpectedEOF
}
b := dAtA[iNdEx]
iNdEx++
v |= int(b&0x7F) << shift
if b < 0x80 {
break
}
}
m.UseNts = bool(v != 0)
default:
iNdEx = preIndex
skippy, err := protohelpers.Skip(dAtA[iNdEx:])

View File

@ -125,6 +125,7 @@ type NetworkTimeSyncConfig interface {
Disabled() bool
Servers() []string
BootTimeout() time.Duration
UseNTS() bool
}
// NetworkPhysicalLinkConfig defines a physical network link configuration.

View File

@ -2621,9 +2621,16 @@
},
"type": "array",
"title": "servers",
"description": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to time.cloudflare.com.\n",
"markdownDescription": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com`.",
"x-intellij-html-description": "\u003cp\u003eSpecifies time (NTP) servers to use for setting the system time.\nDefaults to \u003ccode\u003etime.cloudflare.com\u003c/code\u003e.\u003c/p\u003e\n"
"description": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to time.cloudflare.com when configuration is not provided.\n",
"markdownDescription": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com` when configuration is not provided.",
"x-intellij-html-description": "\u003cp\u003eSpecifies time (NTP) servers to use for setting the system time.\nDefaults to \u003ccode\u003etime.cloudflare.com\u003c/code\u003e when configuration is not provided.\u003c/p\u003e\n"
},
"useNTS": {
"type": "boolean",
"title": "useNTS",
"description": "Enables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to true when configuration is not provided, using the system default server (time.cloudflare.com).\n",
"markdownDescription": "Enables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to `true` when configuration is not provided, using the system default server (`time.cloudflare.com`).",
"x-intellij-html-description": "\u003cp\u003eEnables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to \u003ccode\u003etrue\u003c/code\u003e when configuration is not provided, using the system default server (\u003ccode\u003etime.cloudflare.com\u003c/code\u003e).\u003c/p\u003e\n"
}
},
"additionalProperties": false,

View File

@ -568,6 +568,10 @@ func (o *TimeSyncConfigV1Alpha1) DeepCopy() *TimeSyncConfigV1Alpha1 {
cp.TimeNTP.Servers = make([]string, len(o.TimeNTP.Servers))
copy(cp.TimeNTP.Servers, o.TimeNTP.Servers)
}
if o.TimeNTP.UseNTS != nil {
cp.TimeNTP.UseNTS = new(bool)
*cp.TimeNTP.UseNTS = *o.TimeNTP.UseNTS
}
}
if o.TimePTP != nil {
cp.TimePTP = new(PTPConfig)

View File

@ -1909,9 +1909,16 @@ func (NTPConfig) Doc() *encoder.Doc {
Name: "servers",
Type: "[]string",
Note: "",
Description: "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com`.",
Description: "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com` when configuration is not provided.",
Comments: [3]string{"" /* encoder.HeadComment */, "Specifies time (NTP) servers to use for setting the system time." /* encoder.LineComment */, "" /* encoder.FootComment */},
},
{
Name: "useNTS",
Type: "bool",
Note: "",
Description: "Enables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to `true` when configuration is not provided, using the system default server (`time.cloudflare.com`).",
Comments: [3]string{"" /* encoder.HeadComment */, "Enables NTS (Network Time Security) for NTP queries." /* encoder.LineComment */, "" /* encoder.FootComment */},
},
},
}

View File

@ -0,0 +1,6 @@
apiVersion: v1alpha1
kind: TimeSyncConfig
ntp:
servers:
- time.cloudflare.com
useNTS: true

View File

@ -8,8 +8,12 @@ package network
import (
"errors"
"fmt"
"net"
"time"
"github.com/siderolabs/go-pointer"
"github.com/siderolabs/talos/pkg/machinery/config/config"
"github.com/siderolabs/talos/pkg/machinery/config/container"
"github.com/siderolabs/talos/pkg/machinery/config/internal/registry"
@ -76,8 +80,14 @@ type TimeSyncConfigV1Alpha1 struct {
type NTPConfig struct {
// description: |
// Specifies time (NTP) servers to use for setting the system time.
// Defaults to `time.cloudflare.com`.
// Defaults to `time.cloudflare.com` when configuration is not provided.
Servers []string `yaml:"servers,omitempty"`
// description: |
// Enables NTS (Network Time Security) for NTP queries.
// NTS provides authenticated and encrypted time synchronization using TLS.
// When enabled, all NTP capable servers must be specified as hostnames (not IP addresses).
// Defaults to `true` when configuration is not provided, using the system default server (`time.cloudflare.com`).
UseNTS *bool `yaml:"useNTS,omitempty"`
}
// PTPConfig represents a PTP (Precision Time Protocol) configuration.
@ -136,6 +146,14 @@ func (s *TimeSyncConfigV1Alpha1) Validate(validation.RuntimeMode, ...validation.
errs = errors.Join(errs, errors.New("only one of ntp or ptp configuration can be specified"))
}
if s.TimeNTP != nil && s.TimeNTP.UseNTS != nil && *s.TimeNTP.UseNTS {
for _, server := range s.TimeNTP.Servers {
if net.ParseIP(server) != nil {
errs = errors.Join(errs, fmt.Errorf("NTS requires hostnames, not IP addresses: %q", server))
}
}
}
return nil, errs
}
@ -189,3 +207,8 @@ func (s *TimeSyncConfigV1Alpha1) Servers() []string {
return nil
}
// UseNTS implements config.NetworkTimeSyncConfig interface.
func (s *TimeSyncConfigV1Alpha1) UseNTS() bool {
return pointer.SafeDeref(pointer.SafeDeref(s.TimeNTP).UseNTS)
}

View File

@ -22,6 +22,9 @@ import (
//go:embed testdata/timesyncconfig.yaml
var expectedTimeSyncConfigDocument []byte
//go:embed testdata/timesyncconfig_nts.yaml
var expectedTimeSyncConfigNTSDocument []byte
func TestTimeSyncConfigMarshalStability(t *testing.T) {
t.Parallel()
@ -40,6 +43,23 @@ func TestTimeSyncConfigMarshalStability(t *testing.T) {
assert.Equal(t, expectedTimeSyncConfigDocument, marshaled)
}
func TestTimeSyncConfigMarshalStabilityNTS(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"time.cloudflare.com"},
UseNTS: new(true),
}
marshaled, err := encoder.NewEncoder(cfg, encoder.WithComments(encoder.CommentsDisabled)).Encode()
require.NoError(t, err)
t.Log(string(marshaled))
assert.Equal(t, expectedTimeSyncConfigNTSDocument, marshaled)
}
func TestTimeSyncConfigUnmarshal(t *testing.T) {
t.Parallel()
@ -62,6 +82,22 @@ func TestTimeSyncConfigUnmarshal(t *testing.T) {
}, docs[0])
}
func TestTimeSyncConfigUnmarshalNTS(t *testing.T) {
t.Parallel()
provider, err := configloader.NewFromBytes(expectedTimeSyncConfigNTSDocument)
require.NoError(t, err)
docs := provider.Documents()
require.Len(t, docs, 1)
cfg, ok := docs[0].(*network.TimeSyncConfigV1Alpha1)
require.True(t, ok)
assert.True(t, cfg.UseNTS())
assert.Equal(t, []string{"time.cloudflare.com"}, cfg.Servers())
}
func TestTimeSyncValidate(t *testing.T) {
t.Parallel()
@ -113,6 +149,31 @@ func TestTimeSyncValidate(t *testing.T) {
return cfg
},
},
{
name: "valid NTP config with NTS",
cfg: func() *network.TimeSyncConfigV1Alpha1 {
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"time.cloudflare.com"},
UseNTS: new(true),
}
return cfg
},
},
{
name: "NTS with IP address is invalid",
cfg: func() *network.TimeSyncConfigV1Alpha1 {
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"192.0.2.1"},
UseNTS: new(true),
}
return cfg
},
expectedError: `NTS requires hostnames, not IP addresses: "192.0.2.1"`,
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
@ -197,3 +258,60 @@ func TestTimeSyncV1Alpha1Validate(t *testing.T) {
})
}
}
func TestTimeSyncUseNTS(t *testing.T) {
t.Parallel()
t.Run("nil NTP config returns false", func(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
assert.False(t, cfg.UseNTS())
})
t.Run("PTP config returns false", func(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimePTP = &network.PTPConfig{
Devices: []string{"/dev/ptp0"},
}
assert.False(t, cfg.UseNTS())
})
t.Run("NTP config without NTS returns false", func(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"pool.ntp.org"},
}
assert.False(t, cfg.UseNTS())
})
t.Run("NTP config with NTS returns true", func(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"time.cloudflare.com"},
UseNTS: new(true),
}
assert.True(t, cfg.UseNTS())
})
t.Run("NTP config with NTS explicitly false returns false", func(t *testing.T) {
t.Parallel()
cfg := network.NewTimeSyncConfigV1Alpha1()
cfg.TimeNTP = &network.NTPConfig{
Servers: []string{"pool.ntp.org"},
UseNTS: new(false),
}
assert.False(t, cfg.UseNTS())
})
}

View File

@ -1227,6 +1227,13 @@ func (t *TimeConfig) BootTimeout() time.Duration {
return t.TimeBootTimeout
}
// UseNTS implements the config.Provider interface.
//
// Deprecated v1alpha1 TimeConfig does not support NTS.
func (t *TimeConfig) UseNTS() bool {
return false
}
// Image implements the config.Provider interface.
func (i *InstallConfig) Image() string {
return i.InstallImage

View File

@ -27,6 +27,7 @@ const TimeServerID resource.ID = "timeservers"
//gotagsrewrite:gen
type TimeServerSpecSpec struct {
NTPServers []string `yaml:"timeServers" protobuf:"1"`
UseNTS bool `yaml:"useNTS,omitempty" protobuf:"3"`
ConfigLayer ConfigLayer `yaml:"layer" protobuf:"2"`
}
@ -47,7 +48,16 @@ func (TimeServerSpecExtension) ResourceDefinition() meta.ResourceDefinitionSpec
Type: TimeServerSpecType,
Aliases: []resource.Type{},
DefaultNamespace: NamespaceName,
PrintColumns: []meta.PrintColumn{},
PrintColumns: []meta.PrintColumn{
{
Name: "Timeservers",
JSONPath: "{.timeServers}",
},
{
Name: "UseNTS",
JSONPath: "{.useNTS}",
},
},
}
}

View File

@ -24,6 +24,7 @@ type TimeServerStatus = typed.Resource[TimeServerStatusSpec, TimeServerStatusExt
//gotagsrewrite:gen
type TimeServerStatusSpec struct {
NTPServers []string `yaml:"timeServers" protobuf:"1"`
UseNTS bool `yaml:"useNTS" protobuf:"2"`
}
// NewTimeServerStatus initializes a TimeServerStatus resource.
@ -48,6 +49,10 @@ func (TimeServerStatusExtension) ResourceDefinition() meta.ResourceDefinitionSpe
Name: "Timeservers",
JSONPath: "{.timeServers}",
},
{
Name: "UseNTS",
JSONPath: "{.useNTS}",
},
},
}
}

View File

@ -10073,6 +10073,7 @@ TimeServerSpecSpec describes NTP servers.
| ----- | ---- | ----- | ----------- |
| ntp_servers | [string](#string) | repeated | |
| config_layer | [talos.resource.definitions.enums.NetworkConfigLayer](#talos.resource.definitions.enums.NetworkConfigLayer) | | |
| use_nts | [bool](#bool) | | |
@ -10088,6 +10089,7 @@ TimeServerStatusSpec describes NTP servers.
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| ntp_servers | [string](#string) | repeated | |
| use_nts | [bool](#bool) | | |

View File

@ -53,7 +53,8 @@ NTPConfig represents a NTP server configuration.
| Field | Type | Description | Value(s) |
|-------|------|-------------|----------|
|`servers` |[]string |Specifies time (NTP) servers to use for setting the system time.<br>Defaults to `time.cloudflare.com`. | |
|`servers` |[]string |Specifies time (NTP) servers to use for setting the system time.<br>Defaults to `time.cloudflare.com` when configuration is not provided. | |
|`useNTS` |bool |Enables NTS (Network Time Security) for NTP queries.<br>NTS provides authenticated and encrypted time synchronization using TLS.<br>When enabled, all NTP capable servers must be specified as hostnames (not IP addresses).<br>Defaults to `true` when configuration is not provided, using the system default server (`time.cloudflare.com`). | |

View File

@ -2621,9 +2621,16 @@
},
"type": "array",
"title": "servers",
"description": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to time.cloudflare.com.\n",
"markdownDescription": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com`.",
"x-intellij-html-description": "\u003cp\u003eSpecifies time (NTP) servers to use for setting the system time.\nDefaults to \u003ccode\u003etime.cloudflare.com\u003c/code\u003e.\u003c/p\u003e\n"
"description": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to time.cloudflare.com when configuration is not provided.\n",
"markdownDescription": "Specifies time (NTP) servers to use for setting the system time.\nDefaults to `time.cloudflare.com` when configuration is not provided.",
"x-intellij-html-description": "\u003cp\u003eSpecifies time (NTP) servers to use for setting the system time.\nDefaults to \u003ccode\u003etime.cloudflare.com\u003c/code\u003e when configuration is not provided.\u003c/p\u003e\n"
},
"useNTS": {
"type": "boolean",
"title": "useNTS",
"description": "Enables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to true when configuration is not provided, using the system default server (time.cloudflare.com).\n",
"markdownDescription": "Enables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to `true` when configuration is not provided, using the system default server (`time.cloudflare.com`).",
"x-intellij-html-description": "\u003cp\u003eEnables NTS (Network Time Security) for NTP queries.\nNTS provides authenticated and encrypted time synchronization using TLS.\nWhen enabled, all NTP capable servers must be specified as hostnames (not IP addresses).\nDefaults to \u003ccode\u003etrue\u003c/code\u003e when configuration is not provided, using the system default server (\u003ccode\u003etime.cloudflare.com\u003c/code\u003e).\u003c/p\u003e\n"
}
},
"additionalProperties": false,