From 926fcf202a3bf32545834c5cdd978bc22d698b25 Mon Sep 17 00:00:00 2001
From: Marc Brooks <IDisposable@gmail.com>
Date: Wed, 18 Jun 2025 20:27:36 -0500
Subject: [PATCH] Add support for using DHCP-provided NTP server

---
 internal/confparser/confparser_test.go |  4 +--
 internal/network/config.go             |  6 ++--
 internal/network/netif.go              | 44 +++++++++++++++++++++++++-
 internal/timesync/timesync.go          | 36 ++++++++++++++-------
 network.go                             |  8 +++++
 5 files changed, 81 insertions(+), 17 deletions(-)

diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go
index 166cdd7..e14a1ea 100644
--- a/internal/confparser/confparser_test.go
+++ b/internal/confparser/confparser_test.go
@@ -46,8 +46,8 @@ type testNetworkConfig struct {
 	TimeSyncOrdering        []string    `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
 	TimeSyncDisableFallback null.Bool   `json:"time_sync_disable_fallback,omitempty" default:"false"`
 	TimeSyncParallel        null.Int    `json:"time_sync_parallel,omitempty" default:"4"`
-	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_custom"`
-	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_custom"`
+	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
+	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
 }
 
 func TestValidateConfig(t *testing.T) {
diff --git a/internal/network/config.go b/internal/network/config.go
index 34d412e..c8fe582 100644
--- a/internal/network/config.go
+++ b/internal/network/config.go
@@ -45,11 +45,11 @@ type NetworkConfig struct {
 	LLDPTxTLVs              []string    `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
 	MDNSMode                null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
 	TimeSyncMode            null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
-	TimeSyncOrdering        []string    `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_custom,http_custom" default:"ntp,http"`
+	TimeSyncOrdering        []string    `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
 	TimeSyncDisableFallback null.Bool   `json:"time_sync_disable_fallback,omitempty" default:"false"`
 	TimeSyncParallel        null.Int    `json:"time_sync_parallel,omitempty" default:"4"`
-	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_custom"`
-	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_custom"`
+	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
+	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
 }
 
 func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
diff --git a/internal/network/netif.go b/internal/network/netif.go
index c5db806..5a8dab6 100644
--- a/internal/network/netif.go
+++ b/internal/network/netif.go
@@ -21,6 +21,7 @@ type NetworkInterfaceState struct {
 	ipv6Addr      *net.IP
 	ipv6Addresses []IPv6Address
 	ipv6LinkLocal *net.IP
+	ntpAddresses  []*net.IP
 	macAddr       *net.HardwareAddr
 
 	l         *zerolog.Logger
@@ -76,6 +77,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
 		onInitialCheck:  opts.OnInitialCheck,
 		cbConfigChange:  opts.OnConfigChange,
 		config:          opts.NetworkConfig,
+		ntpAddresses:    make([]*net.IP, 0),
 	}
 
 	// create the dhcp client
@@ -89,7 +91,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
 				opts.Logger.Error().Err(err).Msg("failed to update network state")
 				return
 			}
-
+			_ = s.updateNtpServersFromLease(lease)
 			_ = s.setHostnameIfNotSame()
 
 			opts.OnDhcpLeaseChange(lease)
@@ -135,6 +137,27 @@ func (s *NetworkInterfaceState) IPv6String() string {
 	return s.ipv6Addr.String()
 }
 
+func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
+	return s.ntpAddresses
+}
+
+func (s *NetworkInterfaceState) NtpAddressesString() []string {
+	ntpServers := []string{}
+
+	if s != nil {
+		s.l.Debug().Any("s", s).Msg("getting NTP address strings")
+
+		if len(s.ntpAddresses) > 0 {
+			for _, server := range s.ntpAddresses {
+				s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
+				ntpServers = append(ntpServers, server.String())
+			}
+		}
+	}
+
+	return ntpServers
+}
+
 func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
 	return s.macAddr
 }
@@ -318,6 +341,25 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
 	return dhcpTargetState, nil
 }
 
