ekfa/src/efka_client.erl
2026-04-20 15:11:44 +08:00

518 lines
21 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 20. 4月 2026 00:00
%%%-------------------------------------------------------------------
-module(efka_client).
-author("anlicheng").
-include("protocol.hrl").
-include("message_pb.hrl").
-include("efka_tables.hrl").
-behaviour(gen_statem).
%% API
-export([start_link/0]).
-export([metric_data/2, ping/13, task_event_stream/3, close_task_event_stream/2]).
%% gen_statem callbacks
-export([init/1, handle_event/4, terminate/3, code_change/4, callback_mode/0]).
-define(SERVER, ?MODULE).
%% 标记当前agent的状态只有在 activated 状态下才可以正常的发送数据
-define(STATE_DENIED, denied).
-define(STATE_AUTH, auth).
%% 不能推送消息到服务,但是可以接受服务器的部分指令
-define(STATE_RESTRICTED, restricted).
%% 激活状态下
-define(STATE_ACTIVATED, activated).
-record(state, {
socket :: undefined | ssl:sslsocket()
}).
%%%===================================================================
%%% API
%%%===================================================================
%% 发送数据
-spec metric_data(RouteKey :: binary(), Metric :: binary()) -> no_return().
metric_data(RouteKey, Metric) when is_binary(RouteKey), is_binary(Metric) ->
gen_statem:cast(?SERVER, {metric_data, RouteKey, Metric}).
-spec task_event_stream(TaskId :: integer(), Type :: binary(), Stream :: binary()) -> no_return().
task_event_stream(TaskId, Type, Stream) when is_integer(TaskId), is_binary(Type), is_binary(Stream) ->
gen_statem:cast(?SERVER, {task_event_stream, TaskId, Type, Stream}).
-spec close_task_event_stream(TaskId :: integer(), Reason :: binary()) -> no_return().
close_task_event_stream(TaskId, Reason) when is_integer(TaskId), is_binary(Reason) ->
gen_statem:cast(?SERVER, {close_task_event_stream, TaskId, Reason}).
ping(AdCode, BootTime, Province, City, EfkaVersion, KernelArch, Ips, CpuCore, CpuLoad, CpuTemperature, Disk, Memory, Interfaces) ->
gen_statem:cast(?SERVER, {ping, AdCode, BootTime, Province, City, EfkaVersion, KernelArch, Ips, CpuCore, CpuLoad, CpuTemperature, Disk, Memory, Interfaces}).
start_link() ->
gen_statem:start_link({local, ?SERVER}, ?MODULE, [], []).
%%%===================================================================
%%% gen_statem callbacks
%%%===================================================================
init([]) ->
erlang:start_timer(0, self(), create_transport),
{ok, ?STATE_DENIED, #state{socket = undefined}}.
callback_mode() ->
handle_event_function.
%% 异步发送数据, 连接存在时候直接发送否则缓存到mnesia
handle_event(cast, {metric_data, RouteKey, Metric}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
Packet = message_pb:encode_msg(#'CastFrame'{
body = {data, #'Data'{route_key = RouteKey, metric = Metric}}
}),
send_packet(Socket, Packet),
{keep_state, State};
handle_event(cast, {metric_data, RouteKey, Metric}, _, State) ->
Packet = message_pb:encode_msg(#'CastFrame'{
body = {data, #'Data'{route_key = RouteKey, metric = Metric}}
}),
ok = cache_model:insert(Packet),
{keep_state, State};
handle_event(cast, {task_event_stream, TaskId, Type, Stream}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
logger:debug("[efka_client] event_stream task_id: ~p, stream: ~ts", [TaskId, Stream]),
EventPacket = message_pb:encode_msg(#'CastFrame'{
body = {event_stream, #'TaskEventStream'{task_id = TaskId, type = Type, stream = Stream}}
}),
send_packet(Socket, EventPacket),
{keep_state, State};
handle_event(cast, {close_task_event_stream, TaskId, Reason}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
EventPacket = message_pb:encode_msg(#'CastFrame'{
body = {event_stream, #'TaskEventStream'{task_id = TaskId, type = <<"close">>, stream = Reason}}
}),
send_packet(Socket, EventPacket),
{keep_state, State};
%% 其他情况下直接忽略
handle_event(cast, {task_event_stream, _TaskId, _Stream}, _, State = #state{}) ->
{keep_state, State};
%% 异步建立到服务器的连接
handle_event(info, {timeout, _, create_transport}, ?STATE_DENIED, State) ->
case connect_socket() of
{ok, Socket} ->
AuthPacket = auth_packet(),
send_packet(Socket, AuthPacket),
{next_state, ?STATE_AUTH, State#state{socket = Socket}, [{state_timeout, 5000, auth_timeout}]};
{error, Reason} ->
logger:debug("[efka_client] connect failed, error: ~p", [Reason]),
schedule_reconnect(),
{keep_state, State#state{socket = undefined}}
end;
handle_event(info, {timeout, _, create_transport}, _, State) ->
{keep_state, State};
handle_event(state_timeout, auth_timeout, ?STATE_AUTH, State = #state{socket = Socket}) ->
logger:debug("[efka_client] auth request timeout"),
disconnect(Socket),
schedule_reconnect(),
{next_state, ?STATE_DENIED, State#state{socket = undefined}};
%% 将缓存中的数据推送到服务器端
handle_event(info, flush_cache, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
case cache_model:fetch_next() of
{ok, {Id, Packet}} ->
send_packet(Socket, Packet),
cache_model:delete(Id),
{keep_state, State, [{next_event, info, flush_cache}]};
error ->
{keep_state, State}
end;
handle_event(info, flush_cache, _, State) ->
{keep_state, State};
%% 处理收到的ssl消息
handle_event(info, {ssl, Socket, PacketBin}, ?STATE_AUTH, State = #state{socket = Socket}) ->
#'ResponseFrame'{packet_id = 1, body = {auth_reply, #'AuthReply'{code = Code, payload = Message}}} =
message_pb:decode_msg(PacketBin, 'ResponseFrame'),
case Code of
0 ->
logger:debug("[efka_client] auth success, message: ~p", [Message]),
{next_state, ?STATE_ACTIVATED, State, [{next_event, info, flush_cache}]};
1 ->
logger:debug("[efka_client] auth denied, message: ~p", [Message]),
{next_state, ?STATE_RESTRICTED, State};
2 ->
logger:debug("[efka_client] auth failed, message: ~p", [Message]),
disconnect(Socket),
schedule_reconnect(),
{next_state, ?STATE_DENIED, State#state{socket = undefined}};
_ ->
logger:debug("[efka_client] auth failed, invalid message"),
disconnect(Socket),
schedule_reconnect(),
{next_state, ?STATE_DENIED, State#state{socket = undefined}}
end;
handle_event(info, {ssl, Socket, <<8, _/binary>> = PacketBin}, StateName, State = #state{socket = Socket})
when StateName =:= ?STATE_ACTIVATED; StateName =:= ?STATE_RESTRICTED ->
#'RequestFrame'{packet_id = PacketId, body = Body} = message_pb:decode_msg(PacketBin, 'RequestFrame'),
true = is_integer(PacketId) andalso PacketId > 0,
case Body of
{rpc_request, Request} ->
{keep_state, State, [{next_event, info, {server_rpc, PacketId, Request}}]};
{container_request, Request} ->
{keep_state, State, [{next_event, info, {container_request, PacketId, Request}}]}
end;
handle_event(info, {ssl, Socket, <<10, _/binary>> = PacketBin}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
#'CastFrame'{body = {pub, Pub}} = message_pb:decode_msg(PacketBin, 'CastFrame'),
{keep_state, State, [{next_event, info, {server_cast, Pub}}]};
handle_event(info, {ssl, Socket, <<18, _/binary>> = PacketBin}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
#'CastFrame'{body = {command, Command}} = message_pb:decode_msg(PacketBin, 'CastFrame'),
{keep_state, State, [{next_event, info, {server_cast, Command}}]};
handle_event(info, {ssl, Socket, <<10, _/binary>> = PacketBin}, ?STATE_RESTRICTED, State = #state{socket = Socket}) ->
#'CastFrame'{body = {pub, Pub}} = message_pb:decode_msg(PacketBin, 'CastFrame'),
{keep_state, State, [{next_event, info, {server_cast, Pub}}]};
handle_event(info, {ssl, Socket, <<18, _/binary>> = PacketBin}, ?STATE_RESTRICTED, State = #state{socket = Socket}) ->
#'CastFrame'{body = {command, Command}} = message_pb:decode_msg(PacketBin, 'CastFrame'),
{keep_state, State, [{next_event, info, {server_cast, Command}}]};
%% 微服务部署
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {list, #'ContainerRequest.List'{all = _All}}}},
?STATE_ACTIVATED, State = #state{socket = Socket}) ->
case docker_manager:get_containers() of
{ok, Containers} ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(Containers)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {deploy, #'ContainerRequest.Deploy'{
task_id = TaskId,
params = Params
}}}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
case docker_manager:deploy(TaskId, Params) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {start, #'ContainerRequest.Start'{target = Target}}}},
?STATE_ACTIVATED, State = #state{socket = Socket}) ->
ContainerTarget = container_target(Target),
case docker_manager:start_container(ContainerTarget) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {stop, #'ContainerRequest.Stop'{
target = Target,
timeout_seconds = TimeoutSeconds
}}}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
ContainerTarget = container_target(Target),
case docker_manager:stop_container(ContainerTarget, TimeoutSeconds) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {kill, #'ContainerRequest.Kill'{
target = Target,
signal = Signal
}}}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
ContainerTarget = container_target(Target),
case docker_manager:kill_container(ContainerTarget, to_binary(Signal)) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {remove, #'ContainerRequest.Remove'{
target = Target,
force = Force,
remove_volumes = RemoveVolumes
}}}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
ContainerTarget = container_target(Target),
case docker_manager:remove_container(ContainerTarget, to_bool(Force), to_bool(RemoveVolumes)) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} when is_binary(Reason) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, #'ContainerRequest'{action = {config, #'ContainerRequest.Config'{
target = Target,
config = Config
}}}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
ContainerTarget = container_target(Target),
case docker_manager:config_container(ContainerTarget, iolist_to_binary(Config)) of
ok ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {result, #'RpcReply.RpcResult'{data = encode_rpc_payload(<<"ok">>)}}
}}
}),
send_packet(Socket, Packet);
{error, Reason} ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = Reason}}
}}
}),
send_packet(Socket, Packet)
end,
{keep_state, State};
handle_event(info, {container_request, PacketId, _Request}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = <<"unsupported container request">>}}
}}
}),
send_packet(Socket, Packet),
{keep_state, State};
handle_event(info, {container_request, PacketId, _Request}, ?STATE_RESTRICTED, State = #state{socket = Socket}) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = <<"agent restricted">>}}
}}
}),
send_packet(Socket, Packet),
{keep_state, State};
handle_event(info, {server_rpc, PacketId, #'RpcRequest'{method = Method}}, ?STATE_ACTIVATED, State = #state{socket = Socket}) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = <<"unsupported rpc request: ", Method/binary>>}}
}}
}),
send_packet(Socket, Packet),
{keep_state, State};
handle_event(info, {server_rpc, PacketId, _Request}, ?STATE_RESTRICTED, State = #state{socket = Socket}) ->
Packet = message_pb:encode_msg(#'ResponseFrame'{
packet_id = PacketId,
body = {rpc_reply, #'RpcReply'{
reply = {error, #'RpcReply.RpcError'{code = -1, message = <<"agent restricted">>}}
}}
}),
send_packet(Socket, Packet),
{keep_state, State};
%% 处理命令
handle_event(info, {server_cast, #'Command'{command_type = ?COMMAND_AUTH, command = Auth0}}, StateName,
State = #state{socket = Socket}) ->
Auth = binary_to_integer(Auth0),
case {Auth, StateName} of
{1, ?STATE_ACTIVATED} ->
{keep_state, State};
{1, _} ->
AuthPacket = auth_packet(),
send_packet(Socket, AuthPacket),
{next_state, ?STATE_AUTH, State, [{state_timeout, 5000, auth_timeout}]};
{0, _} ->
{next_state, ?STATE_RESTRICTED, State}
end;
%% 处理Pub/Sub机制
handle_event(info, {server_cast, #'Pub'{topic = Topic, qos = Qos, content = Content}}, ?STATE_ACTIVATED, State) ->
logger:debug("[efka_client] get pub topic: ~p, qos: ~p, content: ~p", [Topic, Qos, Content]),
efka_subscription:publish(Topic, Qos, Content),
{keep_state, State};
handle_event(info, {ssl_error, Socket, Reason}, _, State = #state{socket = Socket}) ->
logger:debug("[efka_client] ssl error: ~p", [Reason]),
disconnect(Socket),
schedule_reconnect(),
{next_state, ?STATE_DENIED, State#state{socket = undefined}};
handle_event(info, {ssl_closed, Socket}, _, State = #state{socket = Socket}) ->
schedule_reconnect(),
{next_state, ?STATE_DENIED, State#state{socket = undefined}};
handle_event(info, Info, _, State = #state{}) ->
logger:notice("[efka_client] get unknown info: ~p", [Info]),
{keep_state, State}.
terminate(_Reason, _StateName, _State = #state{socket = Socket}) ->
disconnect(Socket),
ok.
code_change(_OldVsn, StateName, State = #state{}, _Extra) ->
{ok, StateName, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec auth_packet() -> binary().
auth_packet() ->
{ok, AuthInfo} = application:get_env(efka, auth),
UUID = proplists:get_value(uuid, AuthInfo),
Username = proplists:get_value(username, AuthInfo),
Salt = proplists:get_value(salt, AuthInfo),
Token = proplists:get_value(token, AuthInfo),
message_pb:encode_msg(#'RequestFrame'{
packet_id = 1,
body = {auth_request, #'AuthRequest'{
uuid = unicode:characters_to_binary(UUID),
username = unicode:characters_to_binary(Username),
salt = unicode:characters_to_binary(Salt),
token = unicode:characters_to_binary(Token),
timestamp = efka_util:timestamp()
}}
}).
-spec connect_socket() -> {ok, ssl:sslsocket()} | {error, term()}.
connect_socket() ->
{ok, Props} = application:get_env(efka, tls_server),
Host = proplists:get_value(host, Props),
Port = proplists:get_value(port, Props),
SslOptions = [
binary,
{active, true},
{packet, 4},
{verify, verify_none}
],
ssl:connect(Host, Port, SslOptions, 5000).
-spec send_packet(ssl:sslsocket(), binary()) -> ok.
send_packet(Socket, Packet) when is_binary(Packet) ->
ok = ssl:send(Socket, Packet).
-spec disconnect(undefined | ssl:sslsocket()) -> ok.
disconnect(undefined) ->
ok;
disconnect(Socket) ->
catch ssl:close(Socket),
ok.
-spec schedule_reconnect() -> reference().
schedule_reconnect() ->
erlang:start_timer(5000, self(), create_transport).
-spec encode_rpc_payload(any()) -> binary().
encode_rpc_payload(Payload) ->
jiffy:encode(Payload, [force_utf8]).
container_target(#'ContainerRef'{name = Name, id = Id}) ->
NameBin = to_binary(Name),
IdBin = to_binary(Id),
case NameBin of
<<>> ->
true = IdBin =/= <<>>,
IdBin;
_ ->
NameBin
end.
to_binary(Value) when is_binary(Value) ->
Value;
to_binary(Value) when is_list(Value) ->
unicode:characters_to_binary(Value).
to_bool(true) ->
true;
to_bool(1) ->
true;
to_bool(false) ->
false;
to_bool(0) ->
false.