%%%------------------------------------------------------------------- %%% @doc UDP heartbeat sender for iot host liveness. %%% @end %%%------------------------------------------------------------------- -module(efka_iot_heartbeat). -behaviour(gen_server). %% API -export([start_link/0]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -define(SERVER, ?MODULE). -define(HEARTBEAT_VERSION, 1). -define(HEARTBEAT_NONCE_BYTES, 16). -define(DEFAULT_INTERVAL, 5000). -record(state, { socket :: gen_udp:socket(), host :: inet:hostname() | inet:ip_address(), port :: inet:port_number(), interval :: pos_integer(), uuid :: binary(), heartbeat_secret :: binary() }). %%%=================================================================== %%% API %%%=================================================================== -spec start_link() -> {ok, pid()} | ignore | {error, term()}. start_link() -> gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). %%%=================================================================== %%% gen_server callbacks %%%=================================================================== -spec init(list()) -> {ok, #state{}} | {stop, term()}. init([]) -> ok = application:ensure_started(crypto), {ok, ServerProps} = application:get_env(efka, iot_server), {ok, AuthProps} = application:get_env(efka, auth), HeartbeatProps = case application:get_env(efka, heartbeat) of {ok, Props} -> Props; undefined -> [] end, Host = proplists:get_value(host, ServerProps), UdpPort = proplists:get_value(udp_port, ServerProps), Interval = proplists:get_value(interval, HeartbeatProps, ?DEFAULT_INTERVAL), UUID = list_to_binary(proplists:get_value(uuid, AuthProps)), Token = list_to_binary(proplists:get_value(token, AuthProps)), HeartbeatSecret = crypto:hash(sha256, Token), case gen_udp:open(0, [binary]) of {ok, Socket} -> erlang:send_after(0, self(), heartbeat), {ok, #state{ socket = Socket, host = Host, port = UdpPort, interval = Interval, uuid = UUID, heartbeat_secret = HeartbeatSecret }}; {error, Reason} -> {stop, Reason} end. -spec handle_call(term(), {pid(), term()}, #state{}) -> {reply, ok, #state{}}. handle_call(_Request, _From, State) -> {reply, ok, State}. -spec handle_cast(term(), #state{}) -> {noreply, #state{}}. handle_cast(_Request, State) -> {noreply, State}. -spec handle_info(term(), #state{}) -> {noreply, #state{}}. handle_info(heartbeat, State = #state{interval = Interval}) -> ok = send_heartbeat(State), erlang:send_after(Interval, self(), heartbeat), {noreply, State}; handle_info(Info, State) -> logger:warning("[efka_iot_heartbeat] ignore unknown info: ~p", [Info]), {noreply, State}. -spec terminate(term(), #state{}) -> ok. terminate(_Reason, #state{socket = Socket}) -> gen_udp:close(Socket), ok. -spec code_change(term(), #state{}, term()) -> {ok, #state{}}. code_change(_OldVsn, State, _Extra) -> {ok, State}. %%%=================================================================== %%% Internal functions %%%=================================================================== -spec send_heartbeat(#state{}) -> ok. send_heartbeat(#state{socket = Socket, host = Host, port = Port, uuid = UUID, heartbeat_secret = HeartbeatSecret}) -> Packet = heartbeat_packet_iodata(UUID, HeartbeatSecret), case gen_udp:send(Socket, Host, Port, Packet) of ok -> ok; {error, Reason} -> logger:warning("[efka_iot_heartbeat] send heartbeat failed, reason: ~p", [Reason]), ok end. -spec heartbeat_packet_iodata(binary(), binary()) -> iodata(). heartbeat_packet_iodata(UUID, HeartbeatSecret) when is_binary(UUID), is_binary(HeartbeatSecret) -> UUIDLen = byte_size(UUID), Timestamp = efka_util:timestamp(), Nonce = crypto:strong_rand_bytes(?HEARTBEAT_NONCE_BYTES), Payload = <>, Mac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload), [Payload, Mac].