+func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
+	if lease != nil && len(lease.NTPServers) > 0 {
+		s.l.Info().Msg("lease found, updating DHCP NTP addresses")
+		s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
+
+		for _, ntpServer := range lease.NTPServers {
+			if ntpServer != nil {
+				s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
+				s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
+			}
+		}
+	} else {
+		s.l.Info().Msg("no NTP servers found in lease")
+		s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
+	}
+
+	return nil
+}
+
 func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
 	dhcpTargetState, err := s.update()
 	if err != nil {
diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go
index 64bfcd1..db1c96e 100644
--- a/internal/timesync/timesync.go
+++ b/internal/timesync/timesync.go
@@ -28,7 +28,8 @@ type TimeSync struct {
 	syncLock *sync.Mutex
 	l        *zerolog.Logger
 
-	networkConfig *network.NetworkConfig
+	networkConfig    *network.NetworkConfig
+	dhcpNtpAddresses []string
 
 	rtcDevicePath string
 	rtcDevice     *os.File //nolint:unused
@@ -62,12 +63,13 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
 	}
 
 	t := &TimeSync{
-		syncLock:      &sync.Mutex{},
-		l:             opts.Logger,
-		rtcDevicePath: rtcDevice,
-		rtcLock:       &sync.Mutex{},
-		preCheckFunc:  opts.PreCheckFunc,
-		networkConfig: opts.NetworkConfig,
+		syncLock:         &sync.Mutex{},
+		l:                opts.Logger,
+		dhcpNtpAddresses: []string{},
+		rtcDevicePath:    rtcDevice,
+		rtcLock:          &sync.Mutex{},
+		preCheckFunc:     opts.PreCheckFunc,
+		networkConfig:    opts.NetworkConfig,
 	}
 
 	if t.rtcDevicePath != "" {
@@ -78,11 +80,15 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
 	return t
 }
 
+func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
+	t.dhcpNtpAddresses = addresses
+}
+
 func (t *TimeSync) getSyncMode() SyncMode {
 	syncMode := SyncMode{
 		Ntp:             true,
 		Http:            true,
-		Ordering:        []string{"ntp_dhcp", "ntp_fallback", "http_fallback"},
+		Ordering:        []string{"ntp_dhcp", "ntp", "http"},
 		NtpUseFallback:  true,
 		HttpUseFallback: true,
 	}
@@ -161,7 +167,7 @@ func (t *TimeSync) Sync() error {
 Orders:
 	for _, mode := range syncMode.Ordering {
 		switch mode {
-		case "ntp_custom":
+		case "ntp_user_provided":
 			if syncMode.Ntp {
 				t.l.Info().Msg("using NTP custom servers")
 				now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
@@ -170,7 +176,15 @@ Orders:
 					break Orders
 				}
 			}
-		case "ntp_fallback":
+		case "ntp_dhcp":
+			if syncMode.Ntp {
+				t.l.Info().Msg("using NTP servers from DHCP")
+				now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
+				if now != nil {
+					t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
+					break Orders
+				}
+			}
 		case "ntp":
 			if syncMode.Ntp && syncMode.NtpUseFallback {
 				t.l.Info().Msg("using NTP fallback")
@@ -180,7 +194,7 @@ Orders:
 					break Orders
 				}
 			}
-		case "http_custom":
+		case "http_user_provided":
 			if syncMode.Http {
 				t.l.Info().Msg("using HTTP custom URLs")
 				now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
diff --git a/network.go b/network.go
index 2208a47..211b860 100644
--- a/network.go
+++ b/network.go
@@ -19,6 +19,14 @@ func networkStateChanged() {
 	// do not block the main thread
 	go waitCtrlAndRequestDisplayUpdate(true)
 
+	if timeSync != nil {
+		if networkState != nil {
+			timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
+		}
+
+		timeSync.Sync()
+	}
+
 	// always restart mDNS when the network state changes
 	if mDNS != nil {
 		_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())