src/sntp.erl

%%%------------------------------------------------------------------------
%%% @doc Implements SNTP query logic.  
%%%      SNTP - Simple Network Time Protocol (RFC-2030).
%%%
%%% @author  Serge Aleynikov <saleyn@gmail.com>
%%% @end
%%%------------------------------------------------------------------------
%%% Created 2006-07-15
%%%------------------------------------------------------------------------
-module(sntp).
-author('saleyn@gmail.com').

%% External API
-export([time/1, time_servers/0, time_servers/1, avg_time/0, avg_time/1]).

-include("sntp.hrl").
-include_lib("kernel/include/inet.hrl").

%%%------------------------------------------------------------------------
%%% API
%%%------------------------------------------------------------------------

%%-------------------------------------------------------------------------
%% @doc Return a list of default NTP time servers for this host.
%% @end
%%-------------------------------------------------------------------------
-spec time_servers() -> [ inet:ip_address() ].
time_servers() ->
    time_servers(true).
    
%%-------------------------------------------------------------------------
%% @doc Return a list of default NTP time servers for this host. If 
%%      `Resolve' is true, the list will contain IP addresses or else 
%%      host names.
%% @end
%%-------------------------------------------------------------------------
-spec time_servers(boolean()) -> [ inet:ip_address() ].
time_servers(Resolve) when is_boolean(Resolve) ->
    {ok, Bin} = file:read_file("/etc/ntp.conf"),
    Res = re:run(Bin, <<"(?:^|\\n)[^#]\\s*erver\\s+([a-zA-Z0-9\\.-]+)">>,
            [{capture, [1], list}, global]),
    case Res of
    {match, Servers} ->
        IPs = [resolve(Resolve, A) || A <- Servers],
        [A || A <- IPs, A=/=nxdomain];
    nomatch ->
        []
    end.

%%-------------------------------------------------------------------------
%% @doc Query NTP time sources from `"/etc/ntp.conf"' and return 
%%      min/max/avg offset of current host from given time sources.
%% @see avg_time/1
%% @end
%%-------------------------------------------------------------------------
-spec avg_time() -> {Min::integer(), Max::integer(), Avg::integer()}.
avg_time() ->
    avg_time(time_servers()).

%%-------------------------------------------------------------------------
%% @doc Query `ServerAddress' NTP time sources and return min/max/avg offset
%%      of current host from given time sources.
%% @end
%%-------------------------------------------------------------------------
-spec avg_time([ inet:ip_address() ]) -> {Min::integer(), Max::integer(), Avg::integer()}.
avg_time(ServerAddresses) ->
    Results = [time(3, Addr, []) || Addr <- ServerAddresses],
    {Min, Max, Sum, N} = 
        lists:foldl(
            fun(#sntp{offset=Offset}, {Min, Max, Sum, N}) ->
                {erlang:min(Min, Offset),
                 erlang:max(Max, Offset), Sum+Offset, N+1};
            (_, Acc) ->
                Acc
            end,
            {99999999, -99999999, 0, 0},
            Results),
    {Min, Max, round(Sum/N)}.

%%-------------------------------------------------------------------------
%% @doc Query `ServerAddress' time source to find out server time and 
%%      current host's offset from time source.
%% @end
%%-------------------------------------------------------------------------
-spec time(ServerAddress::inet:ip_address()) -> #sntp{}.
time(ServerAddress) ->
    {ok, S} = gen_udp:open(0, [binary, {active, false}]),
    try
        ok = gen_udp:send(S, ServerAddress, _Port = 123, encode()),
        case gen_udp:recv(S, 0, 3000) of
        {ok, {_Addr, _Port2, Reply}} ->
            decode(Reply);
        Other ->
            Other
        end
    after
        gen_udp:close(S)
    end.

%%%------------------------------------------------------------------------
%%% Internal functions
%%%------------------------------------------------------------------------

