ekfa/apps/efka/src/iot/efka_iot_heartbeat.erl
2026-05-11 23:21:19 +08:00

120 lines
4.2 KiB
Erlang

%%%-------------------------------------------------------------------
%%% @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 = <<?HEARTBEAT_VERSION:8, UUIDLen:16, UUID:UUIDLen/binary, Timestamp:64/unsigned-big, Nonce/binary>>,
Mac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload),
[Payload, Mac].