From 62c2340dd5847e38c8d1db45c2e17e59c1ecf86e Mon Sep 17 00:00:00 2001 From: Frank Hunleth Date: Sun, 12 Aug 2018 10:23:39 -0400 Subject: [PATCH] Add runtime server configuration Changing the list of servers restarts `ntpd`. This also adds support for supplying an empty server list to disable `ntpd` until the servers are known. --- lib/nerves_time.ex | 58 ++++++++++++++++++++-- lib/nerves_time/ntpd.ex | 104 ++++++++++++++++++++++++++++++---------- 2 files changed, 131 insertions(+), 31 deletions(-) diff --git a/lib/nerves_time.ex b/lib/nerves_time.ex index 674d50b..3a15b15 100644 --- a/lib/nerves_time.ex +++ b/lib/nerves_time.ex @@ -3,16 +3,64 @@ defmodule Nerves.Time do `Nerves.Time` keeps the system clock on [Nerves](http://nerves-project.org) devices in sync when connected to the network and close to in sync when disconnected. It's especially useful for devices lacking a [Battery-backed - real-time clock](https://en.wikipedia.org/wiki/Real-time_clock) and will advance - the clock at startup to a reasonable guess. + real-time clock](https://en.wikipedia.org/wiki/Real-time_clock) and will + advance the clock at startup to a reasonable guess. """ @doc """ Check whether NTP is synchronized with the configured NTP servers. - It's possible that the time is already set correctly when this - returns false. Nerves.Time decides that NTP is synchronized when - ntpd sends a notification that the device's clock stratum is 4 or less. + It's possible that the time is already set correctly when this returns false. + `Nerves.Time` decides that NTP is synchronized when `ntpd` sends a + notification that the device's clock stratum is 4 or less. Clock adjustments + occur before this, though. """ + @spec synchronized?() :: boolean() defdelegate synchronized?, to: Nerves.Time.Ntpd + + @doc """ + Set the list of NTP servers. + + Use this function to replace the list of NTP servers that are queried for + time. It is also possible to set this list in your `config.exs` by doing + something like the following: + + ```elixir + config :nerves_time, :servers, [ + "0.pool.ntp.org", + "1.pool.ntp.org", + "2.pool.ntp.org", + "3.pool.ntp.org" + ] + ``` + + `Nerves.Time` uses [NTP Pool](https://www.ntppool.org/en/) by default. To + disable this and configure servers solely at runtime, specify an empty list + in `config.exs`: + + ```elixir + config :nerves_time, :servers, [] + ``` + """ + @spec set_ntp_servers([String.t()]) :: :ok + defdelegate set_ntp_servers(servers), to: Nerves.Time.Ntpd + + @doc """ + Return the current NTP servers. + """ + @spec ntp_servers() :: [String.t()] | {:error, term()} + defdelegate ntp_servers(), to: Nerves.Time.Ntpd + + @doc """ + Manually restart the NTP daemon. + + This is normally not necessary since `Nerves.Time` handles restarting it + automatically. An example of a reason to call this function is if you know + when the Internet becomes available. For this case, calling `restart_ntp` + will cancel `ntpd`'s internal timeouts and cause it to immediately send time + requests. If using NTP Pool, be sure not to violate its terms of service by + calling this function too frequently. + """ + @spec restart_ntpd() :: :ok | {:error, term()} + defdelegate restart_ntpd(), to: Nerves.Time.Ntpd end diff --git a/lib/nerves_time/ntpd.ex b/lib/nerves_time/ntpd.ex index f0cdfe7..8949cb1 100644 --- a/lib/nerves_time/ntpd.ex +++ b/lib/nerves_time/ntpd.ex @@ -24,21 +24,57 @@ defmodule Nerves.Time.Ntpd do GenServer.start_link(__MODULE__, [], name: __MODULE__) end - @spec synchronized?() :: true | false + @spec synchronized?() :: boolean() def synchronized?() do GenServer.call(__MODULE__, :synchronized?) end + @spec set_ntp_servers([String.t()]) :: :ok + def set_ntp_servers(servers) when is_list(servers) do + GenServer.call(__MODULE__, {:set_ntp_servers, servers}) + end + + @spec ntp_servers() :: [String.t()] | {:error, term()} + def ntp_servers() do + GenServer.call(__MODULE__, :ntp_servers) + end + + @spec restart_ntpd() :: :ok | {:error, term()} + def restart_ntpd() do + GenServer.call(__MODULE__, :restart_ntpd) + end + @spec init(any()) :: {:ok, any()} def init(_args) do - state = %State{port: run_ntpd()} - {:ok, state} + {:ok, do_restart_ntpd(%State{})} end def handle_call(:synchronized?, _from, state) do {:reply, state.synchronized, state} end + def handle_call({:set_ntp_servers, servers}, _from, state) do + Application.put_env(:nerves_time, :servers, servers) + new_state = do_restart_ntpd(state) + {:reply, :ok, new_state} + end + + def handle_call(:restart_ntpd, _from, state) do + new_state = do_restart_ntpd(state) + + result = + case new_state.port do + nil -> {:error, "No NTP servers defined"} + _ -> :ok + end + + {:reply, result, new_state} + end + + def handle_call(:ntp_servers, _from, state) do + {:reply, get_ntp_servers(), state} + end + def handle_info({_, {:exit_status, code}}, state) do Logger.error("ntpd exited with code: #{code}!") {:stop, :ntpd_died, state} @@ -50,26 +86,8 @@ defmodule Nerves.Time.Ntpd do |> handle_ntpd(state) end - defp run_ntpd() do - ntpd_path = Application.get_env(:nerves_time, :ntpd, @default_ntpd_path) - servers = Application.get_env(:nerves_time, :servers, @default_ntp_servers) - ntpd_script_path = Application.app_dir(:nerves_time, "priv/ntpd_script") - - args = [ntpd_path, "-n", "-d", "-S", ntpd_script_path] ++ server_args(servers) - - Logger.debug("Running ntp as: #{inspect(args)}") - - # Call ntpd using muontrap. Muontrap will kill ntpd if this GenServer - # crashes. - - Port.open({:spawn_executable, MuonTrap.muontrap_path()}, [ - {:args, ["--" | args]}, - :exit_status, - :use_stdio, - :binary, - {:line, 2048}, - :stderr_to_stdout - ]) + defp get_ntp_servers() do + Application.get_env(:nerves_time, :servers, @default_ntp_servers) end defp server_args(servers) do @@ -87,14 +105,13 @@ defmodule Nerves.Time.Ntpd do end defp handle_ntpd({:unsync, _result}, state) do - Logger.error("ntpd reports that it is unsynchronized; relaunching") + Logger.error("ntpd reports that it is unsynchronized; restarting") # According to the Busybox ntpd docs, if you get an `unsync` notification, then # you should restart ntpd to be safe. This is stated to be due to name resolution # only being done at initialization. - Port.close(state.port) + new_state = do_restart_ntpd(state) - new_state = %{state | port: run_ntpd(), synchronized: false} {:noreply, new_state} end @@ -108,6 +125,41 @@ defmodule Nerves.Time.Ntpd do {:noreply, state} end + defp do_restart_ntpd(state) do + if is_port(state.port) do + Port.close(state.port) + end + + ntpd = maybe_start_ntpd(get_ntp_servers()) + + %{state | port: ntpd, synchronized: false} + end + + defp maybe_start_ntpd([]) do + Logger.debug("Not starting ntpd since no NTP servers.") + nil + end + + defp maybe_start_ntpd(servers) do + ntpd_path = Application.get_env(:nerves_time, :ntpd, @default_ntpd_path) + ntpd_script_path = Application.app_dir(:nerves_time, "priv/ntpd_script") + + args = [ntpd_path, "-n", "-d", "-S", ntpd_script_path] ++ server_args(servers) + + Logger.debug("Starting ntpd as: #{inspect(args)}") + + # Call ntpd using muontrap. Muontrap will kill ntpd if this GenServer + # crashes. + Port.open({:spawn_executable, MuonTrap.muontrap_path()}, [ + {:args, ["--" | args]}, + :exit_status, + :use_stdio, + :binary, + {:line, 2048}, + :stderr_to_stdout + ]) + end + defp maybe_update_clock(%{stratum: stratum}) when stratum <= 4 do # Update the time assuming that we're getting time from a decent clock.