%% Try several consequitive time lookup and choose the best response.
time(0, _, List) ->
    lists:foldl(
        fun(#sntp{offset=Offset} = T, #sntp{offset=Min}) when Offset < Min ->
            T;
        (_, Min) ->
            Min
        end,
        #sntp{},    %% (integer() < undefined) for any value.
        List);
time(N, Server, Acc) ->
    Res = time(Server),
    time(N-1, Server, [Res | Acc]).

encode() ->
    {Secs, US} = now_to_sntp_time(erlang:timestamp()),
    <<(_LI = 0):2, (_VN = 4):3, (_Mode = 3):3, 
      0:8, 0:8, 0:8, 0:32, 0:32, 0:32, 0:64, 0:64, 0:64,
      Secs:32/big-integer, US:32/big-integer>>.

decode(<<LI:2, Vsn:3, Mode:3, Stratum:8, _Poll:8/signed-integer,
         Precision:8/signed-integer,    RootDelay:32/big-signed-integer,
         RootDispersion:32/big-integer, RefId:4/binary,
         RefTimeSec:32/big-integer,     RefTimeUSec:32/big-integer,
         OrigTimeSec:32/big-integer,    OrigTimeUSec:32/big-integer,
         RecvTimeSec:32/big-integer,    RecvTimeUSec:32/big-integer,
         TransTimeSec:32/big-integer,   TransTimeUSec:32/big-integer,
         _Rest/binary>>) when LI < 3, Mode >= 3 ->
    DestTime  = erlang:timestamp(),
    OrigTime  = sntp_time_to_now(OrigTimeSec, OrigTimeUSec),
    RecvTime  = sntp_time_to_now(RecvTimeSec,  RecvTimeUSec),
    TransTime = sntp_time_to_now(TransTimeSec, TransTimeUSec),
    Delay     = (timer:now_diff(DestTime, OrigTime) - timer:now_diff(RecvTime, TransTime)),
    Offset    = (timer:now_diff(RecvTime, OrigTime) + timer:now_diff(TransTime, DestTime)) div 2,
    [I1 | Tail] = binary_to_list(RefId),
    Ref = lists:flatten(integer_to_list(I1) ++ [[$., integer_to_list(I)] || I <- Tail]),
    #sntp{version=Vsn, stratum=Stratum, precision=round((1 / (1 bsl abs(Precision)))*1000000), 
          rootdelay=RootDelay / 65.536, rootdisp=(RootDispersion * 1000) / 65536, 
          refid=Ref, reftime=sntp_time_to_now(RefTimeSec,RefTimeUSec),
          transtime=TransTime, delay=Delay, offset=Offset};
decode(<<3:2, _:6, _/binary>>) ->
    {error, clock_not_synchronized};
decode(Packet) ->
    {error, {unknown_packet_format, Packet}}.

sntp_time_to_now(Sec, USec) ->
    case Sec band 16#80000000 of
    0 -> Time = Sec + 2085978496; % use base: 7-Feb-2036 @ 06:28:16 UTC
    _ -> Time = Sec - 2208988800  % use base: 1-Jan-1900 @ 01:00:00 UTC
    end,
    {Time div 1000000, Time rem 1000000, round((USec * 1000000) / (1 bsl 32))}.

now_to_sntp_time({_,_,USec} = Now) ->
    SecsSinceJan1900 = 16#80000000 bor
        (calendar:datetime_to_gregorian_seconds(calendar:now_to_universal_time(Now)) - 59958230400),
    {SecsSinceJan1900, round(USec * (1 bsl 32) / 1000000)}.

-spec resolve(Resolve::boolean(), Name::string()|tuple()) -> inet:ip_address() | nxdomain.
resolve(_, {_, _, _, _} = IP) ->
    IP;
resolve(false, Name) ->
    Name;
resolve(true, Name) when is_list(Name) ->
    % Do a DNS lookup on the hostname
    case inet:gethostbyname(Name) of 
    {ok, #hostent{h_addr_list = [Addr | _]}} -> Addr;
    _ -> nxdomain
    end.