diff --git a/apps/emqx_management/Makefile b/apps/emqx_management/Makefile new file mode 100644 index 000000000..660dc5724 --- /dev/null +++ b/apps/emqx_management/Makefile @@ -0,0 +1,37 @@ +## shallow clone for speed + +REBAR_GIT_CLONE_OPTIONS += --depth 1 +export REBAR_GIT_CLONE_OPTIONS + +REBAR = rebar3 +all: compile + +compile: + $(REBAR) compile + +clean: distclean + +ct: compile + $(REBAR) as test ct -v + +eunit: compile + $(REBAR) as test eunit + +xref: + $(REBAR) xref + +cover: + $(REBAR) cover + +distclean: + @rm -rf _build + @rm -f data/app.*.config data/vm.*.args rebar.lock + +CUTTLEFISH_SCRIPT = _build/default/lib/cuttlefish/cuttlefish + +$(CUTTLEFISH_SCRIPT): + @${REBAR} get-deps + @if [ ! -f cuttlefish ]; then make -C _build/default/lib/cuttlefish; fi + +app.config: $(CUTTLEFISH_SCRIPT) + $(verbose) $(CUTTLEFISH_SCRIPT) -l info -e etc/ -c etc/emqx_management.conf -i priv/emqx_management.schema -d data diff --git a/apps/emqx_management/README.md b/apps/emqx_management/README.md new file mode 100644 index 000000000..c71a47628 --- /dev/null +++ b/apps/emqx_management/README.md @@ -0,0 +1,9 @@ + +# emqx-management + +EMQ X Management API + +## How to Design RESTful API? + +http://restful-api-design.readthedocs.io/en/latest/scope.html + diff --git a/apps/emqx_management/etc/emqx_management.conf b/apps/emqx_management/etc/emqx_management.conf new file mode 100644 index 000000000..31a3c1dc5 --- /dev/null +++ b/apps/emqx_management/etc/emqx_management.conf @@ -0,0 +1,52 @@ +##-------------------------------------------------------------------- +## EMQ X Management Plugin +##-------------------------------------------------------------------- + +## Max Row Limit +management.max_row_limit = 10000 + +## Application default secret +## +## Value: String +## management.application.default_secret = public + +## Default Application ID +## +## Value: String +management.default_application.id = admin + +## Default Application Secret +## +## Value: String +management.default_application.secret = public + +##-------------------------------------------------------------------- +## HTTP Listener + +management.listener.http = 8081 +management.listener.http.acceptors = 2 +management.listener.http.max_clients = 512 +management.listener.http.backlog = 512 +management.listener.http.send_timeout = 15s +management.listener.http.send_timeout_close = on +management.listener.http.inet6 = false +management.listener.http.ipv6_v6only = false + +##-------------------------------------------------------------------- +## HTTPS Listener + +## management.listener.https = 8081 +## management.listener.https.acceptors = 2 +## management.listener.https.max_clients = 512 +## management.listener.https.backlog = 512 +## management.listener.https.send_timeout = 15s +## management.listener.https.send_timeout_close = on +## management.listener.https.certfile = etc/certs/cert.pem +## management.listener.https.keyfile = etc/certs/key.pem +## management.listener.https.cacertfile = etc/certs/cacert.pem +## management.listener.https.verify = verify_peer +## management.listener.https.tls_versions = tlsv1.2,tlsv1.1,tlsv1 +## management.listener.https.ciphers = ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-AES256-SHA384,ECDHE-RSA-AES256-SHA384,ECDHE-ECDSA-DES-CBC3-SHA,ECDH-ECDSA-AES256-GCM-SHA384,ECDH-RSA-AES256-GCM-SHA384,ECDH-ECDSA-AES256-SHA384,ECDH-RSA-AES256-SHA384,DHE-DSS-AES256-GCM-SHA384,DHE-DSS-AES256-SHA256,AES256-GCM-SHA384,AES256-SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES128-SHA256,ECDHE-RSA-AES128-SHA256,ECDH-ECDSA-AES128-GCM-SHA256,ECDH-RSA-AES128-GCM-SHA256,ECDH-ECDSA-AES128-SHA256,ECDH-RSA-AES128-SHA256,DHE-DSS-AES128-GCM-SHA256,DHE-DSS-AES128-SHA256,AES128-GCM-SHA256,AES128-SHA256,ECDHE-ECDSA-AES256-SHA,ECDHE-RSA-AES256-SHA,DHE-DSS-AES256-SHA,ECDH-ECDSA-AES256-SHA,ECDH-RSA-AES256-SHA,AES256-SHA,ECDHE-ECDSA-AES128-SHA,ECDHE-RSA-AES128-SHA,DHE-DSS-AES128-SHA,ECDH-ECDSA-AES128-SHA,ECDH-RSA-AES128-SHA,AES128-SHA +## management.listener.https.fail_if_no_peer_cert = true +## management.listener.https.inet6 = false +## management.listener.https.ipv6_v6only = false diff --git a/apps/emqx_management/include/emqx_mgmt.hrl b/apps/emqx_management/include/emqx_mgmt.hrl new file mode 100644 index 000000000..469820b66 --- /dev/null +++ b/apps/emqx_management/include/emqx_mgmt.hrl @@ -0,0 +1,35 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% Return Codes +-define(SUCCESS, 0). %% Success +-define(ERROR1, 101). %% badrpc +-define(ERROR2, 102). %% Unknown error +-define(ERROR3, 103). %% Username or password error +-define(ERROR4, 104). %% Empty username or password +-define(ERROR5, 105). %% User does not exist +-define(ERROR6, 106). %% Admin can not be deleted +-define(ERROR7, 107). %% Missing request parameter +-define(ERROR8, 108). %% Request parameter type error +-define(ERROR9, 109). %% Request parameter is not a json +-define(ERROR10, 110). %% Plugin has been loaded +-define(ERROR11, 111). %% Plugin has been unloaded +-define(ERROR12, 112). %% Client not online +-define(ERROR13, 113). %% User already exist +-define(ERROR14, 114). %% OldPassword error +-define(ERROR15, 115). %% bad topic + +-define(VERSIONS, ["1", "3.2", "3.4", "4.0", "4.1", "4.2"]). \ No newline at end of file diff --git a/apps/emqx_management/priv/emqx_management.schema b/apps/emqx_management/priv/emqx_management.schema new file mode 100644 index 000000000..343a70de6 --- /dev/null +++ b/apps/emqx_management/priv/emqx_management.schema @@ -0,0 +1,239 @@ +%%-*- mode: erlang -*- +%% emqx_management config mapping + +{mapping, "management.max_row_limit", "emqx_management.max_row_limit", [ + {default, 10000}, + {datatype, integer} +]}. + +{mapping, "management.default_application.id", "emqx_management.default_application_id", [ + {default, undefined}, + {datatype, string} +]}. + +{mapping, "management.default_application.secret", "emqx_management.default_application_secret", [ + {default, undefined}, + {datatype, string} +]}. + +{mapping, "management.application.default_secret", "emqx_management.application", [ + {default, undefined}, + {datatype, string} +]}. + +{mapping, "management.listener.http", "emqx_management.listeners", [ + {datatype, [integer, ip]} +]}. + +{mapping, "management.listener.http.acceptors", "emqx_management.listeners", [ + {default, 4}, + {datatype, integer} +]}. + +{mapping, "management.listener.http.max_clients", "emqx_management.listeners", [ + {default, 512}, + {datatype, integer} +]}. + +{mapping, "management.listener.http.backlog", "emqx_management.listeners", [ + {default, 1024}, + {datatype, integer} +]}. + +{mapping, "management.listener.http.send_timeout", "emqx_management.listeners", [ + {datatype, {duration, ms}}, + {default, "15s"} +]}. + +{mapping, "management.listener.http.send_timeout_close", "emqx_management.listeners", [ + {datatype, flag}, + {default, on} +]}. + +{mapping, "management.listener.http.recbuf", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.http.sndbuf", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.http.buffer", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.http.tune_buffer", "emqx_management.listeners", [ + {datatype, flag}, + hidden +]}. + +{mapping, "management.listener.http.nodelay", "emqx_management.listeners", [ + {datatype, {enum, [true, false]}}, + hidden +]}. + +{mapping, "management.listener.http.inet6", "emqx_management.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "management.listener.http.ipv6_v6only", "emqx_management.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "management.listener.https", "emqx_management.listeners", [ + {datatype, [integer, ip]} +]}. + +{mapping, "management.listener.https.acceptors", "emqx_management.listeners", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "management.listener.https.max_clients", "emqx_management.listeners", [ + {default, 64}, + {datatype, integer} +]}. + +{mapping, "management.listener.https.backlog", "emqx_management.listeners", [ + {default, 1024}, + {datatype, integer} +]}. + +{mapping, "management.listener.https.send_timeout", "emqx_management.listeners", [ + {datatype, {duration, ms}}, + {default, "15s"} +]}. + +{mapping, "management.listener.https.send_timeout_close", "emqx_management.listeners", [ + {datatype, flag}, + {default, on} +]}. + +{mapping, "management.listener.https.recbuf", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.https.sndbuf", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.https.buffer", "emqx_management.listeners", [ + {datatype, bytesize}, + hidden +]}. + +{mapping, "management.listener.https.tune_buffer", "emqx_management.listeners", [ + {datatype, flag}, + hidden +]}. + +{mapping, "management.listener.https.nodelay", "emqx_management.listeners", [ + {datatype, {enum, [true, false]}}, + hidden +]}. + +{mapping, "management.listener.https.keyfile", "emqx_management.listeners", [ + {datatype, string} +]}. + +{mapping, "management.listener.https.certfile", "emqx_management.listeners", [ + {datatype, string} +]}. + +{mapping, "management.listener.https.cacertfile", "emqx_management.listeners", [ + {datatype, string} +]}. + +{mapping, "management.listener.https.verify", "emqx_management.listeners", [ + {datatype, atom} +]}. + +{mapping, "management.listener.https.ciphers", "emqx_management.listeners", [ + {datatype, string} +]}. + +{mapping, "management.listener.https.tls_versions", "emqx_management.listeners", [ + {datatype, string} +]}. + +{mapping, "management.listener.https.fail_if_no_peer_cert", "emqx_management.listeners", [ + {datatype, {enum, [true, false]}} +]}. + +{mapping, "management.listener.https.inet6", "emqx_management.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{mapping, "management.listener.https.ipv6_v6only", "emqx_management.listeners", [ + {default, false}, + {datatype, {enum, [true, false]}} +]}. + +{translation, "emqx_management.application", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + Opts = fun(Prefix) -> + Filter([{default_secret, cuttlefish:conf_get(Prefix ++ ".default_secret", Conf)}]) + end, + Prefix = "management.application", + Transfer = fun(default_secret, V) -> list_to_binary(V); + (_, V) -> V + end, + [{K, Transfer(K, V)}|| {K, V} <- Opts(Prefix)] +end}. + +{translation, "emqx_management.listeners", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + Opts = fun(Prefix) -> + Filter([{num_acceptors, cuttlefish:conf_get(Prefix ++ ".acceptors", Conf)}, + {max_connections, cuttlefish:conf_get(Prefix ++ ".max_clients", Conf)}]) + end, + TcpOpts = fun(Prefix) -> + Filter([{backlog, cuttlefish:conf_get(Prefix ++ ".backlog", Conf, undefined)}, + {send_timeout, cuttlefish:conf_get(Prefix ++ ".send_timeout", Conf, undefined)}, + {send_timeout_close, cuttlefish:conf_get(Prefix ++ ".send_timeout_close", Conf, undefined)}, + {recbuf, cuttlefish:conf_get(Prefix ++ ".recbuf", Conf, undefined)}, + {sndbuf, cuttlefish:conf_get(Prefix ++ ".sndbuf", Conf, undefined)}, + {buffer, cuttlefish:conf_get(Prefix ++ ".buffer", Conf, undefined)}, + {nodelay, cuttlefish:conf_get(Prefix ++ ".nodelay", Conf, true)}, + {inet6, cuttlefish:conf_get(Prefix ++ ".inet6", Conf)}, + {ipv6_v6only, cuttlefish:conf_get(Prefix ++ ".ipv6_v6only", Conf)}]) + end, + + SplitFun = fun(undefined) -> undefined; (S) -> string:tokens(S, ",") end, + + SslOpts = fun(Prefix) -> + Versions = case SplitFun(cuttlefish:conf_get(Prefix ++ ".tls_versions", Conf, undefined)) of + undefined -> undefined; + L -> [list_to_atom(V) || V <- L] + end, + Filter([{versions, Versions}, + {ciphers, SplitFun(cuttlefish:conf_get(Prefix ++ ".ciphers", Conf, undefined))}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".keyfile", Conf, undefined)}, + {certfile, cuttlefish:conf_get(Prefix ++ ".certfile", Conf, undefined)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".cacertfile", Conf, undefined)}, + {verify, cuttlefish:conf_get(Prefix ++ ".verify", Conf, undefined)}, + {fail_if_no_peer_cert, cuttlefish:conf_get(Prefix ++ ".fail_if_no_peer_cert", Conf, undefined)}]) + end, + lists:foldl( + fun(Proto, Acc) -> + Prefix = "management.listener." ++ atom_to_list(Proto), + case cuttlefish:conf_get(Prefix, Conf, undefined) of + undefined -> Acc; + Port -> + [{Proto, Port, TcpOpts(Prefix) ++ Opts(Prefix) + ++ case Proto of + http -> []; + https -> SslOpts(Prefix) + end} | Acc] + end + end, [], [http, https]) +end}. + diff --git a/apps/emqx_management/rebar.config b/apps/emqx_management/rebar.config new file mode 100644 index 000000000..fcc358c95 --- /dev/null +++ b/apps/emqx_management/rebar.config @@ -0,0 +1,30 @@ +{deps, + [{minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.1"}}}, + {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} + ]}. + +{profiles, + [{test, + [{deps, + [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {branch, "develop"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.1.1"}}}, + meck + ]} + ]} + ]}. + +{edoc_opts, [{preprocess, true}]}. +{erl_opts, [warn_unused_vars, + warn_shadow_vars, + warn_unused_import, + warn_obsolete_guard, + warnings_as_errors, + debug_info, + {parse_transform}]}. + +{xref_checks, [undefined_function_calls, undefined_functions, + locals_not_used, deprecated_function_calls, + warnings_as_errors, deprecated_functions]}. +{cover_enabled, true}. +{cover_opts, [verbose]}. +{cover_export_enabled, true}. diff --git a/apps/emqx_management/src/emqx_management.app.src b/apps/emqx_management/src/emqx_management.app.src new file mode 100644 index 000000000..c6d696477 --- /dev/null +++ b/apps/emqx_management/src/emqx_management.app.src @@ -0,0 +1,14 @@ +{application, emqx_management, + [{description, "EMQ X Management API and CLI"}, + {vsn, "git"}, + {modules, []}, + {registered, [emqx_management_sup]}, + {applications, [kernel,stdlib,minirest]}, + {mod, {emqx_mgmt_app,[]}}, + {env, []}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}, + {"Github", "https://github.com/emqx/emqx-management"} + ]} + ]}. diff --git a/apps/emqx_management/src/emqx_management.app.src.script b/apps/emqx_management/src/emqx_management.app.src.script new file mode 100644 index 000000000..0e14ff23f --- /dev/null +++ b/apps/emqx_management/src/emqx_management.app.src.script @@ -0,0 +1,24 @@ +%%-*- mode: erlang -*- +%% .app.src.script + +RemoveLeadingV = + fun(Tag) -> + case re:run(Tag, "^[v|e]?[0-9]\.[0-9]\.([0-9]|(rc|beta|alpha)\.[0-9])", [{capture, none}]) of + nomatch -> + re:replace(Tag, "/", "-", [{return ,list}]); + _ -> + %% if it is a version number prefixed by 'v' or 'e', then remove it + re:replace(Tag, "[v|e]", "", [{return ,list}]) + end + end, + +case os:getenv("EMQX_DEPS_DEFAULT_VSN") of + false -> CONFIG; % env var not defined + [] -> CONFIG; % env var set to empty string + Tag -> + [begin + AppConf0 = lists:keystore(vsn, 1, AppConf, {vsn, RemoveLeadingV(Tag)}), + {application, App, AppConf0} + end || Conf = {application, App, AppConf} <- CONFIG] +end. + diff --git a/apps/emqx_management/src/emqx_mgmt.erl b/apps/emqx_management/src/emqx_mgmt.erl new file mode 100644 index 000000000..36d4b8e73 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt.erl @@ -0,0 +1,932 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt). + +-include("emqx_mgmt.hrl"). + +-include_lib("stdlib/include/qlc.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-import(proplists, [get_value/2]). + +%% Nodes and Brokers API +-export([ list_nodes/0 + , lookup_node/1 + , list_brokers/0 + , lookup_broker/1 + , node_info/1 + , broker_info/1 + ]). + +%% Metrics and Stats +-export([ get_metrics/0 + , get_metrics/1 + , get_all_topic_metrics/0 + , get_topic_metrics/1 + , get_topic_metrics/2 + , register_topic_metrics/1 + , register_topic_metrics/2 + , unregister_topic_metrics/1 + , unregister_topic_metrics/2 + , unregister_all_topic_metrics/0 + , unregister_all_topic_metrics/1 + , get_stats/0 + , get_stats/1 + ]). + +%% Clients, Sessions +-export([ lookup_client/2 + , lookup_client/3 + , kickout_client/1 + , list_acl_cache/1 + , clean_acl_cache/1 + , clean_acl_cache/2 + , set_ratelimit_policy/2 + , set_quota_policy/2 + ]). + +%% Internal funcs +-export([call_client/3]). + +%% Subscriptions +-export([ list_subscriptions/1 + , list_subscriptions_via_topic/2 + , list_subscriptions_via_topic/3 + , lookup_subscriptions/1 + , lookup_subscriptions/2 + ]). + +%% Routes +-export([ list_routes/0 + , lookup_routes/1 + ]). + +%% PubSub +-export([ subscribe/2 + , do_subscribe/2 + , publish/1 + , unsubscribe/2 + , do_unsubscribe/2 + ]). + +%% Plugins +-export([ list_plugins/0 + , list_plugins/1 + , load_plugin/2 + , unload_plugin/2 + , reload_plugin/2 + ]). + +%% Modules +-export([ list_modules/0 + , list_modules/1 + , load_module/2 + , unload_module/2 + , reload_module/2 + ]). + +%% Listeners +-export([ list_listeners/0 + , list_listeners/1 + ]). + +%% Alarms +-export([ get_alarms/1 + , get_alarms/2 + , deactivate/2 + , delete_all_deactivated_alarms/0 + , delete_all_deactivated_alarms/1 + ]). + +%% Banned +-export([ create_banned/1 + , delete_banned/1 + ]). + +%% Export/Import +-export([ export_rules/0 + , export_resources/0 + , export_blacklist/0 + , export_applications/0 + , export_users/0 + , export_auth_clientid/0 + , export_auth_username/0 + , export_auth_mnesia/0 + , export_acl_mnesia/0 + , export_schemas/0 + , import_rules/1 + , import_resources/1 + , import_blacklist/1 + , import_applications/1 + , import_users/1 + , import_auth_clientid/1 + , import_auth_username/1 + , import_auth_mnesia/1 + , import_acl_mnesia/1 + , import_schemas/1 + , to_version/1 + ]). + +-export([ enable_telemetry/0 + , disable_telemetry/0 + , get_telemetry_status/0 + , get_telemetry_data/0 + ]). + +%% Common Table API +-export([ item/2 + , max_row_limit/0 + ]). + +-define(MAX_ROW_LIMIT, 10000). + +-define(APP, emqx_management). + +%%-------------------------------------------------------------------- +%% Node Info +%%-------------------------------------------------------------------- + +list_nodes() -> + Running = mnesia:system_info(running_db_nodes), + Stopped = mnesia:system_info(db_nodes) -- Running, + DownNodes = lists:map(fun stopped_node_info/1, Stopped), + [{Node, node_info(Node)} || Node <- Running] ++ DownNodes. + +lookup_node(Node) -> node_info(Node). + +node_info(Node) when Node =:= node() -> + Memory = emqx_vm:get_memory(), + Info = maps:from_list([{K, list_to_binary(V)} || {K, V} <- emqx_vm:loads()]), + BrokerInfo = emqx_sys:info(), + Info#{node => node(), + otp_release => iolist_to_binary(otp_rel()), + memory_total => get_value(allocated, Memory), + memory_used => get_value(used, Memory), + process_available => erlang:system_info(process_limit), + process_used => erlang:system_info(process_count), + max_fds => get_value(max_fds, lists:usort(lists:flatten(erlang:system_info(check_io)))), + connections => ets:info(emqx_channel, size), + node_status => 'Running', + uptime => iolist_to_binary(proplists:get_value(uptime, BrokerInfo)), + version => iolist_to_binary(proplists:get_value(version, BrokerInfo)) + }; +node_info(Node) -> + rpc_call(Node, node_info, [Node]). + +stopped_node_info(Node) -> + #{name => Node, node_status => 'Stopped'}. + +%%-------------------------------------------------------------------- +%% Brokers +%%-------------------------------------------------------------------- + +list_brokers() -> + [{Node, broker_info(Node)} || Node <- ekka_mnesia:running_nodes()]. + +lookup_broker(Node) -> + broker_info(Node). + +broker_info(Node) when Node =:= node() -> + Info = maps:from_list([{K, iolist_to_binary(V)} || {K, V} <- emqx_sys:info()]), + Info#{node => Node, otp_release => iolist_to_binary(otp_rel()), node_status => 'Running'}; + +broker_info(Node) -> + rpc_call(Node, broker_info, [Node]). + +%%-------------------------------------------------------------------- +%% Metrics and Stats +%%-------------------------------------------------------------------- + +get_metrics() -> + [{Node, get_metrics(Node)} || Node <- ekka_mnesia:running_nodes()]. + +get_metrics(Node) when Node =:= node() -> + emqx_metrics:all(); +get_metrics(Node) -> + rpc_call(Node, get_metrics, [Node]). + +get_all_topic_metrics() -> + lists:foldl(fun(Topic, Acc) -> + case get_topic_metrics(Topic) of + {error, _Reason} -> + Acc; + Metrics -> + [#{topic => Topic, metrics => Metrics} | Acc] + end + end, [], emqx_mod_topic_metrics:all_registered_topics()). + +get_topic_metrics(Topic) -> + lists:foldl(fun(Node, Acc) -> + case get_topic_metrics(Node, Topic) of + {error, _Reason} -> + Acc; + Metrics -> + case Acc of + [] -> Metrics; + _ -> + lists:foldl(fun({K, V}, Acc0) -> + [{K, V + proplists:get_value(K, Metrics, 0)} | Acc0] + end, [], Acc) + end + end + end, [], ekka_mnesia:running_nodes()). + +get_topic_metrics(Node, Topic) when Node =:= node() -> + emqx_mod_topic_metrics:metrics(Topic); +get_topic_metrics(Node, Topic) -> + rpc_call(Node, get_topic_metrics, [Node, Topic]). + +register_topic_metrics(Topic) -> + Results = [register_topic_metrics(Node, Topic) || Node <- ekka_mnesia:running_nodes()], + case lists:any(fun(Item) -> Item =:= ok end, Results) of + true -> ok; + false -> lists:last(Results) + end. + +register_topic_metrics(Node, Topic) when Node =:= node() -> + emqx_mod_topic_metrics:register(Topic); +register_topic_metrics(Node, Topic) -> + rpc_call(Node, register_topic_metrics, [Node, Topic]). + +unregister_topic_metrics(Topic) -> + Results = [unregister_topic_metrics(Node, Topic) || Node <- ekka_mnesia:running_nodes()], + case lists:any(fun(Item) -> Item =:= ok end, Results) of + true -> ok; + false -> lists:last(Results) + end. + +unregister_topic_metrics(Node, Topic) when Node =:= node() -> + emqx_mod_topic_metrics:unregister(Topic); +unregister_topic_metrics(Node, Topic) -> + rpc_call(Node, unregister_topic_metrics, [Node, Topic]). + +unregister_all_topic_metrics() -> + Results = [unregister_all_topic_metrics(Node) || Node <- ekka_mnesia:running_nodes()], + case lists:any(fun(Item) -> Item =:= ok end, Results) of + true -> ok; + false -> lists:last(Results) + end. + +unregister_all_topic_metrics(Node) when Node =:= node() -> + emqx_mod_topic_metrics:unregister_all(); +unregister_all_topic_metrics(Node) -> + rpc_call(Node, unregister_topic_metrics, [Node]). + +get_stats() -> + [{Node, get_stats(Node)} || Node <- ekka_mnesia:running_nodes()]. + +get_stats(Node) when Node =:= node() -> + emqx_stats:getstats(); +get_stats(Node) -> + rpc_call(Node, get_stats, [Node]). + +%%-------------------------------------------------------------------- +%% Clients +%%-------------------------------------------------------------------- + +lookup_client({clientid, ClientId}, FormatFun) -> + lists:append([lookup_client(Node, {clientid, ClientId}, FormatFun) || Node <- ekka_mnesia:running_nodes()]); + +lookup_client({username, Username}, FormatFun) -> + lists:append([lookup_client(Node, {username, Username}, FormatFun) || Node <- ekka_mnesia:running_nodes()]). + +lookup_client(Node, {clientid, ClientId}, {M,F}) when Node =:= node() -> + M:F(ets:lookup(emqx_channel, ClientId)); + +lookup_client(Node, {clientid, ClientId}, FormatFun) -> + rpc_call(Node, lookup_client, [Node, {clientid, ClientId}, FormatFun]); + +lookup_client(Node, {username, Username}, {M,F}) when Node =:= node() -> + MatchSpec = [{{'$1', #{clientinfo => #{username => '$2'}}, '_'}, [{'=:=','$2', Username}], ['$1']}], + M:F(ets:select(emqx_channel_info, MatchSpec)); + +lookup_client(Node, {username, Username}, FormatFun) -> + rpc_call(Node, lookup_client, [Node, {username, Username}, FormatFun]). + +kickout_client(ClientId) -> + Results = [kickout_client(Node, ClientId) || Node <- ekka_mnesia:running_nodes()], + case lists:any(fun(Item) -> Item =:= ok end, Results) of + true -> ok; + false -> lists:last(Results) + end. + +kickout_client(Node, ClientId) when Node =:= node() -> + emqx_cm:kick_session(ClientId); + +kickout_client(Node, ClientId) -> + rpc_call(Node, kickout_client, [Node, ClientId]). + +list_acl_cache(ClientId) -> + call_client(ClientId, list_acl_cache). + +clean_acl_cache(ClientId) -> + Results = [clean_acl_cache(Node, ClientId) || Node <- ekka_mnesia:running_nodes()], + case lists:any(fun(Item) -> Item =:= ok end, Results) of + true -> ok; + false -> lists:last(Results) + end. + +clean_acl_cache(Node, ClientId) when Node =:= node() -> + case emqx_cm:lookup_channels(ClientId) of + [] -> + {error, not_found}; + Pids when is_list(Pids) -> + erlang:send(lists:last(Pids), clean_acl_cache), + ok + end; +clean_acl_cache(Node, ClientId) -> + rpc_call(Node, clean_acl_cache, [Node, ClientId]). + +set_ratelimit_policy(ClientId, Policy) -> + call_client(ClientId, {ratelimit, Policy}). + +set_quota_policy(ClientId, Policy) -> + call_client(ClientId, {quota, Policy}). + +%% @private +call_client(ClientId, Req) -> + Results = [call_client(Node, ClientId, Req) || Node <- ekka_mnesia:running_nodes()], + Expected = lists:filter(fun({error, _}) -> false; + (_) -> true + end, Results), + case Expected of + [] -> {error, not_found}; + [Result|_] -> Result + end. + +%% @private +call_client(Node, ClientId, Req) when Node =:= node() -> + case emqx_cm:lookup_channels(ClientId) of + [] -> {error, not_found}; + Pids when is_list(Pids) -> + Pid = lists:last(Pids), + case emqx_cm:get_chan_info(ClientId, Pid) of + #{conninfo := #{conn_mod := ConnMod}} -> + ConnMod:call(Pid, Req); + undefined -> {error, not_found} + end + end; +call_client(Node, ClientId, Req) -> + rpc_call(Node, call_client, [Node, ClientId, Req]). + +%%-------------------------------------------------------------------- +%% Subscriptions +%%-------------------------------------------------------------------- + +list_subscriptions(Node) when Node =:= node() -> + case check_row_limit([mqtt_subproperty]) of + false -> throw(max_row_limit); + ok -> [item(subscription, Sub) || Sub <- ets:tab2list(mqtt_subproperty)] + end; + +list_subscriptions(Node) -> + rpc_call(Node, list_subscriptions, [Node]). + +list_subscriptions_via_topic(Topic, FormatFun) -> + lists:append([list_subscriptions_via_topic(Node, Topic, FormatFun) || Node <- ekka_mnesia:running_nodes()]). + +list_subscriptions_via_topic(Node, Topic, {M,F}) when Node =:= node() -> + MatchSpec = [{{{'_', '$1'}, '_'}, [{'=:=','$1', Topic}], ['$_']}], + M:F(ets:select(emqx_suboption, MatchSpec)); + +list_subscriptions_via_topic(Node, {topic, Topic}, FormatFun) -> + rpc_call(Node, list_subscriptions_via_topic, [Node, {topic, Topic}, FormatFun]). + +lookup_subscriptions(ClientId) -> + lists:append([lookup_subscriptions(Node, ClientId) || Node <- ekka_mnesia:running_nodes()]). + +lookup_subscriptions(Node, ClientId) when Node =:= node() -> + case ets:lookup(emqx_subid, ClientId) of + [] -> []; + [{_, Pid}] -> + ets:match_object(emqx_suboption, {{Pid, '_'}, '_'}) + end; + +lookup_subscriptions(Node, ClientId) -> + rpc_call(Node, lookup_subscriptions, [Node, ClientId]). + +%%-------------------------------------------------------------------- +%% Routes +%%-------------------------------------------------------------------- + +list_routes() -> + case check_row_limit(emqx_route) of + false -> throw(max_row_limit); + ok -> lists:append([ets:tab2list(Tab) || Tab <- emqx_route]) + end. + +lookup_routes(Topic) -> + emqx_router:lookup_routes(Topic). + +%%-------------------------------------------------------------------- +%% PubSub +%%-------------------------------------------------------------------- + +subscribe(ClientId, TopicTables) -> + subscribe(ekka_mnesia:running_nodes(), ClientId, TopicTables). + +subscribe([Node | Nodes], ClientId, TopicTables) -> + case rpc_call(Node, do_subscribe, [ClientId, TopicTables]) of + {error, _} -> subscribe(Nodes, ClientId, TopicTables); + Re -> Re + end; + +subscribe([], _ClientId, _TopicTables) -> + {error, channel_not_found}. + +do_subscribe(ClientId, TopicTables) -> + case ets:lookup(emqx_channel, ClientId) of + [] -> {error, channel_not_found}; + [{_, Pid}] -> + Pid ! {subscribe, TopicTables} + end. + +%%TODO: ??? +publish(Msg) -> emqx:publish(Msg). + +unsubscribe(ClientId, Topic) -> + unsubscribe(ekka_mnesia:running_nodes(), ClientId, Topic). + +unsubscribe([Node | Nodes], ClientId, Topic) -> + case rpc_call(Node, do_unsubscribe, [ClientId, Topic]) of + {error, _} -> unsubscribe(Nodes, ClientId, Topic); + Re -> Re + end; + +unsubscribe([], _ClientId, _Topic) -> + {error, channel_not_found}. + +do_unsubscribe(ClientId, Topic) -> + case ets:lookup(emqx_channel, ClientId) of + [] -> {error, channel_not_found}; + [{_, Pid}] -> + Pid ! {unsubscribe, [emqx_topic:parse(Topic)]} + end. + +%%-------------------------------------------------------------------- +%% Plugins +%%-------------------------------------------------------------------- + +list_plugins() -> + [{Node, list_plugins(Node)} || Node <- ekka_mnesia:running_nodes()]. + +list_plugins(Node) when Node =:= node() -> + emqx_plugins:list(); +list_plugins(Node) -> + rpc_call(Node, list_plugins, [Node]). + +load_plugin(Node, Plugin) when Node =:= node() -> + emqx_plugins:load(Plugin); +load_plugin(Node, Plugin) -> + rpc_call(Node, load_plugin, [Node, Plugin]). + +unload_plugin(Node, Plugin) when Node =:= node() -> + emqx_plugins:unload(Plugin); +unload_plugin(Node, Plugin) -> + rpc_call(Node, unload_plugin, [Node, Plugin]). + +reload_plugin(Node, Plugin) when Node =:= node() -> + emqx_plugins:reload(Plugin); +reload_plugin(Node, Plugin) -> + rpc_call(Node, reload_plugin, [Node, Plugin]). + + +%%-------------------------------------------------------------------- +%% Modules +%%-------------------------------------------------------------------- + +list_modules() -> + [{Node, list_modules(Node)} || Node <- ekka_mnesia:running_nodes()]. + +list_modules(Node) when Node =:= node() -> + emqx_modules:list(); +list_modules(Node) -> + rpc_call(Node, list_modules, [Node]). + +load_module(Node, Module) when Node =:= node() -> + emqx_modules:load(Module); +load_module(Node, Module) -> + rpc_call(Node, load_module, [Node, Module]). + +unload_module(Node, Module) when Node =:= node() -> + emqx_modules:unload(Module); +unload_module(Node, Module) -> + rpc_call(Node, unload_module, [Node, Module]). + +reload_module(Node, Module) when Node =:= node() -> + emqx_modules:reload(Module); +reload_module(Node, Module) -> + rpc_call(Node, reload_module, [Node, Module]). +%%-------------------------------------------------------------------- +%% Listeners +%%-------------------------------------------------------------------- + +list_listeners() -> + [{Node, list_listeners(Node)} || Node <- ekka_mnesia:running_nodes()]. + +list_listeners(Node) when Node =:= node() -> + Tcp = lists:map(fun({{Protocol, ListenOn}, _Pid}) -> + #{protocol => Protocol, + listen_on => ListenOn, + acceptors => esockd:get_acceptors({Protocol, ListenOn}), + max_conns => esockd:get_max_connections({Protocol, ListenOn}), + current_conns => esockd:get_current_connections({Protocol, ListenOn}), + shutdown_count => esockd:get_shutdown_count({Protocol, ListenOn})} + end, esockd:listeners()), + Http = lists:map(fun({Protocol, Opts}) -> + #{protocol => Protocol, + listen_on => proplists:get_value(port, Opts), + acceptors => maps:get(num_acceptors, proplists:get_value(transport_options, Opts, #{}), 0), + max_conns => proplists:get_value(max_connections, Opts), + current_conns => proplists:get_value(all_connections, Opts), + shutdown_count => []} + end, ranch:info()), + Tcp ++ Http; + +list_listeners(Node) -> + rpc_call(Node, list_listeners, [Node]). + +%%-------------------------------------------------------------------- +%% Get Alarms +%%-------------------------------------------------------------------- + +get_alarms(Type) -> + [{Node, get_alarms(Node, Type)} || Node <- ekka_mnesia:running_nodes()]. + +get_alarms(Node, Type) when Node =:= node() -> + emqx_alarm:get_alarms(Type); +get_alarms(Node, Type) -> + rpc_call(Node, get_alarms, [Node, Type]). + +deactivate(Node, Name) when Node =:= node() -> + emqx_alarm:deactivate(Name); +deactivate(Node, Name) -> + rpc_call(Node, deactivate, [Node, Name]). + +delete_all_deactivated_alarms() -> + [delete_all_deactivated_alarms(Node) || Node <- ekka_mnesia:running_nodes()]. + +delete_all_deactivated_alarms(Node) when Node =:= node() -> + emqx_alarm:delete_all_deactivated_alarms(); +delete_all_deactivated_alarms(Node) -> + rpc_call(Node, delete_deactivated_alarms, [Node]). + +%%-------------------------------------------------------------------- +%% Banned API +%%-------------------------------------------------------------------- + +create_banned(Banned) -> + emqx_banned:create(Banned). + +delete_banned(Who) -> + emqx_banned:delete(Who). + +%%-------------------------------------------------------------------- +%% Data Export and Import +%%-------------------------------------------------------------------- + +export_rules() -> + lists:map(fun({_, RuleId, _, RawSQL, _, _, _, _, _, _, Actions, Enabled, Desc}) -> + [{id, RuleId}, + {rawsql, RawSQL}, + {actions, actions_to_prop_list(Actions)}, + {enabled, Enabled}, + {description, Desc}] + end, emqx_rule_registry:get_rules()). + +export_resources() -> + lists:foldl(fun({_, Id, Type, Config, CreatedAt, Desc}, Acc) -> + NCreatedAt = case CreatedAt of + undefined -> null; + _ -> CreatedAt + end, + [[{id, Id}, + {type, Type}, + {config, maps:to_list(Config)}, + {created_at, NCreatedAt}, + {description, Desc}] | Acc] + end, [], emqx_rule_registry:get_resources()). + +export_blacklist() -> + lists:foldl(fun(#banned{who = Who, by = By, reason = Reason, at = At, until = Until}, Acc) -> + NWho = case Who of + {peerhost, Peerhost} -> {peerhost, inet:ntoa(Peerhost)}; + _ -> Who + end, + [[{who, [NWho]}, {by, By}, {reason, Reason}, {at, At}, {until, Until}] | Acc] + end, [], ets:tab2list(emqx_banned)). + +export_applications() -> + lists:foldl(fun({_, AppID, AppSecret, Name, Desc, Status, Expired}, Acc) -> + [[{id, AppID}, {secret, AppSecret}, {name, Name}, {desc, Desc}, {status, Status}, {expired, Expired}] | Acc] + end, [], ets:tab2list(mqtt_app)). + +export_users() -> + lists:foldl(fun({_, Username, Password, Tags}, Acc) -> + [[{username, Username}, {password, base64:encode(Password)}, {tags, Tags}] | Acc] + end, [], ets:tab2list(mqtt_admin)). + +export_auth_clientid() -> + case ets:info(emqx_auth_clientid) of + undefined -> []; + _ -> + lists:foldl(fun({_, ClientId, Password}, Acc) -> + [[{clientid, ClientId}, {password, Password}] | Acc] + end, [], ets:tab2list(emqx_auth_clientid)) + end. + +export_auth_username() -> + case ets:info(emqx_auth_username) of + undefined -> []; + _ -> + lists:foldl(fun({_, Username, Password}, Acc) -> + [[{username, Username}, {password, Password}] | Acc] + end, [], ets:tab2list(emqx_auth_username)) + end. + +export_auth_mnesia() -> + case ets:info(emqx_user) of + undefined -> []; + _ -> + lists:foldl(fun({_, Login, Password, IsSuperuser}, Acc) -> + [[{login, Login}, {password, Password}, {is_superuser, IsSuperuser}] | Acc] + end, [], ets:tab2list(emqx_user)) + end. + +export_acl_mnesia() -> + case ets:info(emqx_acl) of + undefined -> []; + _ -> + lists:foldl(fun({_, Login, Topic, Action, Allow}, Acc) -> + [[{login, Login}, {topic, Topic}, {action, Action}, {allow, Allow}] | Acc] + end, [], ets:tab2list(emqx_acl)) + end. + +export_schemas() -> + case ets:info(emqx_schema) of + undefined -> []; + _ -> + [emqx_schema_api:format_schema(Schema) || Schema <- emqx_schema_registry:get_all_schemas()] + end. + +import_rules(Rules) -> + lists:foreach(fun(#{<<"id">> := RuleId, + <<"rawsql">> := RawSQL, + <<"actions">> := Actions, + <<"enabled">> := Enabled, + <<"description">> := Desc}) -> + Rule = #{ + id => RuleId, + rawsql => RawSQL, + actions => map_to_actions(Actions), + enabled => Enabled, + description => Desc + }, + try emqx_rule_engine:create_rule(Rule) + catch throw:{resource_not_initialized, _ResId} -> + emqx_rule_engine:create_rule(Rule#{enabled => false}) + end + end, Rules). + +import_resources(Reources) -> + lists:foreach(fun(#{<<"id">> := Id, + <<"type">> := Type, + <<"config">> := Config, + <<"created_at">> := CreatedAt, + <<"description">> := Desc}) -> + NCreatedAt = case CreatedAt of + null -> undefined; + _ -> CreatedAt + end, + emqx_rule_engine:create_resource(#{id => Id, + type => any_to_atom(Type), + config => Config, + created_at => NCreatedAt, + description => Desc}) + end, Reources). + +import_blacklist(Blacklist) -> + lists:foreach(fun(#{<<"who">> := Who, + <<"by">> := By, + <<"reason">> := Reason, + <<"at">> := At, + <<"until">> := Until}) -> + NWho = case Who of + #{<<"peerhost">> := Peerhost} -> + {ok, NPeerhost} = inet:parse_address(Peerhost), + {peerhost, NPeerhost}; + #{<<"clientid">> := ClientId} -> {clientid, ClientId}; + #{<<"username">> := Username} -> {username, Username} + end, + emqx_banned:create(#banned{who = NWho, by = By, reason = Reason, at = At, until = Until}) + end, Blacklist). + +import_applications(Apps) -> + lists:foreach(fun(#{<<"id">> := AppID, + <<"secret">> := AppSecret, + <<"name">> := Name, + <<"desc">> := Desc, + <<"status">> := Status, + <<"expired">> := Expired}) -> + NExpired = case is_integer(Expired) of + true -> Expired; + false -> undefined + end, + emqx_mgmt_auth:force_add_app(AppID, Name, AppSecret, Desc, Status, NExpired) + end, Apps). + +import_users(Users) -> + lists:foreach(fun(#{<<"username">> := Username, + <<"password">> := Password, + <<"tags">> := Tags}) -> + NPassword = base64:decode(Password), + emqx_dashboard_admin:force_add_user(Username, NPassword, Tags) + end, Users). + +import_auth_clientid(Lists) -> + case ets:info(emqx_auth_clientid) of + undefined -> ok; + _ -> + [ mnesia:dirty_write({emqx_auth_clientid, ClientId, Password}) || #{<<"clientid">> := ClientId, + <<"password">> := Password} <- Lists ] + end. + +import_auth_username(Lists) -> + case ets:info(emqx_auth_username) of + undefined -> ok; + _ -> + [ mnesia:dirty_write({emqx_auth_username, Username, Password}) || #{<<"username">> := Username, + <<"password">> := Password} <- Lists ] + end. + +import_auth_mnesia(Auths) -> + case ets:info(emqx_user) of + undefined -> ok; + _ -> + [ mnesia:dirty_write({emqx_user, Login, Password, IsSuperuser}) || #{<<"login">> := Login, + <<"password">> := Password, + <<"is_superuser">> := IsSuperuser} <- Auths ] + end. + +import_acl_mnesia(Acls) -> + case ets:info(emqx_acl) of + undefined -> ok; + _ -> + [ mnesia:dirty_write({emqx_acl ,Login, Topic, Action, Allow}) || #{<<"login">> := Login, + <<"topic">> := Topic, + <<"action">> := Action, + <<"allow">> := Allow} <- Acls ] + end. + +import_schemas(Schemas) -> + case ets:info(emqx_schema) of + undefined -> ok; + _ -> [emqx_schema_registry:add_schema(emqx_schema_api:make_schema_params(Schema)) || Schema <- Schemas] + end. + +any_to_atom(L) when is_list(L) -> list_to_atom(L); +any_to_atom(B) when is_binary(B) -> binary_to_atom(B, utf8); +any_to_atom(A) when is_atom(A) -> A. + +to_version(Version) when is_integer(Version) -> + integer_to_list(Version); +to_version(Version) when is_binary(Version) -> + binary_to_list(Version); +to_version(Version) when is_list(Version) -> + Version. + +%%-------------------------------------------------------------------- +%% Telemtry API +%%-------------------------------------------------------------------- + +enable_telemetry() -> + [enable_telemetry(Node) || Node <- ekka_mnesia:running_nodes()], ok. + +enable_telemetry(Node) when Node =:= node() -> + emqx_telemetry:enable(); +enable_telemetry(Node) -> + rpc_call(Node, enable_telemetry, [Node]). + +disable_telemetry() -> + [disable_telemetry(Node) || Node <- ekka_mnesia:running_nodes()], ok. + +disable_telemetry(Node) when Node =:= node() -> + emqx_telemetry:disable(); +disable_telemetry(Node) -> + rpc_call(Node, disable_telemetry, [Node]). + +get_telemetry_status() -> + [{enabled, emqx_telemetry:is_enabled()}]. + +get_telemetry_data() -> + emqx_telemetry:get_telemetry(). + +%%-------------------------------------------------------------------- +%% Common Table API +%%-------------------------------------------------------------------- + +item(client, {ClientId, ChanPid}) -> + Attrs = case emqx_cm:get_chan_info(ClientId, ChanPid) of + undefined -> #{}; + Attrs0 -> Attrs0 + end, + Stats = case emqx_cm:get_chan_stats(ClientId, ChanPid) of + undefined -> #{}; + Stats0 -> maps:from_list(Stats0) + end, + ClientInfo = maps:get(clientinfo, Attrs, #{}), + ConnInfo = maps:get(conninfo, Attrs, #{}), + Session = maps:get(session, Attrs, #{}), + Connected = case maps:get(conn_state, Attrs) of + connected -> true; + _ -> false + end, + NStats = Stats#{max_subscriptions => maps:get(subscriptions_max, Stats, 0), + max_inflight => maps:get(inflight_max, Stats, 0), + max_awaiting_rel => maps:get(awaiting_rel_max, Stats, 0), + max_mqueue => maps:get(mqueue_max, Stats, 0), + inflight => maps:get(inflight_cnt, Stats, 0), + awaiting_rel => maps:get(awaiting_rel_cnt, Stats, 0)}, + lists:foldl(fun(Items, Acc) -> + maps:merge(Items, Acc) + end, #{connected => Connected}, + [maps:with([ subscriptions_cnt, max_subscriptions, + inflight, max_inflight, awaiting_rel, + max_awaiting_rel, mqueue_len, mqueue_dropped, + max_mqueue, heap_size, reductions, mailbox_len, + recv_cnt, recv_msg, recv_oct, recv_pkt, send_cnt, + send_msg, send_oct, send_pkt], NStats), + maps:with([clientid, username, mountpoint, is_bridge, zone], ClientInfo), + maps:with([clean_start, keepalive, expiry_interval, proto_name, + proto_ver, peername, connected_at, disconnected_at], ConnInfo), + maps:with([created_at], Session)]); + +item(subscription, {{Topic, ClientId}, Options}) -> + #{topic => Topic, clientid => ClientId, options => Options}; + +item(route, #route{topic = Topic, dest = Node}) -> + #{topic => Topic, node => Node}; +item(route, {Topic, Node}) -> + #{topic => Topic, node => Node}. + +%%-------------------------------------------------------------------- +%% Internel Functions. +%%-------------------------------------------------------------------- + +rpc_call(Node, Fun, Args) -> + case rpc:call(Node, ?MODULE, Fun, Args) of + {badrpc, Reason} -> {error, Reason}; + Res -> Res + end. + +otp_rel() -> + lists:concat(["R", erlang:system_info(otp_release), "/", erlang:system_info(version)]). + +check_row_limit(Tables) -> + check_row_limit(Tables, max_row_limit()). + +check_row_limit([], _Limit) -> + ok; +check_row_limit([Tab|Tables], Limit) -> + case table_size(Tab) > Limit of + true -> false; + false -> check_row_limit(Tables, Limit) + end. + +max_row_limit() -> + application:get_env(?APP, max_row_limit, ?MAX_ROW_LIMIT). + +table_size(Tab) -> ets:info(Tab, size). + +map_to_actions(Maps) -> + [map_to_action(M) || M <- Maps]. + +map_to_action(Map = #{<<"id">> := ActionInstId, <<"name">> := Name, <<"args">> := Args}) -> + #{id => ActionInstId, + name => any_to_atom(Name), + args => Args, + fallbacks => map_to_actions(maps:get(<<"fallbacks">>, Map, []))}. + +actions_to_prop_list(Actions) -> + [action_to_prop_list(Act) || Act <- Actions]. + +action_to_prop_list({action_instance, ActionInstId, Name, FallbackActions, Args}) -> + [{id, ActionInstId}, + {name, Name}, + {fallbacks, actions_to_prop_list(FallbackActions)}, + {args, Args}]. diff --git a/apps/emqx_management/src/emqx_mgmt_api.erl b/apps/emqx_management/src/emqx_mgmt_api.erl new file mode 100644 index 000000000..f24d30a8a --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api.erl @@ -0,0 +1,326 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api). + +-include_lib("stdlib/include/qlc.hrl"). + +-export([paginate/3]). + +%% first_next query APIs +-export([ params2qs/2 + , node_query/4 + , cluster_query/3 + , traverse_table/5 + , select_table/5 + ]). + +-export([do_query/5]). + +paginate(Tables, Params, RowFun) -> + Qh = query_handle(Tables), + Count = count(Tables), + Page = page(Params), + Limit = limit(Params), + Cursor = qlc:cursor(Qh), + case Page > 1 of + true -> qlc:next_answers(Cursor, (Page - 1) * Limit); + false -> ok + end, + Rows = qlc:next_answers(Cursor, Limit), + qlc:delete_cursor(Cursor), + #{meta => #{page => Page, limit => Limit, count => Count}, + data => [RowFun(Row) || Row <- Rows]}. + +query_handle(Table) when is_atom(Table) -> + qlc:q([R|| R <- ets:table(Table)]); +query_handle([Table]) when is_atom(Table) -> + qlc:q([R|| R <- ets:table(Table)]); +query_handle(Tables) -> + qlc:append([qlc:q([E || E <- ets:table(T)]) || T <- Tables]). + +count(Table) when is_atom(Table) -> + ets:info(Table, size); +count([Table]) when is_atom(Table) -> + ets:info(Table, size); +count(Tables) -> + lists:sum([count(T) || T <- Tables]). + +count(Table, Nodes) -> + lists:sum([rpc_call(Node, ets, info, [Table, size], 5000) || Node <- Nodes]). + +page(Params) -> + binary_to_integer(proplists:get_value(<<"_page">>, Params, <<"1">>)). + +limit(Params) -> + case proplists:get_value(<<"_limit">>, Params) of + undefined -> emqx_mgmt:max_row_limit(); + Size -> binary_to_integer(Size) + end. + +%%-------------------------------------------------------------------- +%% Node Query +%%-------------------------------------------------------------------- + +node_query(Node, Params, {Tab, QsSchema}, QueryFun) -> + {CodCnt, Qs} = params2qs(Params, QsSchema), + Limit = limit(Params), + Page = page(Params), + Start = if Page > 1 -> (Page-1) * Limit; + true -> 0 + end, + {_, Rows} = do_query(Node, Qs, QueryFun, Start, Limit+1), + Meta = #{page => Page, limit => Limit}, + NMeta = case CodCnt =:= 0 of + true -> Meta#{count => count(Tab), hasnext => length(Rows) > Limit}; + _ -> Meta#{count => -1, hasnext => length(Rows) > Limit} + end, + #{meta => NMeta, data => lists:sublist(Rows, Limit)}. + +%% @private +do_query(Node, Qs, {M,F}, Start, Limit) when Node =:= node() -> + M:F(Qs, Start, Limit); +do_query(Node, Qs, QueryFun, Start, Limit) -> + rpc_call(Node, ?MODULE, do_query, [Node, Qs, QueryFun, Start, Limit], 50000). + +%% @private +rpc_call(Node, M, F, A, T) -> + case rpc:call(Node, M, F, A, T) of + {badrpc, _} = R -> {error, R}; + Res -> Res + end. + +%%-------------------------------------------------------------------- +%% Cluster Query +%%-------------------------------------------------------------------- + +cluster_query(Params, {Tab, QsSchema}, QueryFun) -> + {CodCnt, Qs} = params2qs(Params, QsSchema), + Limit = limit(Params), + Page = page(Params), + Start = if Page > 1 -> (Page-1) * Limit; + true -> 0 + end, + Nodes = ekka_mnesia:running_nodes(), + Rows = do_cluster_query(Nodes, Qs, QueryFun, Start, Limit+1, []), + Meta = #{page => Page, limit => Limit}, + NMeta = case CodCnt =:= 0 of + true -> Meta#{count => count(Tab, Nodes), hasnext => length(Rows) > Limit}; + _ -> Meta#{count => -1, hasnext => length(Rows) > Limit} + end, + #{meta => NMeta, data => lists:sublist(Rows, Limit)}. + +%% @private +do_cluster_query([], _, _, _, _, Acc) -> + lists:append(lists:reverse(Acc)); +do_cluster_query([Node|Nodes], Qs, QueryFun, Start, Limit, Acc) -> + {NStart, Rows} = do_query(Node, Qs, QueryFun, Start, Limit), + case Limit - length(Rows) of + Rest when Rest > 0 -> + do_cluster_query(Nodes, Qs, QueryFun, NStart, Limit, [Rows|Acc]); + 0 -> + lists:append(lists:reverse([Rows|Acc])) + end. + +traverse_table(Tab, MatchFun, Start, Limit, FmtFun) -> + ets:safe_fixtable(Tab, true), + {NStart, Rows} = traverse_n_by_one(Tab, ets:first(Tab), MatchFun, Start, Limit, []), + ets:safe_fixtable(Tab, false), + {NStart, lists:map(FmtFun, Rows)}. + +%% @private +traverse_n_by_one(_, '$end_of_table', _, Start, _, Acc) -> + {Start, lists:flatten(lists:reverse(Acc))}; +traverse_n_by_one(_, _, _, Start, _Limit=0, Acc) -> + {Start, lists:flatten(lists:reverse(Acc))}; +traverse_n_by_one(Tab, K, MatchFun, Start, Limit, Acc) -> + GetRows = fun _GetRows('$end_of_table', _, Ks) -> + {'$end_of_table', Ks}; + _GetRows(Kn, 1, Ks) -> + {ets:next(Tab, Kn), [ets:lookup(Tab, Kn) | Ks]}; + _GetRows(Kn, N, Ks) -> + _GetRows(ets:next(Tab, Kn), N-1, [ets:lookup(Tab, Kn) | Ks]) + end, + {K2, Rows} = GetRows(K, 100, []), + case MatchFun(lists:flatten(lists:reverse(Rows))) of + [] -> + traverse_n_by_one(Tab, K2, MatchFun, Start, Limit, Acc); + Ls -> + case Start - length(Ls) of + N when N > 0 -> %% Skip + traverse_n_by_one(Tab, K2, MatchFun, N, Limit, Acc); + _ -> + Got = lists:sublist(Ls, Start+1, Limit), + NLimit = Limit - length(Got), + traverse_n_by_one(Tab, K2, MatchFun, 0, NLimit, [Got|Acc]) + end + end. + +select_table(Tab, Ms, 0, Limit, FmtFun) -> + case ets:select(Tab, Ms, Limit) of + '$end_of_table' -> + {0, []}; + {Rows, _} -> + {0, lists:map(FmtFun, lists:reverse(Rows))} + end; + +select_table(Tab, Ms, Start, Limit, FmtFun) -> + {NStart, Rows} = select_n_by_one(ets:select(Tab, Ms, Limit), Start, Limit, []), + {NStart, lists:map(FmtFun, Rows)}. + +select_n_by_one('$end_of_table', Start, _Limit, Acc) -> + {Start, lists:flatten(lists:reverse(Acc))}; +select_n_by_one(_, Start, _Limit = 0, Acc) -> + {Start, lists:flatten(lists:reverse(Acc))}; + +select_n_by_one({Rows0, Cons}, Start, Limit, Acc) -> + Rows = lists:reverse(Rows0), + case Start - length(Rows) of + N when N > 0 -> %% Skip + select_n_by_one(ets:select(Cons), N, Limit, Acc); + _ -> + Got = lists:sublist(Rows, Start+1, Limit), + NLimit = Limit - length(Got), + select_n_by_one(ets:select(Cons), 0, NLimit, [Got|Acc]) + end. + +params2qs(Params, QsSchema) -> + {Qs, Fuzzy} = pick_params_to_qs(Params, QsSchema, [], []), + {length(Qs) + length(Fuzzy), {Qs, Fuzzy}}. + +%%-------------------------------------------------------------------- +%% Intenal funcs + +pick_params_to_qs([], _, Acc1, Acc2) -> + NAcc2 = [E || E <- Acc2, not lists:keymember(element(1, E), 1, Acc1)], + {lists:reverse(Acc1), lists:reverse(NAcc2)}; + +pick_params_to_qs([{Key, Value}|Params], QsKits, Acc1, Acc2) -> + case proplists:get_value(Key, QsKits) of + undefined -> pick_params_to_qs(Params, QsKits, Acc1, Acc2); + Type -> + case Key of + <> + when Prefix =:= <<"_gte_">>; + Prefix =:= <<"_lte_">> -> + OpposeKey = case Prefix of + <<"_gte_">> -> <<"_lte_", NKey/binary>>; + <<"_lte_">> -> <<"_gte_", NKey/binary>> + end, + case lists:keytake(OpposeKey, 1, Params) of + false -> + pick_params_to_qs(Params, QsKits, [qs(Key, Value, Type) | Acc1], Acc2); + {value, {K2, V2}, NParams} -> + pick_params_to_qs(NParams, QsKits, [qs(Key, Value, K2, V2, Type) | Acc1], Acc2) + end; + _ -> + case is_fuzzy_key(Key) of + true -> + pick_params_to_qs(Params, QsKits, Acc1, [qs(Key, Value, Type) | Acc2]); + _ -> + pick_params_to_qs(Params, QsKits, [qs(Key, Value, Type) | Acc1], Acc2) + + end + end + end. + +qs(<<"_gte_", Key/binary>>, Value, Type) -> + {binary_to_existing_atom(Key, utf8), '>=', to_type(Value, Type)}; +qs(<<"_lte_", Key/binary>>, Value, Type) -> + {binary_to_existing_atom(Key, utf8), '=<', to_type(Value, Type)}; +qs(<<"_like_", Key/binary>>, Value, Type) -> + {binary_to_existing_atom(Key, utf8), like, to_type(Value, Type)}; +qs(<<"_match_", Key/binary>>, Value, Type) -> + {binary_to_existing_atom(Key, utf8), match, to_type(Value, Type)}; +qs(Key, Value, Type) -> + {binary_to_existing_atom(Key, utf8), '=:=', to_type(Value, Type)}. + +qs(K1, V1, K2, V2, Type) -> + {Key, Op1, NV1} = qs(K1, V1, Type), + {Key, Op2, NV2} = qs(K2, V2, Type), + {Key, Op1, NV1, Op2, NV2}. + +is_fuzzy_key(<<"_like_", _/binary>>) -> + true; +is_fuzzy_key(<<"_match_", _/binary>>) -> + true; +is_fuzzy_key(_) -> + false. + +%%-------------------------------------------------------------------- +%% Types + +to_type(V, atom) -> to_atom(V); +to_type(V, integer) -> to_integer(V); +to_type(V, timestamp) -> to_timestamp(V); +to_type(V, ip) -> aton(V); +to_type(V, _) -> V. + +to_atom(A) when is_atom(A) -> + A; +to_atom(B) when is_binary(B) -> + binary_to_atom(B, utf8). + +to_integer(I) when is_integer(I) -> + I; +to_integer(B) when is_binary(B) -> + binary_to_integer(B). + +to_timestamp(I) when is_integer(I) -> + I; +to_timestamp(B) when is_binary(B) -> + binary_to_integer(B). + +aton(B) when is_binary(B) -> + list_to_tuple([binary_to_integer(T) || T <- re:split(B, "[.]")]). + +%%-------------------------------------------------------------------- +%% EUnits +%%-------------------------------------------------------------------- + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +params2qs_test() -> + Schema = [{<<"str">>, binary}, + {<<"int">>, integer}, + {<<"atom">>, atom}, + {<<"ts">>, timestamp}, + {<<"_gte_range">>, integer}, + {<<"_lte_range">>, integer}, + {<<"_like_fuzzy">>, binary}, + {<<"_match_topic">>, binary}], + Params = [{<<"str">>, <<"abc">>}, + {<<"int">>, <<"123">>}, + {<<"atom">>, <<"connected">>}, + {<<"ts">>, <<"156000">>}, + {<<"_gte_range">>, <<"1">>}, + {<<"_lte_range">>, <<"5">>}, + {<<"_like_fuzzy">>, <<"user">>}, + {<<"_match_topic">>, <<"t/#">>}], + ExpectedQs = [{str, '=:=', <<"abc">>}, + {int, '=:=', 123}, + {atom, '=:=', connected}, + {ts, '=:=', 156000}, + {range, '>=', 1, '=<', 5} + ], + FuzzyQs = [{fuzzy, like, <<"user">>}, + {topic, match, <<"t/#">>}], + ?assertEqual({7, {ExpectedQs, FuzzyQs}}, params2qs(Params, Schema)), + + {0, {[], []}} = params2qs([{not_a_predefined_params, val}], Schema). + +-endif. diff --git a/apps/emqx_management/src/emqx_mgmt_api_alarms.erl b/apps/emqx_management/src/emqx_mgmt_api_alarms.erl new file mode 100644 index 000000000..db4e518d3 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_alarms.erl @@ -0,0 +1,137 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_alarms). + +-include("emqx_mgmt.hrl"). + +-include_lib("emqx/include/emqx.hrl"). + +-rest_api(#{name => list_all_alarms, + method => 'GET', + path => "/alarms", + func => list, + descr => "List all alarms in the cluster"}). + +-rest_api(#{name => list_node_alarms, + method => 'GET', + path => "nodes/:atom:node/alarms", + func => list, + descr => "List all alarms on a node"}). + +-rest_api(#{name => list_all_activated_alarms, + method => 'GET', + path => "/alarms/activated", + func => list_activated, + descr => "List all activated alarm in the cluster"}). + +-rest_api(#{name => list_node_activated_alarms, + method => 'GET', + path => "nodes/:atom:node/alarms/activated", + func => list_activated, + descr => "List all activated alarm on a node"}). + +-rest_api(#{name => list_all_deactivated_alarms, + method => 'GET', + path => "/alarms/deactivated", + func => list_deactivated, + descr => "List all deactivated alarm in the cluster"}). + +-rest_api(#{name => list_node_deactivated_alarms, + method => 'GET', + path => "nodes/:atom:node/alarms/deactivated", + func => list_deactivated, + descr => "List all deactivated alarm on a node"}). + +-rest_api(#{name => deactivate_alarm, + method => 'POST', + path => "/alarms/deactivated", + func => deactivate, + descr => "Delete the special alarm on a node"}). + +-rest_api(#{name => delete_all_deactivated_alarms, + method => 'DELETE', + path => "/alarms/deactivated", + func => delete_deactivated, + descr => "Delete all deactivated alarm in the cluster"}). + +-rest_api(#{name => delete_node_deactivated_alarms, + method => 'DELETE', + path => "nodes/:atom:node/alarms/deactivated", + func => delete_deactivated, + descr => "Delete all deactivated alarm on a node"}). + +-export([ list/2 + , deactivate/2 + , list_activated/2 + , list_deactivated/2 + , delete_deactivated/2 + ]). + +list(Bindings, _Params) when map_size(Bindings) == 0 -> + {ok, #{code => ?SUCCESS, + data => [#{node => Node, alarms => Alarms} || {Node, Alarms} <- emqx_mgmt:get_alarms(all)]}}; + +list(#{node := Node}, _Params) -> + {ok, #{code => ?SUCCESS, + data => emqx_mgmt:get_alarms(Node, all)}}. + +list_activated(Bindings, _Params) when map_size(Bindings) == 0 -> + {ok, #{code => ?SUCCESS, + data => [#{node => Node, alarms => Alarms} || {Node, Alarms} <- emqx_mgmt:get_alarms(activated)]}}; + +list_activated(#{node := Node}, _Params) -> + {ok, #{code => ?SUCCESS, + data => emqx_mgmt:get_alarms(Node, activated)}}. + +list_deactivated(Bindings, _Params) when map_size(Bindings) == 0 -> + {ok, #{code => ?SUCCESS, + data => [#{node => Node, alarms => Alarms} || {Node, Alarms} <- emqx_mgmt:get_alarms(deactivated)]}}; + +list_deactivated(#{node := Node}, _Params) -> + {ok, #{code => ?SUCCESS, + data => emqx_mgmt:get_alarms(Node, deactivated)}}. + +deactivate(_Bindings, Params) -> + Node = get_node(Params), + Name = get_name(Params), + do_deactivate(Node, Name). + +delete_deactivated(Bindings, _Params) when map_size(Bindings) == 0 -> + emqx_mgmt:delete_all_deactivated_alarms(), + {ok, #{code => ?SUCCESS}}; + +delete_deactivated(#{node := Node}, _Params) -> + emqx_mgmt:delete_all_deactivated_alarms(Node), + {ok, #{code => ?SUCCESS}}. + +get_node(Params) -> + binary_to_atom(proplists:get_value(<<"node">>, Params, undefined), utf8). + +get_name(Params) -> + binary_to_atom(proplists:get_value(<<"name">>, Params, undefined), utf8). + +do_deactivate(undefined, _) -> + minirest:return({error, missing_param}); +do_deactivate(_, undefined) -> + minirest:return({error, missing_param}); +do_deactivate(Node, Name) -> + case emqx_mgmt:deactivate(Node, Name) of + ok -> + minirest:return(); + {error, Reason} -> + minirest:return({error, Reason}) + end. \ No newline at end of file diff --git a/apps/emqx_management/src/emqx_mgmt_api_apps.erl b/apps/emqx_management/src/emqx_mgmt_api_apps.erl new file mode 100644 index 000000000..bc44a2f2d --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_apps.erl @@ -0,0 +1,109 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_apps). + +-include("emqx_mgmt.hrl"). + +-import(proplists, [get_value/2]). + +-import(minirest, [ return/0 + , return/1 + ]). + +-rest_api(#{name => add_app, + method => 'POST', + path => "/apps/", + func => add_app, + descr => "Add Application"}). + +-rest_api(#{name => del_app, + method => 'DELETE', + path => "/apps/:bin:appid", + func => del_app, + descr => "Delete Application"}). + +-rest_api(#{name => list_apps, + method => 'GET', + path => "/apps/", + func => list_apps, + descr => "List Applications"}). + +-rest_api(#{name => lookup_app, + method => 'GET', + path => "/apps/:bin:appid", + func => lookup_app, + descr => "Lookup Application"}). + +-rest_api(#{name => update_app, + method => 'PUT', + path => "/apps/:bin:appid", + func => update_app, + descr => "Update Application"}). + +-export([ add_app/2 + , del_app/2 + , list_apps/2 + , lookup_app/2 + , update_app/2 + ]). + +add_app(_Bindings, Params) -> + AppId = get_value(<<"app_id">>, Params), + Name = get_value(<<"name">>, Params), + Secret = get_value(<<"secret">>, Params), + Desc = get_value(<<"desc">>, Params), + Status = get_value(<<"status">>, Params), + Expired = get_value(<<"expired">>, Params), + case emqx_mgmt_auth:add_app(AppId, Name, Secret, Desc, Status, Expired) of + {ok, AppSecret} -> return({ok, #{secret => AppSecret}}); + {error, Reason} -> return({error, ?ERROR2, Reason}) + end. + +del_app(#{appid := AppId}, _Params) -> + case emqx_mgmt_auth:del_app(AppId) of + ok -> return(); + {error, Reason} -> return({error, ?ERROR2, Reason}) + end. + +list_apps(_Bindings, _Params) -> + return({ok, [format(Apps)|| Apps <- emqx_mgmt_auth:list_apps()]}). + +lookup_app(#{appid := AppId}, _Params) -> + case emqx_mgmt_auth:lookup_app(AppId) of + {AppId, AppSecret, Name, Desc, Status, Expired} -> + return({ok, #{app_id => AppId, + secret => AppSecret, + name => Name, + desc => Desc, + status => Status, + expired => Expired}}); + undefined -> + return({ok, #{}}) + end. + +update_app(#{appid := AppId}, Params) -> + Name = get_value(<<"name">>, Params), + Desc = get_value(<<"desc">>, Params), + Status = get_value(<<"status">>, Params), + Expired = get_value(<<"expired">>, Params), + case emqx_mgmt_auth:update_app(AppId, Name, Desc, Status, Expired) of + ok -> return(); + {error, Reason} -> return({error, ?ERROR2, Reason}) + end. + +format({AppId, _AppSecret, Name, Desc, Status, Expired}) -> + [{app_id, AppId}, {name, Name}, {desc, Desc}, {status, Status}, {expired, Expired}]. diff --git a/apps/emqx_management/src/emqx_mgmt_api_banned.erl b/apps/emqx_management/src/emqx_mgmt_api_banned.erl new file mode 100644 index 000000000..0e6439163 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_banned.erl @@ -0,0 +1,174 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_banned). + +-include_lib("emqx/include/emqx.hrl"). + +-include("emqx_mgmt.hrl"). + +-import(proplists, [get_value/2]). + +-import(minirest, [ return/0 + , return/1 + ]). + +-rest_api(#{name => list_banned, + method => 'GET', + path => "/banned/", + func => list, + descr => "List banned"}). + +-rest_api(#{name => create_banned, + method => 'POST', + path => "/banned/", + func => create, + descr => "Create banned"}). + +-rest_api(#{name => delete_banned, + method => 'DELETE', + path => "/banned/:as/:who", + func => delete, + descr => "Delete banned"}). + +-export([ list/2 + , create/2 + , delete/2 + ]). + +list(_Bindings, Params) -> + return({ok, emqx_mgmt_api:paginate(emqx_banned, Params, fun format/1)}). + +create(_Bindings, Params) -> + case pipeline([fun ensure_required/1, + fun validate_params/1], Params) of + {ok, NParams} -> + {ok, Banned} = pack_banned(NParams), + ok = emqx_mgmt:create_banned(Banned), + return({ok, maps:from_list(Params)}); + {error, Code, Message} -> + return({error, Code, Message}) + end. + +delete(#{as := As, who := Who}, _) -> + Params = [{<<"who">>, bin(emqx_mgmt_util:urldecode(Who))}, + {<<"as">>, bin(emqx_mgmt_util:urldecode(As))}], + case pipeline([fun ensure_required/1, + fun validate_params/1], Params) of + {ok, NParams} -> + do_delete(get_value(<<"as">>, NParams), get_value(<<"who">>, NParams)), + return(); + {error, Code, Message} -> + return({error, Code, Message}) + end. + +pipeline([], Params) -> + {ok, Params}; +pipeline([Fun|More], Params) -> + case Fun(Params) of + {ok, NParams} -> + pipeline(More, NParams); + {error, Code, Message} -> + {error, Code, Message} + end. + +%% Plugs +ensure_required(Params) when is_list(Params) -> + #{required_params := RequiredParams, message := Msg} = required_params(), + AllIncluded = lists:all(fun(Key) -> + lists:keymember(Key, 1, Params) + end, RequiredParams), + case AllIncluded of + true -> {ok, Params}; + false -> + {error, ?ERROR7, Msg} + end. + +validate_params(Params) -> + #{enum_values := AsEnums, message := Msg} = enum_values(as), + case lists:member(get_value(<<"as">>, Params), AsEnums) of + true -> {ok, Params}; + false -> + {error, ?ERROR8, Msg} + end. + +pack_banned(Params) -> + Now = erlang:system_time(second), + do_pack_banned(Params, #banned{by = <<"user">>, + at = Now, + until = Now + 300}). + +do_pack_banned([], Banned) -> + {ok, Banned}; +do_pack_banned([{<<"who">>, Who} | Params], Banned) -> + case lists:keytake(<<"as">>, 1, Params) of + {value, {<<"as">>, <<"peerhost">>}, Params2} -> + {ok, IPAddress} = inet:parse_address(str(Who)), + do_pack_banned(Params2, Banned#banned{who = {peerhost, IPAddress}}); + {value, {<<"as">>, <<"clientid">>}, Params2} -> + do_pack_banned(Params2, Banned#banned{who = {clientid, Who}}); + {value, {<<"as">>, <<"username">>}, Params2} -> + do_pack_banned(Params2, Banned#banned{who = {username, Who}}) + end; +do_pack_banned([P1 = {<<"as">>, _}, P2 | Params], Banned) -> + do_pack_banned([P2, P1 | Params], Banned); +do_pack_banned([{<<"by">>, By} | Params], Banned) -> + do_pack_banned(Params, Banned#banned{by = By}); +do_pack_banned([{<<"reason">>, Reason} | Params], Banned) -> + do_pack_banned(Params, Banned#banned{reason = Reason}); +do_pack_banned([{<<"at">>, At} | Params], Banned) -> + do_pack_banned(Params, Banned#banned{at = At}); +do_pack_banned([{<<"until">>, Until} | Params], Banned) -> + do_pack_banned(Params, Banned#banned{until = Until}); +do_pack_banned([_P | Params], Banned) -> %% ingore other params + do_pack_banned(Params, Banned). + +do_delete(<<"peerhost">>, Who) -> + {ok, IPAddress} = inet:parse_address(str(Who)), + emqx_mgmt:delete_banned({peerhost, IPAddress}); +do_delete(<<"username">>, Who) -> + emqx_mgmt:delete_banned({username, bin(Who)}); +do_delete(<<"clientid">>, Who) -> + emqx_mgmt:delete_banned({clientid, bin(Who)}). + +required_params() -> + #{required_params => [<<"who">>, <<"as">>], + message => <<"missing mandatory params: ['who', 'as']">> }. + +enum_values(as) -> + #{enum_values => [<<"clientid">>, <<"username">>, <<"peerhost">>], + message => <<"value of 'as' must be one of: ['clientid', 'username', 'peerhost']">> }. + +%% Internal Functions + +format(BannedList) when is_list(BannedList) -> + [format(Ban) || Ban <- BannedList]; +format(#banned{who = {As, Who}, by = By, reason = Reason, at = At, until = Until}) -> + #{who => case As of + peerhost -> bin(inet:ntoa(Who)); + _ -> Who + end, + as => As, by => By, reason => Reason, at => At, until => Until}. + +bin(L) when is_list(L) -> + list_to_binary(L); +bin(B) when is_binary(B) -> + B. + +str(B) when is_binary(B) -> + binary_to_list(B); +str(L) when is_list(L) -> + L. diff --git a/apps/emqx_management/src/emqx_mgmt_api_brokers.erl b/apps/emqx_management/src/emqx_mgmt_api_brokers.erl new file mode 100644 index 000000000..2d6d82850 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_brokers.erl @@ -0,0 +1,49 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_brokers). + +-include("emqx_mgmt.hrl"). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_brokers, + method => 'GET', + path => "/brokers/", + func => list, + descr => "A list of brokers in the cluster"}). + +-rest_api(#{name => get_broker, + method => 'GET', + path => "/brokers/:atom:node", + func => get, + descr => "Get broker info of a node"}). + +-export([ list/2 + , get/2 + ]). + +list(_Bindings, _Params) -> + return({ok, [Info || {_Node, Info} <- emqx_mgmt:list_brokers()]}). + +get(#{node := Node}, _Params) -> + case emqx_mgmt:lookup_broker(Node) of + {error, Reason} -> + return({error, ?ERROR2, Reason}); + Info -> + return({ok, Info}) + end. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_clients.erl b/apps/emqx_management/src/emqx_mgmt_api_clients.erl new file mode 100644 index 000000000..db0884383 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_clients.erl @@ -0,0 +1,419 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_clients). + +-include("emqx_mgmt.hrl"). + +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("emqx/include/emqx.hrl"). + +-import(minirest, [ return/0 + , return/1 + ]). + +-import(proplists, [get_value/2]). + +-define(CLIENT_QS_SCHEMA, {emqx_channel_info, + [{<<"clientid">>, binary}, + {<<"username">>, binary}, + {<<"zone">>, atom}, + {<<"ip_address">>, ip}, + {<<"conn_state">>, atom}, + {<<"clean_start">>, atom}, + {<<"proto_name">>, binary}, + {<<"proto_ver">>, integer}, + {<<"_like_clientid">>, binary}, + {<<"_like_username">>, binary}, + {<<"_gte_created_at">>, timestamp}, + {<<"_lte_created_at">>, timestamp}, + {<<"_gte_connected_at">>, timestamp}, + {<<"_lte_connected_at">>, timestamp}]}). + +-rest_api(#{name => list_clients, + method => 'GET', + path => "/clients/", + func => list, + descr => "A list of clients on current node"}). + +-rest_api(#{name => list_node_clients, + method => 'GET', + path => "nodes/:atom:node/clients/", + func => list, + descr => "A list of clients on specified node"}). + +-rest_api(#{name => lookup_client, + method => 'GET', + path => "/clients/:bin:clientid", + func => lookup, + descr => "Lookup a client in the cluster"}). + +-rest_api(#{name => lookup_node_client, + method => 'GET', + path => "nodes/:atom:node/clients/:bin:clientid", + func => lookup, + descr => "Lookup a client on the node"}). + +-rest_api(#{name => lookup_client_via_username, + method => 'GET', + path => "/clients/username/:bin:username", + func => lookup, + descr => "Lookup a client via username in the cluster" + }). + +-rest_api(#{name => lookup_node_client_via_username, + method => 'GET', + path => "/nodes/:atom:node/clients/username/:bin:username", + func => lookup, + descr => "Lookup a client via username on the node " + }). + +-rest_api(#{name => kickout_client, + method => 'DELETE', + path => "/clients/:bin:clientid", + func => kickout, + descr => "Kick out the client in the cluster"}). + +-rest_api(#{name => clean_acl_cache, + method => 'DELETE', + path => "/clients/:bin:clientid/acl_cache", + func => clean_acl_cache, + descr => "Clear the ACL cache of a specified client in the cluster"}). + +-rest_api(#{name => list_acl_cache, + method => 'GET', + path => "/clients/:bin:clientid/acl_cache", + func => list_acl_cache, + descr => "List the ACL cache of a specified client in the cluster"}). + +-rest_api(#{name => set_ratelimit_policy, + method => 'POST', + path => "/clients/:bin:clientid/ratelimit", + func => set_ratelimit_policy, + descr => "Set the client ratelimit policy"}). + +-rest_api(#{name => clean_ratelimit, + method => 'DELETE', + path => "/clients/:bin:clientid/ratelimit", + func => clean_ratelimit, + descr => "Clear the ratelimit policy"}). + +-rest_api(#{name => set_quota_policy, + method => 'POST', + path => "/clients/:bin:clientid/quota", + func => set_quota_policy, + descr => "Set the client quota policy"}). + +-rest_api(#{name => clean_quota, + method => 'DELETE', + path => "/clients/:bin:clientid/quota", + func => clean_quota, + descr => "Clear the quota policy"}). + +-import(emqx_mgmt_util, [ ntoa/1 + , strftime/1 + ]). + +-export([ list/2 + , lookup/2 + , kickout/2 + , clean_acl_cache/2 + , list_acl_cache/2 + , set_ratelimit_policy/2 + , set_quota_policy/2 + , clean_ratelimit/2 + , clean_quota/2 + ]). + +-export([ query/3 + , format/1 + ]). + +-define(query_fun, {?MODULE, query}). +-define(format_fun, {?MODULE, format}). + +list(Bindings, Params) when map_size(Bindings) == 0 -> + return({ok, emqx_mgmt_api:cluster_query(Params, ?CLIENT_QS_SCHEMA, ?query_fun)}); + +list(#{node := Node}, Params) when Node =:= node() -> + return({ok, emqx_mgmt_api:node_query(Node, Params, ?CLIENT_QS_SCHEMA, ?query_fun)}); + +list(Bindings = #{node := Node}, Params) -> + case rpc:call(Node, ?MODULE, list, [Bindings, Params]) of + {badrpc, Reason} -> return({error, ?ERROR1, Reason}); + Res -> Res + end. + +lookup(#{node := Node, clientid := ClientId}, _Params) -> + return({ok, emqx_mgmt:lookup_client(Node, {clientid, emqx_mgmt_util:urldecode(ClientId)}, ?format_fun)}); + +lookup(#{clientid := ClientId}, _Params) -> + return({ok, emqx_mgmt:lookup_client({clientid, emqx_mgmt_util:urldecode(ClientId)}, ?format_fun)}); + +lookup(#{node := Node, username := Username}, _Params) -> + return({ok, emqx_mgmt:lookup_client(Node, {username, emqx_mgmt_util:urldecode(Username)}, ?format_fun)}); + +lookup(#{username := Username}, _Params) -> + return({ok, emqx_mgmt:lookup_client({username, emqx_mgmt_util:urldecode(Username)}, ?format_fun)}). + +kickout(#{clientid := ClientId}, _Params) -> + case emqx_mgmt:kickout_client(emqx_mgmt_util:urldecode(ClientId)) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end. + +clean_acl_cache(#{clientid := ClientId}, _Params) -> + case emqx_mgmt:clean_acl_cache(emqx_mgmt_util:urldecode(ClientId)) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end. + +list_acl_cache(#{clientid := ClientId}, _Params) -> + case emqx_mgmt:list_acl_cache(emqx_mgmt_util:urldecode(ClientId)) of + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}); + Caches -> return({ok, [format_acl_cache(Cache) || Cache <- Caches]}) + end. + +set_ratelimit_policy(#{clientid := ClientId}, Params) -> + P = [{conn_bytes_in, get_value(<<"conn_bytes_in">>, Params)}, + {conn_messages_in, get_value(<<"conn_messages_in">>, Params)}], + case [{K, parse_ratelimit_str(V)} || {K, V} <- P, V =/= undefined] of + [] -> return(); + Policy -> + case emqx_mgmt:set_ratelimit_policy(emqx_mgmt_util:urldecode(ClientId), Policy) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end + end. + +clean_ratelimit(#{clientid := ClientId}, _Params) -> + case emqx_mgmt:set_ratelimit_policy(emqx_mgmt_util:urldecode(ClientId), []) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end. + +set_quota_policy(#{clientid := ClientId}, Params) -> + P = [{conn_messages_routing, get_value(<<"conn_messages_routing">>, Params)}], + case [{K, parse_ratelimit_str(V)} || {K, V} <- P, V =/= undefined] of + [] -> return(); + Policy -> + case emqx_mgmt:set_quota_policy(emqx_mgmt_util:urldecode(ClientId), Policy) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end + end. + +clean_quota(#{clientid := ClientId}, _Params) -> + case emqx_mgmt:set_quota_policy(emqx_mgmt_util:urldecode(ClientId), []) of + ok -> return(); + {error, not_found} -> return({error, ?ERROR12, not_found}); + {error, Reason} -> return({error, ?ERROR1, Reason}) + end. + +%% @private +%% S = 100,1s +%% | 100KB, 1m +parse_ratelimit_str(S) when is_binary(S) -> + parse_ratelimit_str(binary_to_list(S)); +parse_ratelimit_str(S) -> + [L, D] = string:tokens(S, ", "), + Limit = case cuttlefish_bytesize:parse(L) of + Sz when is_integer(Sz) -> Sz; + {error, Reason1} -> error(Reason1) + end, + Duration = case cuttlefish_duration:parse(D, s) of + Secs when is_integer(Secs) -> Secs; + {error, Reason} -> error(Reason) + end, + {Limit, Duration}. + +%%-------------------------------------------------------------------- +%% Format + +format(Items) when is_list(Items) -> + [format(Item) || Item <- Items]; +format(Key) when is_tuple(Key) -> + format(emqx_mgmt:item(client, Key)); +format(Data) when is_map(Data)-> + {IpAddr, Port} = maps:get(peername, Data), + ConnectedAt = maps:get(connected_at, Data), + CreatedAt = maps:get(created_at, Data), + Data1 = maps:without([peername], Data), + maps:merge(Data1#{node => node(), + ip_address => iolist_to_binary(ntoa(IpAddr)), + port => Port, + connected_at => iolist_to_binary(strftime(ConnectedAt div 1000)), + created_at => iolist_to_binary(strftime(CreatedAt div 1000))}, + case maps:get(disconnected_at, Data, undefined) of + undefined -> #{}; + DisconnectedAt -> #{disconnected_at => iolist_to_binary(strftime(DisconnectedAt div 1000))} + end). + +format_acl_cache({{PubSub, Topic}, {AclResult, Timestamp}}) -> + #{access => PubSub, + topic => Topic, + result => AclResult, + updated_time => Timestamp}. + +%%-------------------------------------------------------------------- +%% Query Functions +%%-------------------------------------------------------------------- + +query({Qs, []}, Start, Limit) -> + Ms = qs2ms_k(Qs), + emqx_mgmt_api:select_table(emqx_channel_info, Ms, Start, Limit, fun format/1); + +query({Qs, Fuzzy}, Start, Limit) -> + Ms = qs2ms(Qs), + MatchFun = match_fun(Ms, Fuzzy), + emqx_mgmt_api:traverse_table(emqx_channel_info, MatchFun, Start, Limit, fun format/1). + +%%-------------------------------------------------------------------- +%% Match funcs + +match_fun(Ms, Fuzzy) -> + MsC = ets:match_spec_compile(Ms), + REFuzzy = lists:map(fun({K, like, S}) -> + {ok, RE} = re:compile(S), + {K, like, RE} + end, Fuzzy), + fun(Rows) -> + case ets:match_spec_run(Rows, MsC) of + [] -> []; + Ls -> + lists:filtermap(fun(E) -> + case run_fuzzy_match(E, REFuzzy) of + false -> false; + true -> {true, element(1, E)} + end end, Ls) + end + end. + +run_fuzzy_match(_, []) -> + true; +run_fuzzy_match(E = {_, #{clientinfo := ClientInfo}, _}, [{Key, _, RE}|Fuzzy]) -> + Val = case maps:get(Key, ClientInfo, "") of + undefined -> ""; + V -> V + end, + re:run(Val, RE, [{capture, none}]) == match andalso run_fuzzy_match(E, Fuzzy). + +%%-------------------------------------------------------------------- +%% QueryString to Match Spec + +-spec qs2ms(list()) -> ets:match_spec(). +qs2ms(Qs) -> + {MtchHead, Conds} = qs2ms(Qs, 2, {#{}, []}), + [{{'$1', MtchHead, '_'}, Conds, ['$_']}]. + +qs2ms_k(Qs) -> + {MtchHead, Conds} = qs2ms(Qs, 2, {#{}, []}), + [{{'$1', MtchHead, '_'}, Conds, ['$1']}]. + +qs2ms([], _, {MtchHead, Conds}) -> + {MtchHead, lists:reverse(Conds)}; + +qs2ms([{Key, '=:=', Value} | Rest], N, {MtchHead, Conds}) -> + NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(Key, Value)), + qs2ms(Rest, N, {NMtchHead, Conds}); +qs2ms([Qs | Rest], N, {MtchHead, Conds}) -> + Holder = binary_to_atom(iolist_to_binary(["$", integer_to_list(N)]), utf8), + NMtchHead = emqx_mgmt_util:merge_maps(MtchHead, ms(element(1, Qs), Holder)), + NConds = put_conds(Qs, Holder, Conds), + qs2ms(Rest, N+1, {NMtchHead, NConds}). + +put_conds({_, Op, V}, Holder, Conds) -> + [{Op, Holder, V} | Conds]; +put_conds({_, Op1, V1, Op2, V2}, Holder, Conds) -> + [{Op2, Holder, V2}, + {Op1, Holder, V1} | Conds]. + +ms(clientid, X) -> + #{clientinfo => #{clientid => X}}; +ms(username, X) -> + #{clientinfo => #{username => X}}; +ms(zone, X) -> + #{clientinfo => #{zone => X}}; +ms(ip_address, X) -> + #{clientinfo => #{peerhost => X}}; +ms(conn_state, X) -> + #{conn_state => X}; +ms(clean_start, X) -> + #{conninfo => #{clean_start => X}}; +ms(proto_name, X) -> + #{conninfo => #{proto_name => X}}; +ms(proto_ver, X) -> + #{conninfo => #{proto_ver => X}}; +ms(connected_at, X) -> + #{conninfo => #{connected_at => X}}; +ms(created_at, X) -> + #{session => #{created_at => X}}. + +%%-------------------------------------------------------------------- +%% EUnits +%%-------------------------------------------------------------------- + +-ifdef(TEST). +-include_lib("eunit/include/eunit.hrl"). + +params2qs_test() -> + QsSchema = element(2, ?CLIENT_QS_SCHEMA), + Params = [{<<"clientid">>, <<"abc">>}, + {<<"username">>, <<"def">>}, + {<<"zone">>, <<"external">>}, + {<<"ip_address">>, <<"127.0.0.1">>}, + {<<"conn_state">>, <<"connected">>}, + {<<"clean_start">>, true}, + {<<"proto_name">>, <<"MQTT">>}, + {<<"proto_ver">>, 4}, + {<<"_gte_created_at">>, 1}, + {<<"_lte_created_at">>, 5}, + {<<"_gte_connected_at">>, 1}, + {<<"_lte_connected_at">>, 5}, + {<<"_like_clientid">>, <<"a">>}, + {<<"_like_username">>, <<"e">>} + ], + ExpectedMtchHead = + #{clientinfo => #{clientid => <<"abc">>, + username => <<"def">>, + zone => external, + peerhost => {127,0,0,1} + }, + conn_state => connected, + conninfo => #{clean_start => true, + proto_name => <<"MQTT">>, + proto_ver => 4, + connected_at => '$3'}, + session => #{created_at => '$2'}}, + ExpectedCondi = [{'>=','$2', 1}, + {'=<','$2', 5}, + {'>=','$3', 1}, + {'=<','$3', 5}], + {10, {Qs1, []}} = emqx_mgmt_api:params2qs(Params, QsSchema), + [{{'$1', MtchHead, _}, Condi, _}] = qs2ms(Qs1), + ?assertEqual(ExpectedMtchHead, MtchHead), + ?assertEqual(ExpectedCondi, Condi), + + [{{'$1', #{}, '_'}, [], ['$_']}] = qs2ms([]), + [{{'$1', #{}, '_'}, [], ['$1']}] = qs2ms_k([]). + +-endif. diff --git a/apps/emqx_management/src/emqx_mgmt_api_data.erl b/apps/emqx_management/src/emqx_mgmt_api_data.erl new file mode 100644 index 000000000..6fc44add0 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_data.erl @@ -0,0 +1,217 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_data). + +-include_lib("emqx/include/emqx.hrl"). + +-include_lib("kernel/include/file.hrl"). + +-include("emqx_mgmt.hrl"). + +-import(minirest, [ return/0 + , return/1 + ]). + +-rest_api(#{name => export, + method => 'POST', + path => "/data/export", + func => export, + descr => "Export data"}). + +-rest_api(#{name => list_exported, + method => 'GET', + path => "/data/export", + func => list_exported, + descr => "List exported file"}). + +-rest_api(#{name => import, + method => 'POST', + path => "/data/import", + func => import, + descr => "Import data"}). + +-rest_api(#{name => download, + method => 'GET', + path => "/data/file/:filename", + func => download, + descr => "Download data file to local"}). + +-rest_api(#{name => upload, + method => 'POST', + path => "/data/file", + func => upload, + descr => "Upload data file from local"}). + +-rest_api(#{name => delete, + method => 'DELETE', + path => "/data/file/:filename", + func => delete, + descr => "Delete data file"}). + +-export([ export/2 + , list_exported/2 + , import/2 + , download/2 + , upload/2 + , delete/2 + ]). + +export(_Bindings, _Params) -> + Rules = emqx_mgmt:export_rules(), + Resources = emqx_mgmt:export_resources(), + Blacklist = emqx_mgmt:export_blacklist(), + Apps = emqx_mgmt:export_applications(), + Users = emqx_mgmt:export_users(), + AuthClientid = emqx_mgmt:export_auth_clientid(), + AuthUsername = emqx_mgmt:export_auth_username(), + AuthMnesia = emqx_mgmt:export_auth_mnesia(), + AclMnesia = emqx_mgmt:export_acl_mnesia(), + Schemas = emqx_mgmt:export_schemas(), + Seconds = erlang:system_time(second), + {{Y, M, D}, {H, MM, S}} = emqx_mgmt_util:datetime(Seconds), + Filename = io_lib:format("emqx-export-~p-~p-~p-~p-~p-~p.json", [Y, M, D, H, MM, S]), + NFilename = filename:join([emqx:get_env(data_dir), Filename]), + Version = string:sub_string(emqx_sys:version(), 1, 3), + Data = [{version, erlang:list_to_binary(Version)}, + {date, erlang:list_to_binary(emqx_mgmt_util:strftime(Seconds))}, + {rules, Rules}, + {resources, Resources}, + {blacklist, Blacklist}, + {apps, Apps}, + {users, Users}, + {auth_clientid, AuthClientid}, + {auth_username, AuthUsername}, + {auth_mnesia, AuthMnesia}, + {acl_mnesia, AclMnesia}, + {schemas, Schemas}], + Bin = emqx_json:encode(Data), + case file:write_file(NFilename, Bin) of + ok -> + case file:read_file_info(NFilename) of + {ok, #file_info{size = Size, ctime = {{Y, M, D}, {H, MM, S}}}} -> + CreatedAt = io_lib:format("~p-~p-~p ~p:~p:~p", [Y, M, D, H, MM, S]), + return({ok, [{filename, list_to_binary(Filename)}, + {size, Size}, + {created_at, list_to_binary(CreatedAt)}]}); + {error, Reason} -> + return({error, Reason}) + end; + {error, Reason} -> + return({error, Reason}) + end. + +list_exported(_Bindings, _Params) -> + Dir = emqx:get_env(data_dir), + {ok, Files} = file:list_dir_all(Dir), + List = lists:foldl(fun(File, Acc) -> + case filename:extension(File) =:= ".json" of + true -> + FullFile = filename:join([Dir, File]), + case file:read_file_info(FullFile) of + {ok, #file_info{size = Size, ctime = CTime = {{Y, M, D}, {H, MM, S}}}} -> + CreatedAt = io_lib:format("~p-~p-~p ~p:~p:~p", [Y, M, D, H, MM, S]), + Seconds = calendar:datetime_to_gregorian_seconds(CTime), + [{Seconds, [{filename, list_to_binary(File)}, + {size, Size}, + {created_at, list_to_binary(CreatedAt)}]} | Acc]; + {error, Reason} -> + logger:error("Read file info of ~s failed with: ~p", [File, Reason]), + Acc + end; + false -> + Acc + end + end, [], Files), + NList = lists:map(fun({_, FileInfo}) -> FileInfo end, lists:keysort(1, List)), + return({ok, NList}). + +import(_Bindings, Params) -> + case proplists:get_value(<<"filename">>, Params) of + undefined -> + return({error, missing_required_params}); + Filename -> + FullFilename = filename:join([emqx:get_env(data_dir), Filename]), + case file:read_file(FullFilename) of + {ok, Json} -> + Data = emqx_json:decode(Json, [return_maps]), + Version = emqx_mgmt:to_version(maps:get(<<"version">>, Data)), + case lists:member(Version, ?VERSIONS) of + true -> + try + emqx_mgmt:import_resources(maps:get(<<"resources">>, Data, [])), + emqx_mgmt:import_rules(maps:get(<<"rules">>, Data, [])), + emqx_mgmt:import_blacklist(maps:get(<<"blacklist">>, Data, [])), + emqx_mgmt:import_applications(maps:get(<<"apps">>, Data, [])), + emqx_mgmt:import_users(maps:get(<<"users">>, Data, [])), + emqx_mgmt:import_auth_clientid(maps:get(<<"auth_clientid">>, Data, [])), + emqx_mgmt:import_auth_username(maps:get(<<"auth_username">>, Data, [])), + emqx_mgmt:import_auth_mnesia(maps:get(<<"auth_mnesia">>, Data, [])), + emqx_mgmt:import_acl_mnesia(maps:get(<<"acl_mnesia">>, Data, [])), + emqx_mgmt:import_schemas(maps:get(<<"schemas">>, Data, [])), + logger:debug("The emqx data has been imported successfully"), + return() + catch Class:Reason:Stack -> + logger:error("The emqx data import failed: ~0p", [{Class,Reason,Stack}]), + return({error, import_failed}) + end; + false -> + logger:error("Unsupported version: ~p", [Version]), + return({error, unsupported_version}) + end; + {error, Reason} -> + return({error, Reason}) + end + end. + +download(#{filename := Filename}, _Params) -> + FullFilename = filename:join([emqx:get_env(data_dir), Filename]), + case file:read_file(FullFilename) of + {ok, Bin} -> + {ok, #{filename => list_to_binary(Filename), + file => Bin}}; + {error, Reason} -> + return({error, Reason}) + end. + +upload(Bindings, Params) -> + do_upload(Bindings, maps:from_list(Params)). + +do_upload(_Bindings, #{<<"filename">> := Filename, + <<"file">> := Bin}) -> + FullFilename = filename:join([emqx:get_env(data_dir), Filename]), + case file:write_file(FullFilename, Bin) of + ok -> + return(); + {error, Reason} -> + return({error, Reason}) + end; +do_upload(Bindings, Params = #{<<"file">> := _}) -> + Seconds = erlang:system_time(second), + {{Y, M, D}, {H, MM, S}} = emqx_mgmt_util:datetime(Seconds), + Filename = io_lib:format("emqx-export-~p-~p-~p-~p-~p-~p.json", [Y, M, D, H, MM, S]), + do_upload(Bindings, Params#{<<"filename">> => Filename}); +do_upload(_Bindings, _Params) -> + return({error, missing_required_params}). + +delete(#{filename := Filename}, _Params) -> + FullFilename = filename:join([emqx:get_env(data_dir), Filename]), + case file:delete(FullFilename) of + ok -> + return(); + {error, Reason} -> + return({error, Reason}) + end. \ No newline at end of file diff --git a/apps/emqx_management/src/emqx_mgmt_api_listeners.erl b/apps/emqx_management/src/emqx_mgmt_api_listeners.erl new file mode 100644 index 000000000..153d20d38 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_listeners.erl @@ -0,0 +1,49 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_listeners). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_listeners, + method => 'GET', + path => "/listeners/", + func => list, + descr => "A list of listeners in the cluster"}). + +-rest_api(#{name => list_node_listeners, + method => 'GET', + path => "/nodes/:atom:node/listeners", + func => list, + descr => "A list of listeners on the node"}). + +-export([list/2]). + +%% List listeners on a node. +list(#{node := Node}, _Params) -> + return({ok, format(emqx_mgmt:list_listeners(Node))}); + +%% List listeners in the cluster. +list(_Binding, _Params) -> + return({ok, [#{node => Node, listeners => format(Listeners)} + || {Node, Listeners} <- emqx_mgmt:list_listeners()]}). + +format(Listeners) when is_list(Listeners) -> + [ Info#{listen_on => list_to_binary(esockd:to_string(ListenOn))} + || Info = #{listen_on := ListenOn} <- Listeners ]; + +format({error, Reason}) -> [{error, Reason}]. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_metrics.erl b/apps/emqx_management/src/emqx_mgmt_api_metrics.erl new file mode 100644 index 000000000..3e9c88db6 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_metrics.erl @@ -0,0 +1,44 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_metrics). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_all_metrics, + method => 'GET', + path => "/metrics", + func => list, + descr => "A list of metrics of all nodes in the cluster"}). + +-rest_api(#{name => list_node_metrics, + method => 'GET', + path => "/nodes/:atom:node/metrics", + func => list, + descr => "A list of metrics of a node"}). + +-export([list/2]). + +list(Bindings, _Params) when map_size(Bindings) == 0 -> + return({ok, [#{node => Node, metrics => maps:from_list(Metrics)} + || {Node, Metrics} <- emqx_mgmt:get_metrics()]}); + +list(#{node := Node}, _Params) -> + case emqx_mgmt:get_metrics(Node) of + {error, Reason} -> return({error, Reason}); + Metrics -> return({ok, maps:from_list(Metrics)}) + end. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_modules.erl b/apps/emqx_management/src/emqx_mgmt_api_modules.erl new file mode 100644 index 000000000..0e2821ec6 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_modules.erl @@ -0,0 +1,129 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_modules). + +-include("emqx_mgmt.hrl"). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_all_modules, + method => 'GET', + path => "/modules/", + func => list, + descr => "List all modules in the cluster"}). + +-rest_api(#{name => list_node_modules, + method => 'GET', + path => "/nodes/:atom:node/modules/", + func => list, + descr => "List all modules on a node"}). + +-rest_api(#{name => load_node_module, + method => 'PUT', + path => "/nodes/:atom:node/modules/:atom:module/load", + func => load, + descr => "Load a module"}). + +-rest_api(#{name => unload_node_module, + method => 'PUT', + path => "/nodes/:atom:node/modules/:atom:module/unload", + func => unload, + descr => "Unload a module"}). + +-rest_api(#{name => reload_node_module, + method => 'PUT', + path => "/nodes/:atom:node/modules/:atom:module/reload", + func => reload, + descr => "Reload a module"}). + +-rest_api(#{name => load_module, + method => 'PUT', + path => "/modules/:atom:module/load", + func => load, + descr => "load a module in the cluster"}). + +-rest_api(#{name => unload_module, + method => 'PUT', + path => "/modules/:atom:module/unload", + func => unload, + descr => "Unload a module in the cluster"}). + +-rest_api(#{name => reload_module, + method => 'PUT', + path => "/modules/:atom:module/reload", + func => reload, + descr => "Reload a module in the cluster"}). + +-export([ list/2 + , load/2 + , unload/2 + , reload/2 + ]). + +list(#{node := Node}, _Params) -> + return({ok, [format(Module) || Module <- emqx_mgmt:list_modules(Node)]}); + +list(_Bindings, _Params) -> + return({ok, [format(Node, Modules) || {Node, Modules} <- emqx_mgmt:list_modules()]}). + +load(#{node := Node, module := Module}, _Params) -> + return(emqx_mgmt:load_module(Node, Module)); + +load(#{module := Module}, _Params) -> + Results = [emqx_mgmt:load_module(Node, Module) || {Node, _Info} <- emqx_mgmt:list_nodes()], + case lists:filter(fun(Item) -> Item =/= ok end, Results) of + [] -> + return(ok); + Errors -> + return(lists:last(Errors)) + end. + +unload(#{node := Node, module := Module}, _Params) -> + return(emqx_mgmt:unload_module(Node, Module)); + +unload(#{module := Module}, _Params) -> + Results = [emqx_mgmt:unload_module(Node, Module) || {Node, _Info} <- emqx_mgmt:list_nodes()], + case lists:filter(fun(Item) -> Item =/= ok end, Results) of + [] -> + return(ok); + Errors -> + return(lists:last(Errors)) + end. + +reload(#{node := Node, module := Module}, _Params) -> + case emqx_mgmt:reload_module(Node, Module) of + ignore -> return(ok); + Result -> return(Result) + end; + +reload(#{module := Module}, _Params) -> + Results = [emqx_mgmt:reload_module(Node, Module) || {Node, _Info} <- emqx_mgmt:list_nodes()], + case lists:filter(fun(Item) -> Item =/= ok end, Results) of + [] -> + return(ok); + Errors -> + return(lists:last(Errors)) + end. + +format(Node, Modules) -> + #{node => Node, modules => [format(Module) || Module <- Modules]}. + +format({Name, Active}) -> + #{name => Name, + description => iolist_to_binary(Name:description()), + active => Active}. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_nodes.erl b/apps/emqx_management/src/emqx_mgmt_api_nodes.erl new file mode 100644 index 000000000..c5791f3d5 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_nodes.erl @@ -0,0 +1,48 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_nodes). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_nodes, + method => 'GET', + path => "/nodes/", + func => list, + descr => "A list of nodes in the cluster"}). + +-rest_api(#{name => get_node, + method => 'GET', + path => "/nodes/:atom:node", + func => get, + descr => "Lookup a node in the cluster"}). + +-export([ list/2 + , get/2 + ]). + +list(_Bindings, _Params) -> + return({ok, [format(Node, Info) || {Node, Info} <- emqx_mgmt:list_nodes()]}). + +get(#{node := Node}, _Params) -> + return({ok, emqx_mgmt:lookup_node(Node)}). + +format(Node, {error, Reason}) -> #{node => Node, error => Reason}; + +format(_Node, Info = #{memory_total := Total, memory_used := Used}) -> + Info#{memory_total := emqx_mgmt_util:kmg(Total), + memory_used := emqx_mgmt_util:kmg(Used)}. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_plugins.erl b/apps/emqx_management/src/emqx_mgmt_api_plugins.erl new file mode 100644 index 000000000..991dffad9 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_plugins.erl @@ -0,0 +1,117 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_plugins). + +-include("emqx_mgmt.hrl"). + +-include_lib("emqx/include/emqx.hrl"). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_all_plugins, + method => 'GET', + path => "/plugins/", + func => list, + descr => "List all plugins in the cluster"}). + +-rest_api(#{name => list_node_plugins, + method => 'GET', + path => "/nodes/:atom:node/plugins/", + func => list, + descr => "List all plugins on a node"}). + +-rest_api(#{name => load_node_plugin, + method => 'PUT', + path => "/nodes/:atom:node/plugins/:atom:plugin/load", + func => load, + descr => "Load a plugin"}). + +-rest_api(#{name => unload_node_plugin, + method => 'PUT', + path => "/nodes/:atom:node/plugins/:atom:plugin/unload", + func => unload, + descr => "Unload a plugin"}). + +-rest_api(#{name => reload_node_plugin, + method => 'PUT', + path => "/nodes/:atom:node/plugins/:atom:plugin/reload", + func => reload, + descr => "Reload a plugin"}). + +-rest_api(#{name => unload_plugin, + method => 'PUT', + path => "/plugins/:atom:plugin/unload", + func => unload, + descr => "Unload a plugin in the cluster"}). + +-rest_api(#{name => reload_plugin, + method => 'PUT', + path => "/plugins/:atom:plugin/reload", + func => reload, + descr => "Reload a plugin in the cluster"}). + +-export([ list/2 + , load/2 + , unload/2 + , reload/2 + ]). + +list(#{node := Node}, _Params) -> + return({ok, [format(Plugin) || Plugin <- emqx_mgmt:list_plugins(Node)]}); + +list(_Bindings, _Params) -> + return({ok, [format({Node, Plugins}) || {Node, Plugins} <- emqx_mgmt:list_plugins()]}). + +load(#{node := Node, plugin := Plugin}, _Params) -> + return(emqx_mgmt:load_plugin(Node, Plugin)). + +unload(#{node := Node, plugin := Plugin}, _Params) -> + return(emqx_mgmt:unload_plugin(Node, Plugin)); + +unload(#{plugin := Plugin}, _Params) -> + Results = [emqx_mgmt:unload_plugin(Node, Plugin) || {Node, _Info} <- emqx_mgmt:list_nodes()], + case lists:filter(fun(Item) -> Item =/= ok end, Results) of + [] -> + return(ok); + Errors -> + return(lists:last(Errors)) + end. + +reload(#{node := Node, plugin := Plugin}, _Params) -> + return(emqx_mgmt:reload_plugin(Node, Plugin)); + +reload(#{plugin := Plugin}, _Params) -> + Results = [emqx_mgmt:reload_plugin(Node, Plugin) || {Node, _Info} <- emqx_mgmt:list_nodes()], + case lists:filter(fun(Item) -> Item =/= ok end, Results) of + [] -> + return(ok); + Errors -> + return(lists:last(Errors)) + end. + +format({Node, Plugins}) -> + #{node => Node, plugins => [format(Plugin) || Plugin <- Plugins]}; + +format(#plugin{name = Name, + descr = Descr, + active = Active, + type = Type}) -> + #{name => Name, + description => iolist_to_binary(Descr), + active => Active, + type => Type}. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_pubsub.erl b/apps/emqx_management/src/emqx_mgmt_api_pubsub.erl new file mode 100644 index 000000000..956f2a45f --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_pubsub.erl @@ -0,0 +1,240 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_pubsub). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include("emqx_mgmt.hrl"). + +-import(proplists, [ get_value/2 + , get_value/3 + ]). + +-import(minirest, [return/1]). + +-rest_api(#{name => mqtt_subscribe, + method => 'POST', + path => "/mqtt/subscribe", + func => subscribe, + descr => "Subscribe a topic"}). + +-rest_api(#{name => mqtt_publish, + method => 'POST', + path => "/mqtt/publish", + func => publish, + descr => "Publish a MQTT message"}). + +-rest_api(#{name => mqtt_unsubscribe, + method => 'POST', + path => "/mqtt/unsubscribe", + func => unsubscribe, + descr => "Unsubscribe a topic"}). + +-rest_api(#{name => mqtt_subscribe_batch, + method => 'POST', + path => "/mqtt/subscribe_batch", + func => subscribe_batch, + descr => "Batch subscribes topics"}). + +-rest_api(#{name => mqtt_publish_batch, + method => 'POST', + path => "/mqtt/publish_batch", + func => publish_batch, + descr => "Batch publish MQTT messages"}). + +-rest_api(#{name => mqtt_unsubscribe_batch, + method => 'POST', + path => "/mqtt/unsubscribe_batch", + func => unsubscribe_batch, + descr => "Batch unsubscribes topics"}). + +-export([ subscribe/2 + , publish/2 + , unsubscribe/2 + , subscribe_batch/2 + , publish_batch/2 + , unsubscribe_batch/2 + ]). + +subscribe(_Bindings, Params) -> + logger:debug("API subscribe Params:~p", [Params]), + {ClientId, Topic, QoS} = parse_subscribe_params(Params), + return(do_subscribe(ClientId, Topic, QoS)). + +publish(_Bindings, Params) -> + logger:debug("API publish Params:~p", [Params]), + {ClientId, Topic, Qos, Retain, Payload} = parse_publish_params(Params), + return(do_publish(ClientId, Topic, Qos, Retain, Payload)). + +unsubscribe(_Bindings, Params) -> + logger:debug("API unsubscribe Params:~p", [Params]), + {ClientId, Topic} = parse_unsubscribe_params(Params), + return(do_unsubscribe(ClientId, Topic)). + +subscribe_batch(_Bindings, Params) -> + logger:debug("API subscribe batch Params:~p", [Params]), + return({ok, loop_subscribe(Params)}). + +publish_batch(_Bindings, Params) -> + logger:debug("API publish batch Params:~p", [Params]), + return({ok, loop_publish(Params)}). + +unsubscribe_batch(_Bindings, Params) -> + logger:debug("API unsubscribe batch Params:~p", [Params]), + return({ok, loop_unsubscribe(Params)}). + +loop_subscribe(Params) -> + loop_subscribe(Params, []). +loop_subscribe([], Result) -> + lists:reverse(Result); +loop_subscribe([Params | ParamsN], Acc) -> + {ClientId, Topic, QoS} = parse_subscribe_params(Params), + Code = case do_subscribe(ClientId, Topic, QoS) of + ok -> 0; + {_, Code0, _Reason} -> Code0 + end, + Result = #{clientid => ClientId, + topic => resp_topic(get_value(<<"topic">>, Params), get_value(<<"topics">>, Params, <<"">>)), + code => Code}, + loop_subscribe(ParamsN, [Result | Acc]). + +loop_publish(Params) -> + loop_publish(Params, []). +loop_publish([], Result) -> + lists:reverse(Result); +loop_publish([Params | ParamsN], Acc) -> + {ClientId, Topic, Qos, Retain, Payload} = parse_publish_params(Params), + Code = case do_publish(ClientId, Topic, Qos, Retain, Payload) of + ok -> 0; + {_, Code0, _} -> Code0 + end, + Result = #{topic => resp_topic(get_value(<<"topic">>, Params), get_value(<<"topics">>, Params, <<"">>)), + code => Code}, + loop_publish(ParamsN, [Result | Acc]). + +loop_unsubscribe(Params) -> + loop_unsubscribe(Params, []). +loop_unsubscribe([], Result) -> + lists:reverse(Result); +loop_unsubscribe([Params | ParamsN], Acc) -> + {ClientId, Topic} = parse_unsubscribe_params(Params), + Code = case do_unsubscribe(ClientId, Topic) of + ok -> 0; + {_, Code0, _} -> Code0 + end, + Result = #{clientid => ClientId, + topic => resp_topic(get_value(<<"topic">>, Params), get_value(<<"topics">>, Params, <<"">>)), + code => Code}, + loop_unsubscribe(ParamsN, [Result | Acc]). + +do_subscribe(_ClientId, [], _QoS) -> + {ok, ?ERROR15, bad_topic}; +do_subscribe(ClientId, Topics, QoS) -> + TopicTable = parse_topic_filters(Topics, QoS), + case emqx_mgmt:subscribe(ClientId, TopicTable) of + {error, Reason} -> {ok, ?ERROR12, Reason}; + _ -> ok + end. + +do_publish(_ClientId, [], _Qos, _Retain, _Payload) -> + {ok, ?ERROR15, bad_topic}; +do_publish(ClientId, Topics, Qos, Retain, Payload) -> + lists:foreach(fun(Topic) -> + Msg = emqx_message:make(ClientId, Qos, Topic, Payload), + emqx_mgmt:publish(Msg#message{flags = #{retain => Retain}}) + end, Topics), + ok. + +do_unsubscribe(ClientId, Topic) -> + case validate_by_filter(Topic) of + true -> + case emqx_mgmt:unsubscribe(ClientId, Topic) of + {error, Reason} -> {ok, ?ERROR12, Reason}; + _ -> ok + end; + false -> + {ok, ?ERROR15, bad_topic} + end. + +parse_subscribe_params(Params) -> + ClientId = get_value(<<"clientid">>, Params), + Topics = topics(filter, get_value(<<"topic">>, Params), get_value(<<"topics">>, Params, <<"">>)), + QoS = get_value(<<"qos">>, Params, 0), + {ClientId, Topics, QoS}. + +parse_publish_params(Params) -> + Topics = topics(name, get_value(<<"topic">>, Params), get_value(<<"topics">>, Params, <<"">>)), + ClientId = get_value(<<"clientid">>, Params), + Payload = decode_payload(get_value(<<"payload">>, Params, <<>>), + get_value(<<"encoding">>, Params, <<"plain">>)), + Qos = get_value(<<"qos">>, Params, 0), + Retain = get_value(<<"retain">>, Params, false), + Payload1 = maybe_maps_to_binary(Payload), + {ClientId, Topics, Qos, Retain, Payload1}. + +parse_unsubscribe_params(Params) -> + ClientId = get_value(<<"clientid">>, Params), + Topic = get_value(<<"topic">>, Params), + {ClientId, Topic}. + +topics(Type, undefined, Topics0) -> + Topics = binary:split(Topics0, <<",">>, [global]), + case Type of + name -> lists:filter(fun(T) -> validate_by_name(T) end, Topics); + filter -> lists:filter(fun(T) -> validate_by_filter(T) end, Topics) + end; + +topics(Type, Topic, _) -> + topics(Type, undefined, Topic). + +%%TODO: +% validate(qos, Qos) -> +% (Qos >= ?QOS_0) and (Qos =< ?QOS_2). + +validate_by_filter(Topic) -> + validate_topic({filter, Topic}). + +validate_by_name(Topic) -> + validate_topic({name, Topic}). + +validate_topic({Type, Topic}) -> + try emqx_topic:validate({Type, Topic}) of + true -> true + catch + error:_Reason -> false + end. + +parse_topic_filters(Topics, Qos) -> + [begin + {Topic, Opts} = emqx_topic:parse(Topic0), + {Topic, Opts#{qos => Qos}} + end || Topic0 <- Topics]. + +resp_topic(undefined, Topics) -> Topics; +resp_topic(Topic, _) -> Topic. + +decode_payload(Payload, <<"base64">>) -> base64:decode(Payload); +decode_payload(Payload, _) -> Payload. + +maybe_maps_to_binary(Payload) when is_binary(Payload) -> Payload; +maybe_maps_to_binary(Payload) -> + try + emqx_json:encode(Payload) + catch + _C : _E : S -> + error({encode_payload_fail, S}) + end. diff --git a/apps/emqx_management/src/emqx_mgmt_api_routes.erl b/apps/emqx_management/src/emqx_mgmt_api_routes.erl new file mode 100644 index 000000000..3a58a26a2 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_routes.erl @@ -0,0 +1,49 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_routes). + +-include_lib("emqx/include/emqx.hrl"). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_routes, + method => 'GET', + path => "/routes/", + func => list, + descr => "List routes"}). + +-rest_api(#{name => lookup_routes, + method => 'GET', + path => "/routes/:bin:topic", + func => lookup, + descr => "Lookup routes to a topic"}). + +-export([ list/2 + , lookup/2 + ]). + +list(Bindings, Params) when map_size(Bindings) == 0 -> + return({ok, emqx_mgmt_api:paginate(emqx_route, Params, fun format/1)}). + +lookup(#{topic := Topic}, _Params) -> + Topic1 = emqx_mgmt_util:urldecode(Topic), + return({ok, [format(R) || R <- emqx_mgmt:lookup_routes(Topic1)]}). +format(#route{topic = Topic, dest = {_, Node}}) -> + #{topic => Topic, node => Node}; +format(#route{topic = Topic, dest = Node}) -> + #{topic => Topic, node => Node}. + diff --git a/apps/emqx_management/src/emqx_mgmt_api_stats.erl b/apps/emqx_management/src/emqx_mgmt_api_stats.erl new file mode 100644 index 000000000..57e0b4fcf --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_stats.erl @@ -0,0 +1,47 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_stats). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_stats, + method => 'GET', + path => "/stats/", + func => list, + descr => "A list of stats of all nodes in the cluster"}). + +-rest_api(#{name => lookup_node_stats, + method => 'GET', + path => "/nodes/:atom:node/stats/", + func => lookup, + descr => "A list of stats of a node"}). + +-export([ list/2 + , lookup/2 + ]). + +%% List stats of all nodes +list(Bindings, _Params) when map_size(Bindings) == 0 -> + return({ok, [#{node => Node, stats => maps:from_list(Stats)} + || {Node, Stats} <- emqx_mgmt:get_stats()]}). + +%% List stats of a node +lookup(#{node := Node}, _Params) -> + case emqx_mgmt:get_stats(Node) of + {error, Reason} -> return({error, Reason}); + Stats -> return({ok, maps:from_list(Stats)}) + end. diff --git a/apps/emqx_management/src/emqx_mgmt_api_subscriptions.erl b/apps/emqx_management/src/emqx_mgmt_api_subscriptions.erl new file mode 100644 index 000000000..51f7a6dec --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_subscriptions.erl @@ -0,0 +1,154 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_subscriptions). + +-include_lib("emqx/include/emqx.hrl"). + +-import(minirest, [return/1]). + +-define(SUBS_QS_SCHEMA, {emqx_suboption, + [{<<"clientid">>, binary}, + {<<"topic">>, binary}, + {<<"share">>, binary}, + {<<"qos">>, integer}, + {<<"_match_topic">>, binary}]}). + +-rest_api(#{name => list_subscriptions, + method => 'GET', + path => "/subscriptions/", + func => list, + descr => "A list of subscriptions in the cluster"}). + +-rest_api(#{name => list_node_subscriptions, + method => 'GET', + path => "/nodes/:atom:node/subscriptions/", + func => list, + descr => "A list of subscriptions on a node"}). + +-rest_api(#{name => lookup_client_subscriptions, + method => 'GET', + path => "/subscriptions/:bin:clientid", + func => lookup, + descr => "A list of subscriptions of a client"}). + +-rest_api(#{name => lookup_client_subscriptions_with_node, + method => 'GET', + path => "/nodes/:atom:node/subscriptions/:bin:clientid", + func => lookup, + descr => "A list of subscriptions of a client on the node"}). + +-export([ list/2 + , lookup/2 + ]). + +-export([ query/3 + , format/1 + ]). + +-define(query_fun, {?MODULE, query}). +-define(format_fun, {?MODULE, format}). + +list(Bindings, Params) when map_size(Bindings) == 0 -> + case proplists:get_value(<<"topic">>, Params) of + undefined -> + return({ok, emqx_mgmt_api:cluster_query(Params, ?SUBS_QS_SCHEMA, ?query_fun)}); + Topic -> + return({ok, emqx_mgmt:list_subscriptions_via_topic(emqx_mgmt_util:urldecode(Topic), ?format_fun)}) + end; + +list(#{node := Node} = Bindings, Params) -> + case proplists:get_value(<<"topic">>, Params) of + undefined -> + case Node =:= node() of + true -> + return({ok, emqx_mgmt_api:node_query(Node, Params, ?SUBS_QS_SCHEMA, ?query_fun)}); + false -> + case rpc:call(Node, ?MODULE, list, [Bindings, Params]) of + {badrpc, Reason} -> return({error, Reason}); + Res -> Res + end + end; + Topic -> + return({ok, emqx_mgmt:list_subscriptions_via_topic(Node, emqx_mgmt_util:urldecode(Topic), ?format_fun)}) + end. + +lookup(#{node := Node, clientid := ClientId}, _Params) -> + return({ok, format(emqx_mgmt:lookup_subscriptions(Node, emqx_mgmt_util:urldecode(ClientId)))}); + +lookup(#{clientid := ClientId}, _Params) -> + return({ok, format(emqx_mgmt:lookup_subscriptions(emqx_mgmt_util:urldecode(ClientId)))}). + +format(Items) when is_list(Items) -> + [format(Item) || Item <- Items]; + +format({{Subscriber, Topic}, Options}) -> + format({Subscriber, Topic, Options}); + +format({_Subscriber, Topic, Options = #{share := Group}}) -> + QoS = maps:get(qos, Options), + #{node => node(), topic => filename:join([<<"$share">>, Group, Topic]), clientid => maps:get(subid, Options), qos => QoS}; +format({_Subscriber, Topic, Options}) -> + QoS = maps:get(qos, Options), + #{node => node(), topic => Topic, clientid => maps:get(subid, Options), qos => QoS}. + +%%-------------------------------------------------------------------- +%% Query Function +%%-------------------------------------------------------------------- + +query({Qs, []}, Start, Limit) -> + Ms = qs2ms(Qs), + emqx_mgmt_api:select_table(emqx_suboption, Ms, Start, Limit, fun format/1); + +query({Qs, Fuzzy}, Start, Limit) -> + Ms = qs2ms(Qs), + MatchFun = match_fun(Ms, Fuzzy), + emqx_mgmt_api:traverse_table(emqx_suboption, MatchFun, Start, Limit, fun format/1). + +match_fun(Ms, Fuzzy) -> + MsC = ets:match_spec_compile(Ms), + fun(Rows) -> + case ets:match_spec_run(Rows, MsC) of + [] -> []; + Ls -> lists:filter(fun(E) -> run_fuzzy_match(E, Fuzzy) end, Ls) + end + end. + +run_fuzzy_match(_, []) -> + true; +run_fuzzy_match(E = {{_, Topic}, _}, [{topic, match, TopicFilter}|Fuzzy]) -> + emqx_topic:match(Topic, TopicFilter) andalso run_fuzzy_match(E, Fuzzy). + +%%-------------------------------------------------------------------- +%% Query String to Match Spec + +qs2ms(Qs) -> + MtchHead = qs2ms(Qs, {{'_', '_'}, #{}}), + [{MtchHead, [], ['$_']}]. + +qs2ms([], MtchHead) -> + MtchHead; +qs2ms([{Key, '=:=', Value} | More], MtchHead) -> + qs2ms(More, update_ms(Key, Value, MtchHead)). + +update_ms(clientid, X, {{Pid, Topic}, Opts}) -> + {{Pid, Topic}, Opts#{subid => X}}; +update_ms(topic, X, {{Pid, _Topic}, Opts}) -> + {{Pid, X}, Opts}; +update_ms(share, X, {{Pid, Topic}, Opts}) -> + {{Pid, Topic}, Opts#{share => X}}; +update_ms(qos, X, {{Pid, Topic}, Opts}) -> + {{Pid, Topic}, Opts#{qos => X}}. diff --git a/apps/emqx_management/src/emqx_mgmt_api_topic_metrics.erl b/apps/emqx_management/src/emqx_mgmt_api_topic_metrics.erl new file mode 100644 index 000000000..aac7845b7 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_api_topic_metrics.erl @@ -0,0 +1,130 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_topic_metrics). + +-import(minirest, [return/1]). + +-rest_api(#{name => list_all_topic_metrics, + method => 'GET', + path => "/topic-metrics", + func => list, + descr => "A list of all topic metrics of all nodes in the cluster"}). + +-rest_api(#{name => list_topic_metrics, + method => 'GET', + path => "/topic-metrics/:bin:topic", + func => list, + descr => "A list of specfied topic metrics of all nodes in the cluster"}). + +-rest_api(#{name => register_topic_metrics, + method => 'POST', + path => "/topic-metrics", + func => register, + descr => "Register topic metrics"}). + +-rest_api(#{name => unregister_all_topic_metrics, + method => 'DELETE', + path => "/topic-metrics", + func => unregister, + descr => "Unregister all topic metrics"}). + +-rest_api(#{name => unregister_topic_metrics, + method => 'DELETE', + path => "/topic-metrics/:bin:topic", + func => unregister, + descr => "Unregister topic metrics"}). + +-export([ list/2 + , register/2 + , unregister/2 + ]). + +list(#{topic := Topic0}, _Params) -> + execute_when_enabled(fun() -> + Topic = emqx_mgmt_util:urldecode(Topic0), + case safe_validate(Topic) of + true -> + case emqx_mgmt:get_topic_metrics(Topic) of + {error, Reason} -> return({error, Reason}); + Metrics -> return({ok, maps:from_list(Metrics)}) + end; + false -> + return({error, invalid_topic_name}) + end + end); + +list(_Bindings, _Params) -> + execute_when_enabled(fun() -> + case emqx_mgmt:get_all_topic_metrics() of + {error, Reason} -> return({error, Reason}); + Metrics -> return({ok, Metrics}) + end + end). + +register(_Bindings, Params) -> + execute_when_enabled(fun() -> + case proplists:get_value(<<"topic">>, Params) of + undefined -> + return({error, missing_required_params}); + Topic -> + case safe_validate(Topic) of + true -> + emqx_mgmt:register_topic_metrics(Topic), + return(ok); + false -> + return({error, invalid_topic_name}) + end + end + end). + +unregister(Bindings, _Params) when map_size(Bindings) =:= 0 -> + execute_when_enabled(fun() -> + emqx_mgmt:unregister_all_topic_metrics(), + return(ok) + end); + +unregister(#{topic := Topic0}, _Params) -> + execute_when_enabled(fun() -> + Topic = emqx_mgmt_util:urldecode(Topic0), + case safe_validate(Topic) of + true -> + emqx_mgmt:unregister_topic_metrics(Topic), + return(ok); + false -> + return({error, invalid_topic_name}) + end + end). + +execute_when_enabled(Fun) -> + Enabled = case emqx_modules:find_module(emqx_mod_topic_metrics) of + [{_, false}] -> false; + [{_, true}] -> true + end, + case Enabled of + true -> + Fun(); + false -> + return({error, module_not_loaded}) + end. + +safe_validate(Topic) -> + try emqx_topic:validate(name, Topic) of + true -> true + catch + error:_Error -> + false + end. diff --git a/apps/emqx_management/src/emqx_mgmt_app.erl b/apps/emqx_management/src/emqx_mgmt_app.erl new file mode 100644 index 000000000..7217304b2 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_app.erl @@ -0,0 +1,35 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_app). + +-behaviour(application). + +-emqx_plugin(?MODULE). + +-export([ start/2 + , stop/1 + ]). + +start(_Type, _Args) -> + {ok, Sup} = emqx_mgmt_sup:start_link(), + emqx_mgmt_auth:add_default_app(), + emqx_mgmt_http:start_listeners(), + emqx_mgmt_cli:load(), + {ok, Sup}. + +stop(_State) -> + emqx_mgmt_http:stop_listeners(). diff --git a/apps/emqx_management/src/emqx_mgmt_auth.erl b/apps/emqx_management/src/emqx_mgmt_auth.erl new file mode 100644 index 000000000..f33eea453 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_auth.erl @@ -0,0 +1,210 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_auth). + +%% Mnesia Bootstrap +-export([mnesia/1]). +-boot_mnesia({mnesia, [boot]}). +-copy_mnesia({mnesia, [copy]}). + +%% APP Management API +-export([ add_default_app/0 + , add_app/2 + , add_app/5 + , add_app/6 + , force_add_app/6 + , lookup_app/1 + , get_appsecret/1 + , update_app/2 + , update_app/5 + , del_app/1 + , list_apps/0 + ]). + +%% APP Auth/ACL API +-export([is_authorized/2]). + +-define(APP, emqx_management). + +-record(mqtt_app, {id, secret, name, desc, status, expired}). + +-type(appid() :: binary()). + +-type(appsecret() :: binary()). + +%%-------------------------------------------------------------------- +%% Mnesia Bootstrap +%%-------------------------------------------------------------------- + +mnesia(boot) -> + ok = ekka_mnesia:create_table(mqtt_app, [ + {disc_copies, [node()]}, + {record_name, mqtt_app}, + {attributes, record_info(fields, mqtt_app)}]); + +mnesia(copy) -> + ok = ekka_mnesia:copy_table(mqtt_app, disc_copies). + +%%-------------------------------------------------------------------- +%% Manage Apps +%%-------------------------------------------------------------------- +-spec(add_default_app() -> ok | {ok, appsecret()} | {error, term()}). +add_default_app() -> + AppId = application:get_env(?APP, default_application_id, undefined), + AppSecret = application:get_env(?APP, default_application_secret, undefined), + case {AppId, AppSecret} of + {undefined, _} -> ok; + {_, undefined} -> ok; + {_, _} -> + AppId1 = erlang:list_to_binary(AppId), + AppSecret1 = erlang:list_to_binary(AppSecret), + add_app(AppId1, <<"Default">>, AppSecret1, <<"Application user">>, true, undefined) + end. + +-spec(add_app(appid(), binary()) -> {ok, appsecret()} | {error, term()}). +add_app(AppId, Name) when is_binary(AppId) -> + add_app(AppId, Name, <<"Application user">>, true, undefined). + +-spec(add_app(appid(), binary(), binary(), boolean(), integer() | undefined) + -> {ok, appsecret()} + | {error, term()}). +add_app(AppId, Name, Desc, Status, Expired) when is_binary(AppId) -> + add_app(AppId, Name, undefined, Desc, Status, Expired). + +-spec(add_app(appid(), binary(), binary(), binary(), boolean(), integer() | undefined) + -> {ok, appsecret()} + | {error, term()}). +add_app(AppId, Name, Secret, Desc, Status, Expired) when is_binary(AppId) -> + Secret1 = generate_appsecret_if_need(Secret), + App = #mqtt_app{id = AppId, + secret = Secret1, + name = Name, + desc = Desc, + status = Status, + expired = Expired}, + AddFun = fun() -> + case mnesia:wread({mqtt_app, AppId}) of + [] -> mnesia:write(App); + _ -> mnesia:abort(alread_existed) + end + end, + case mnesia:transaction(AddFun) of + {atomic, ok} -> {ok, Secret1}; + {aborted, Reason} -> {error, Reason} + end. + +force_add_app(AppId, Name, Secret, Desc, Status, Expired) -> + AddFun = fun() -> + mnesia:write(#mqtt_app{id = AppId, + secret = Secret, + name = Name, + desc = Desc, + status = Status, + expired = Expired}) + end, + case mnesia:transaction(AddFun) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end. + +-spec(generate_appsecret_if_need(binary() | undefined) -> binary()). +generate_appsecret_if_need(InSecrt) when is_binary(InSecrt), byte_size(InSecrt) > 0 -> + InSecrt; +generate_appsecret_if_need(_) -> + AppConf = application:get_env(?APP, application, []), + case proplists:get_value(default_secret, AppConf) of + undefined -> emqx_guid:to_base62(emqx_guid:gen()); + Secret when is_binary(Secret) -> Secret + end. + +-spec(get_appsecret(appid()) -> {appsecret() | undefined}). +get_appsecret(AppId) when is_binary(AppId) -> + case mnesia:dirty_read(mqtt_app, AppId) of + [#mqtt_app{secret = Secret}] -> Secret; + [] -> undefined + end. + +-spec(lookup_app(appid()) -> {{appid(), appsecret(), binary(), binary(), boolean(), integer() | undefined} | undefined}). +lookup_app(AppId) when is_binary(AppId) -> + case mnesia:dirty_read(mqtt_app, AppId) of + [#mqtt_app{id = AppId, + secret = AppSecret, + name = Name, + desc = Desc, + status = Status, + expired = Expired}] -> {AppId, AppSecret, Name, Desc, Status, Expired}; + [] -> undefined + end. + +-spec(update_app(appid(), boolean()) -> ok | {error, term()}). +update_app(AppId, Status) -> + case mnesia:dirty_read(mqtt_app, AppId) of + [App = #mqtt_app{}] -> + case mnesia:transaction(fun() -> mnesia:write(App#mqtt_app{status = Status}) end) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end; + [] -> + {error, not_found} + end. + +-spec(update_app(appid(), binary(), binary(), boolean(), integer() | undefined) -> ok | {error, term()}). +update_app(AppId, Name, Desc, Status, Expired) -> + case mnesia:dirty_read(mqtt_app, AppId) of + [App = #mqtt_app{}] -> + case mnesia:transaction(fun() -> mnesia:write(App#mqtt_app{name = Name, + desc = Desc, + status = Status, + expired = Expired}) end) of + {atomic, ok} -> ok; + {aborted, Reason} -> {error, Reason} + end; + [] -> + {error, not_found} + end. + +-spec(del_app(appid()) -> ok | {error, term()}). +del_app(AppId) when is_binary(AppId) -> + case mnesia:transaction(fun mnesia:delete/1, [{mqtt_app, AppId}]) of + {atomic, Ok} -> Ok; + {aborted, Reason} -> {error, Reason} + end. + +-spec(list_apps() -> [{appid(), appsecret(), binary(), binary(), boolean(), integer() | undefined}]). +list_apps() -> + [ {AppId, AppSecret, Name, Desc, Status, Expired} || #mqtt_app{id = AppId, + secret = AppSecret, + name = Name, + desc = Desc, + status = Status, + expired = Expired} <- ets:tab2list(mqtt_app) ]. +%%-------------------------------------------------------------------- +%% Authenticate App +%%-------------------------------------------------------------------- + +-spec(is_authorized(appid(), appsecret()) -> boolean()). +is_authorized(AppId, AppSecret) -> + case lookup_app(AppId) of + {_, AppSecret1, _, _, Status, Expired} -> + Status andalso is_expired(Expired) andalso AppSecret =:= AppSecret1; + _ -> + false + end. + +is_expired(undefined) -> true; +is_expired(Expired) -> Expired >= erlang:system_time(second). + diff --git a/apps/emqx_management/src/emqx_mgmt_cli.erl b/apps/emqx_management/src/emqx_mgmt_cli.erl new file mode 100644 index 000000000..ae9b16459 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_cli.erl @@ -0,0 +1,715 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_cli). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-include("emqx_mgmt.hrl"). + +-define(PRINT_CMD(Cmd, Descr), io:format("~-48s# ~s~n", [Cmd, Descr])). + +-import(lists, [foreach/2]). + +-export([load/0]). + +-export([ status/1 + , broker/1 + , cluster/1 + , clients/1 + , routes/1 + , subscriptions/1 + , plugins/1 + , listeners/1 + , vm/1 + , mnesia/1 + , trace/1 + , log/1 + , mgmt/1 + , data/1 + , modules/1 + ]). + +-define(PROC_INFOKEYS, [status, + memory, + message_queue_len, + total_heap_size, + heap_size, + stack_size, + reductions]). + +-define(MAX_LIMIT, 10000). + +-define(APP, emqx). + +-spec(load() -> ok). +load() -> + Cmds = [Fun || {Fun, _} <- ?MODULE:module_info(exports), is_cmd(Fun)], + lists:foreach(fun(Cmd) -> emqx_ctl:register_command(Cmd, {?MODULE, Cmd}, []) end, Cmds). + +is_cmd(Fun) -> + not lists:member(Fun, [init, load, module_info]). + +mgmt(["insert", AppId, Name]) -> + case emqx_mgmt_auth:add_app(list_to_binary(AppId), list_to_binary(Name)) of + {ok, Secret} -> + emqx_ctl:print("AppSecret: ~s~n", [Secret]); + {error, already_existed} -> + emqx_ctl:print("Error: already existed~n"); + {error, Reason} -> + emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +mgmt(["lookup", AppId]) -> + case emqx_mgmt_auth:lookup_app(list_to_binary(AppId)) of + {AppId1, AppSecret, Name, Desc, Status, Expired} -> + emqx_ctl:print("app_id: ~s~nsecret: ~s~nname: ~s~ndesc: ~s~nstatus: ~s~nexpired: ~p~n", + [AppId1, AppSecret, Name, Desc, Status, Expired]); + undefined -> + emqx_ctl:print("Not Found.~n") + end; + +mgmt(["update", AppId, Status]) -> + case emqx_mgmt_auth:update_app(list_to_binary(AppId), list_to_atom(Status)) of + ok -> + emqx_ctl:print("update successfully.~n"); + {error, Reason} -> + emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +mgmt(["delete", AppId]) -> + case emqx_mgmt_auth:del_app(list_to_binary(AppId)) of + ok -> emqx_ctl:print("ok~n"); + {error, not_found} -> + emqx_ctl:print("Error: app not found~n"); + {error, Reason} -> + emqx_ctl:print("Error: ~p~n", [Reason]) + end; + +mgmt(["list"]) -> + lists:foreach(fun({AppId, AppSecret, Name, Desc, Status, Expired}) -> + emqx_ctl:print("app_id: ~s, secret: ~s, name: ~s, desc: ~s, status: ~s, expired: ~p~n", + [AppId, AppSecret, Name, Desc, Status, Expired]) + end, emqx_mgmt_auth:list_apps()); + +mgmt(_) -> + emqx_ctl:usage([{"mgmt list", "List Applications"}, + {"mgmt insert ", "Add Application of REST API"}, + {"mgmt update ", "Update Application of REST API"}, + {"mgmt lookup ", "Get Application of REST API"}, + {"mgmt delete ", "Delete Application of REST API"}]). + +%%-------------------------------------------------------------------- +%% @doc Node status + +status([]) -> + {InternalStatus, _ProvidedStatus} = init:get_status(), + emqx_ctl:print("Node ~p is ~p~n", [node(), InternalStatus]), + case lists:keysearch(?APP, 1, application:which_applications()) of + false -> + emqx_ctl:print("~s is not running~n", [?APP]); + {value, {?APP, _Desc, Vsn}} -> + emqx_ctl:print("~s ~s is running~n", [?APP, Vsn]) + end; +status(_) -> + emqx_ctl:usage("status", "Show broker status"). + +%%-------------------------------------------------------------------- +%% @doc Query broker + +broker([]) -> + Funs = [sysdescr, version, uptime, datetime], + [emqx_ctl:print("~-10s: ~s~n", [Fun, emqx_sys:Fun()]) || Fun <- Funs]; + +broker(["stats"]) -> + [emqx_ctl:print("~-30s: ~w~n", [Stat, Val]) || {Stat, Val} <- lists:sort(emqx_stats:getstats())]; + +broker(["metrics"]) -> + [emqx_ctl:print("~-30s: ~w~n", [Metric, Val]) || {Metric, Val} <- lists:sort(emqx_metrics:all())]; + +broker(_) -> + emqx_ctl:usage([{"broker", "Show broker version, uptime and description"}, + {"broker stats", "Show broker statistics of clients, topics, subscribers"}, + {"broker metrics", "Show broker metrics"}]). + +%%----------------------------------------------------------------------------- +%% @doc Cluster with other nodes + +cluster(["join", SNode]) -> + case ekka:join(ekka_node:parse_name(SNode)) of + ok -> + emqx_ctl:print("Join the cluster successfully.~n"), + cluster(["status"]); + ignore -> + emqx_ctl:print("Ignore.~n"); + {error, Error} -> + emqx_ctl:print("Failed to join the cluster: ~p~n", [Error]) + end; + +cluster(["leave"]) -> + case ekka:leave() of + ok -> + emqx_ctl:print("Leave the cluster successfully.~n"), + cluster(["status"]); + {error, Error} -> + emqx_ctl:print("Failed to leave the cluster: ~p~n", [Error]) + end; + +cluster(["force-leave", SNode]) -> + case ekka:force_leave(ekka_node:parse_name(SNode)) of + ok -> + emqx_ctl:print("Remove the node from cluster successfully.~n"), + cluster(["status"]); + ignore -> + emqx_ctl:print("Ignore.~n"); + {error, Error} -> + emqx_ctl:print("Failed to remove the node from cluster: ~p~n", [Error]) + end; + +cluster(["status"]) -> + emqx_ctl:print("Cluster status: ~p~n", [ekka_cluster:info()]); + +cluster(_) -> + emqx_ctl:usage([{"cluster join ", "Join the cluster"}, + {"cluster leave", "Leave the cluster"}, + {"cluster force-leave ","Force the node leave from cluster"}, + {"cluster status", "Cluster status"}]). + +%%-------------------------------------------------------------------- +%% @doc Query clients + +clients(["list"]) -> + dump(emqx_channel, client); + +clients(["show", ClientId]) -> + if_client(ClientId, fun print/1); + +clients(["kick", ClientId]) -> + case emqx_cm:kick_session(bin(ClientId)) of + ok -> emqx_ctl:print("ok~n"); + _ -> emqx_ctl:print("Not Found.~n") + end; + +clients(_) -> + emqx_ctl:usage([{"clients list", "List all clients"}, + {"clients show ", "Show a client"}, + {"clients kick ", "Kick out a client"}]). + +if_client(ClientId, Fun) -> + case ets:lookup(emqx_channel, (bin(ClientId))) of + [] -> emqx_ctl:print("Not Found.~n"); + [Channel] -> Fun({client, Channel}) + end. + +%%-------------------------------------------------------------------- +%% @doc Routes Command + +routes(["list"]) -> + dump(emqx_route); + +routes(["show", Topic]) -> + Routes = ets:lookup(emqx_route, bin(Topic)), + [print({emqx_route, Route}) || Route <- Routes]; + +routes(_) -> + emqx_ctl:usage([{"routes list", "List all routes"}, + {"routes show ", "Show a route"}]). + +subscriptions(["list"]) -> + lists:foreach(fun(Suboption) -> + print({emqx_suboption, Suboption}) + end, ets:tab2list(emqx_suboption)); + +subscriptions(["show", ClientId]) -> + case ets:lookup(emqx_subid, bin(ClientId)) of + [] -> + emqx_ctl:print("Not Found.~n"); + [{_, Pid}] -> + case ets:match_object(emqx_suboption, {{Pid, '_'}, '_'}) of + [] -> emqx_ctl:print("Not Found.~n"); + Suboption -> + [print({emqx_suboption, Sub}) || Sub <- Suboption] + end + end; + +subscriptions(["add", ClientId, Topic, QoS]) -> + if_valid_qos(QoS, fun(IntQos) -> + case ets:lookup(emqx_channel, bin(ClientId)) of + [] -> emqx_ctl:print("Error: Channel not found!"); + [{_, Pid}] -> + {Topic1, Options} = emqx_topic:parse(bin(Topic)), + Pid ! {subscribe, [{Topic1, Options#{qos => IntQos}}]}, + emqx_ctl:print("ok~n") + end + end); + +subscriptions(["del", ClientId, Topic]) -> + case ets:lookup(emqx_channel, bin(ClientId)) of + [] -> emqx_ctl:print("Error: Channel not found!"); + [{_, Pid}] -> + Pid ! {unsubscribe, [emqx_topic:parse(bin(Topic))]}, + emqx_ctl:print("ok~n") + end; + +subscriptions(_) -> + emqx_ctl:usage([{"subscriptions list", "List all subscriptions"}, + {"subscriptions show ", "Show subscriptions of a client"}, + {"subscriptions add ", "Add a static subscription manually"}, + {"subscriptions del ", "Delete a static subscription manually"}]). + +if_valid_qos(QoS, Fun) -> + try list_to_integer(QoS) of + Int when ?IS_QOS(Int) -> Fun(Int); + _ -> emqx_ctl:print("QoS should be 0, 1, 2~n") + catch _:_ -> + emqx_ctl:print("QoS should be 0, 1, 2~n") + end. + +plugins(["list"]) -> + foreach(fun print/1, emqx_plugins:list()); + +plugins(["load", Name]) -> + case emqx_plugins:load(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Plugin ~s loaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Load plugin ~s error: ~p.~n", [Name, Reason]) + end; + +plugins(["unload", "emqx_management"])-> + emqx_ctl:print("Plugin emqx_management can not be unloaded.~n"); + +plugins(["unload", Name]) -> + case emqx_plugins:unload(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Plugin ~s unloaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Unload plugin ~s error: ~p.~n", [Name, Reason]) + end; + +plugins(["reload", Name]) -> + try list_to_existing_atom(Name) of + PluginName -> + case emqx_mgmt:reload_plugin(node(), PluginName) of + ok -> + emqx_ctl:print("Plugin ~s reloaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Reload plugin ~s error: ~p.~n", [Name, Reason]) + end + catch + error:badarg -> + emqx_ctl:print("Reload plugin ~s error: The plugin doesn't exist.~n", [Name]) + end; + +plugins(_) -> + emqx_ctl:usage([{"plugins list", "Show loaded plugins"}, + {"plugins load ", "Load plugin"}, + {"plugins unload ", "Unload plugin"}, + {"plugins reload ", "Reload plugin"} + ]). + +%%-------------------------------------------------------------------- +%% @doc Modules Command +modules(["list"]) -> + foreach(fun(Module) -> print({module, Module}) end, emqx_modules:list()); + +modules(["load", Name]) -> + case emqx_modules:load(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Module ~s loaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Load module ~s error: ~p.~n", [Name, Reason]) + end; + +modules(["unload", Name]) -> + case emqx_modules:unload(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Module ~s unloaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Unload module ~s error: ~p.~n", [Name, Reason]) + end; + +modules(["reload", "emqx_mod_acl_internal" = Name]) -> + case emqx_modules:reload(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Module ~s reloaded successfully.~n", [Name]); + {error, Reason} -> + emqx_ctl:print("Reload module ~s error: ~p.~n", [Name, Reason]) + end; +modules(["reload", Name]) -> + emqx_ctl:print("Module: ~p does not need to be reloaded.~n", [Name]); + +modules(_) -> + emqx_ctl:usage([{"modules list", "Show loaded modules"}, + {"modules load ", "Load module"}, + {"modules unload ", "Unload module"}, + {"modules reload ", "Reload module"} + ]). + +%%-------------------------------------------------------------------- +%% @doc vm command + +vm([]) -> + vm(["all"]); + +vm(["all"]) -> + [vm([Name]) || Name <- ["load", "memory", "process", "io", "ports"]]; + +vm(["load"]) -> + [emqx_ctl:print("cpu/~-20s: ~s~n", [L, V]) || {L, V} <- emqx_vm:loads()]; + +vm(["memory"]) -> + [emqx_ctl:print("memory/~-17s: ~w~n", [Cat, Val]) || {Cat, Val} <- erlang:memory()]; + +vm(["process"]) -> + [emqx_ctl:print("process/~-16s: ~w~n", [Name, erlang:system_info(Key)]) || {Name, Key} <- [{limit, process_limit}, {count, process_count}]]; + +vm(["io"]) -> + IoInfo = lists:usort(lists:flatten(erlang:system_info(check_io))), + [emqx_ctl:print("io/~-21s: ~w~n", [Key, proplists:get_value(Key, IoInfo)]) || Key <- [max_fds, active_fds]]; + +vm(["ports"]) -> + [emqx_ctl:print("ports/~-16s: ~w~n", [Name, erlang:system_info(Key)]) || {Name, Key} <- [{count, port_count}, {limit, port_limit}]]; + +vm(_) -> + emqx_ctl:usage([{"vm all", "Show info of Erlang VM"}, + {"vm load", "Show load of Erlang VM"}, + {"vm memory", "Show memory of Erlang VM"}, + {"vm process", "Show process of Erlang VM"}, + {"vm io", "Show IO of Erlang VM"}, + {"vm ports", "Show Ports of Erlang VM"}]). + +%%-------------------------------------------------------------------- +%% @doc mnesia Command + +mnesia([]) -> + mnesia:system_info(); + +mnesia(_) -> + emqx_ctl:usage([{"mnesia", "Mnesia system info"}]). + +%%-------------------------------------------------------------------- +%% @doc Logger Command + +log(["set-level", Level]) -> + case emqx_logger:set_log_level(list_to_atom(Level)) of + ok -> emqx_ctl:print("~s~n", [Level]); + Error -> emqx_ctl:print("[error] set overall log level failed: ~p~n", [Error]) + end; + +log(["primary-level"]) -> + Level = emqx_logger:get_primary_log_level(), + emqx_ctl:print("~s~n", [Level]); + +log(["primary-level", Level]) -> + emqx_logger:set_primary_log_level(list_to_atom(Level)), + emqx_ctl:print("~s~n", [emqx_logger:get_primary_log_level()]); + +log(["handlers", "list"]) -> + [emqx_ctl:print("LogHandler(id=~s, level=~s, destination=~s, status=~s)~n", [Id, Level, Dst, Status]) + || #{id := Id, level := Level, dst := Dst, status := Status} <- emqx_logger:get_log_handlers()], + ok; + +log(["handlers", "start", HandlerId]) -> + case emqx_logger:start_log_handler(list_to_atom(HandlerId)) of + ok -> emqx_ctl:print("log handler ~s started~n", [HandlerId]); + {error, Reason} -> + emqx_ctl:print("[error] failed to start log handler ~s: ~p~n", [HandlerId, Reason]) + end; + +log(["handlers", "stop", HandlerId]) -> + case emqx_logger:stop_log_handler(list_to_atom(HandlerId)) of + ok -> emqx_ctl:print("log handler ~s stopped~n", [HandlerId]); + {error, Reason} -> + emqx_ctl:print("[error] failed to stop log handler ~s: ~p~n", [HandlerId, Reason]) + end; + +log(["handlers", "set-level", HandlerId, Level]) -> + case emqx_logger:set_log_handler_level(list_to_atom(HandlerId), list_to_atom(Level)) of + ok -> + #{level := NewLevel} = emqx_logger:get_log_handler(list_to_atom(HandlerId)), + emqx_ctl:print("~s~n", [NewLevel]); + {error, Error} -> + emqx_ctl:print("[error] ~p~n", [Error]) + end; + +log(_) -> + emqx_ctl:usage([{"log set-level ", "Set the overall log level"}, + {"log primary-level", "Show the primary log level now"}, + {"log primary-level ","Set the primary log level"}, + {"log handlers list", "Show log handlers"}, + {"log handlers start ", "Start a log handler"}, + {"log handlers stop ", "Stop a log handler"}, + {"log handlers set-level ", "Set log level of a log handler"}]). + +%%-------------------------------------------------------------------- +%% @doc Trace Command + +trace(["list"]) -> + foreach(fun({{Who, Name}, {Level, LogFile}}) -> + emqx_ctl:print("Trace(~s=~s, level=~s, destination=~p)~n", [Who, Name, Level, LogFile]) + end, emqx_tracer:lookup_traces()); + +trace(["stop", "client", ClientId]) -> + trace_off(clientid, ClientId); + +trace(["start", "client", ClientId, LogFile]) -> + trace_on(clientid, ClientId, all, LogFile); + +trace(["start", "client", ClientId, LogFile, Level]) -> + trace_on(clientid, ClientId, list_to_atom(Level), LogFile); + +trace(["stop", "topic", Topic]) -> + trace_off(topic, Topic); + +trace(["start", "topic", Topic, LogFile]) -> + trace_on(topic, Topic, all, LogFile); + +trace(["start", "topic", Topic, LogFile, Level]) -> + trace_on(topic, Topic, list_to_atom(Level), LogFile); + +trace(_) -> + emqx_ctl:usage([{"trace list", "List all traces started"}, + {"trace start client []", "Traces for a client"}, + {"trace stop client ", "Stop tracing for a client"}, + {"trace start topic [] ", "Traces for a topic"}, + {"trace stop topic ", "Stop tracing for a topic"}]). + +trace_on(Who, Name, Level, LogFile) -> + case emqx_tracer:start_trace({Who, iolist_to_binary(Name)}, Level, LogFile) of + ok -> + emqx_ctl:print("trace ~s ~s successfully~n", [Who, Name]); + {error, Error} -> + emqx_ctl:print("[error] trace ~s ~s: ~p~n", [Who, Name, Error]) + end. + +trace_off(Who, Name) -> + case emqx_tracer:stop_trace({Who, iolist_to_binary(Name)}) of + ok -> + emqx_ctl:print("stop tracing ~s ~s successfully~n", [Who, Name]); + {error, Error} -> + emqx_ctl:print("[error] stop tracing ~s ~s: ~p~n", [Who, Name, Error]) + end. + +%%-------------------------------------------------------------------- +%% @doc Listeners Command + +listeners([]) -> + foreach(fun({{Protocol, ListenOn}, _Pid}) -> + Info = [{acceptors, esockd:get_acceptors({Protocol, ListenOn})}, + {max_conns, esockd:get_max_connections({Protocol, ListenOn})}, + {current_conn, esockd:get_current_connections({Protocol, ListenOn})}, + {shutdown_count, esockd:get_shutdown_count({Protocol, ListenOn})}], + emqx_ctl:print("listener on ~s:~s~n", [Protocol, esockd:to_string(ListenOn)]), + foreach(fun({Key, Val}) -> + emqx_ctl:print(" ~-16s: ~w~n", [Key, Val]) + end, Info) + end, esockd:listeners()), + foreach(fun({Protocol, Opts}) -> + Info = [{acceptors, maps:get(num_acceptors, proplists:get_value(transport_options, Opts, #{}), 0)}, + {max_conns, proplists:get_value(max_connections, Opts)}, + {current_conn, proplists:get_value(all_connections, Opts)}, + {shutdown_count, []}], + emqx_ctl:print("listener on ~s:~p~n", [Protocol, proplists:get_value(port, Opts)]), + foreach(fun({Key, Val}) -> + emqx_ctl:print(" ~-16s: ~w~n", [Key, Val]) + end, Info) + end, ranch:info()); + +listeners(["stop", Name = "http" ++ _N, ListenOn]) -> + case minirest:stop_http(list_to_atom(Name)) of + ok -> + emqx_ctl:print("Stop ~s listener on ~s successfully.~n", [Name, ListenOn]); + {error, Error} -> + emqx_ctl:print("Failed to stop ~s listener on ~s, error:~p~n", [Name, ListenOn, Error]) + end; + +listeners(["stop", Proto, ListenOn]) -> + ListenOn1 = case string:tokens(ListenOn, ":") of + [Port] -> list_to_integer(Port); + [IP, Port] -> {IP, list_to_integer(Port)} + end, + case emqx_listeners:stop_listener({list_to_atom(Proto), ListenOn1, []}) of + ok -> + emqx_ctl:print("Stop ~s listener on ~s successfully.~n", [Proto, ListenOn]); + {error, Error} -> + emqx_ctl:print("Failed to stop ~s listener on ~s, error:~p~n", [Proto, ListenOn, Error]) + end; + +listeners(_) -> + emqx_ctl:usage([{"listeners", "List listeners"}, + {"listeners stop ", "Stop a listener"}]). + +%%-------------------------------------------------------------------- +%% @doc data Command + +data(["export"]) -> + Rules = emqx_mgmt:export_rules(), + Resources = emqx_mgmt:export_resources(), + Blacklist = emqx_mgmt:export_blacklist(), + Apps = emqx_mgmt:export_applications(), + Users = emqx_mgmt:export_users(), + AuthClientID = emqx_mgmt:export_auth_clientid(), + AuthUsername = emqx_mgmt:export_auth_username(), + AuthMnesia = emqx_mgmt:export_auth_mnesia(), + AclMnesia = emqx_mgmt:export_acl_mnesia(), + Schemas = emqx_mgmt:export_schemas(), + Seconds = erlang:system_time(second), + {{Y, M, D}, {H, MM, S}} = emqx_mgmt_util:datetime(Seconds), + Filename = io_lib:format("emqx-export-~p-~p-~p-~p-~p-~p.json", [Y, M, D, H, MM, S]), + NFilename = filename:join([emqx:get_env(data_dir), Filename]), + Version = string:sub_string(emqx_sys:version(), 1, 3), + Data = [{version, erlang:list_to_binary(Version)}, + {date, erlang:list_to_binary(emqx_mgmt_util:strftime(Seconds))}, + {rules, Rules}, + {resources, Resources}, + {blacklist, Blacklist}, + {apps, Apps}, + {users, Users}, + {auth_clientid, AuthClientID}, + {auth_username, AuthUsername}, + {auth_mnesia, AuthMnesia}, + {acl_mnesia, AclMnesia}, + {schemas, Schemas}], + case file:write_file(NFilename, emqx_json:encode(Data)) of + ok -> + emqx_ctl:print("The emqx data has been successfully exported to ~s.~n", [NFilename]); + {error, Reason} -> + emqx_ctl:print("The emqx data export failed due to ~p.~n", [Reason]) + end; + +data(["import", Filename]) -> + case file:read_file(Filename) of + {ok, Json} -> + Data = emqx_json:decode(Json, [return_maps]), + Version = emqx_mgmt:to_version(maps:get(<<"version">>, Data)), + case lists:member(Version, ?VERSIONS) of + true -> + try + emqx_mgmt:import_resources(maps:get(<<"resources">>, Data, [])), + emqx_mgmt:import_rules(maps:get(<<"rules">>, Data, [])), + emqx_mgmt:import_blacklist(maps:get(<<"blacklist">>, Data, [])), + emqx_mgmt:import_applications(maps:get(<<"apps">>, Data, [])), + emqx_mgmt:import_users(maps:get(<<"users">>, Data, [])), + emqx_mgmt:import_auth_clientid(maps:get(<<"auth_clientid">>, Data, [])), + emqx_mgmt:import_auth_username(maps:get(<<"auth_username">>, Data, [])), + emqx_mgmt:import_auth_mnesia(maps:get(<<"auth_mnesia">>, Data, [])), + emqx_mgmt:import_acl_mnesia(maps:get(<<"acl_mnesia">>, Data, [])), + emqx_mgmt:import_schemas(maps:get(<<"schemas">>, Data, [])), + emqx_ctl:print("The emqx data has been imported successfully.~n") + catch Class:Reason:Stack -> + emqx_ctl:print("The emqx data import failed due: ~0p~n", [{Class,Reason,Stack}]) + end; + false -> + emqx_ctl:print("Unsupported version: ~p~n", [Version]) + end; + {error, Reason} -> + emqx_ctl:print("The emqx data import failed: ~0p while reading ~s.~n", [Reason, Filename]) + end; + +data(_) -> + emqx_ctl:usage([{"data import ", "Import data from the specified file"}, + {"data export", "Export data"}]). + +%%-------------------------------------------------------------------- +%% Dump ETS +%%-------------------------------------------------------------------- + +dump(Table) -> + dump(Table, Table, ets:first(Table), []). + +dump(Table, Tag) -> + dump(Table, Tag, ets:first(Table), []). + +dump(_Table, _, '$end_of_table', Result) -> + lists:reverse(Result); + +dump(Table, Tag, Key, Result) -> + PrintValue = [print({Tag, Record}) || Record <- ets:lookup(Table, Key)], + dump(Table, Tag, ets:next(Table, Key), [PrintValue | Result]). + +print({_, []}) -> + ok; + +print({client, {ClientId, ChanPid}}) -> + Attrs = case emqx_cm:get_chan_info(ClientId, ChanPid) of + undefined -> #{}; + Attrs0 -> Attrs0 + end, + Stats = case emqx_cm:get_chan_stats(ClientId, ChanPid) of + undefined -> #{}; + Stats0 -> maps:from_list(Stats0) + end, + ClientInfo = maps:get(clientinfo, Attrs, #{}), + ConnInfo = maps:get(conninfo, Attrs, #{}), + Session = maps:get(session, Attrs, #{}), + Connected = case maps:get(conn_state, Attrs) of + connected -> true; + _ -> false + end, + Info = lists:foldl(fun(Items, Acc) -> + maps:merge(Items, Acc) + end, #{connected => Connected}, + [maps:with([subscriptions_cnt, inflight_cnt, awaiting_rel_cnt, + mqueue_len, mqueue_dropped, send_msg], Stats), + maps:with([clientid, username], ClientInfo), + maps:with([peername, clean_start, keepalive, expiry_interval, + connected_at, disconnected_at], ConnInfo), + maps:with([created_at], Session)]), + InfoKeys = [clientid, username, peername, + clean_start, keepalive, expiry_interval, + subscriptions_cnt, inflight_cnt, awaiting_rel_cnt, send_msg, mqueue_len, mqueue_dropped, + connected, created_at, connected_at] ++ case maps:is_key(disconnected_at, Info) of + true -> [disconnected_at]; + false -> [] + end, + emqx_ctl:print("Client(~s, username=~s, peername=~s, " + "clean_start=~s, keepalive=~w, session_expiry_interval=~w, " + "subscriptions=~w, inflight=~w, awaiting_rel=~w, delivered_msgs=~w, enqueued_msgs=~w, dropped_msgs=~w, " + "connected=~s, created_at=~w, connected_at=~w" ++ case maps:is_key(disconnected_at, Info) of + true -> ", disconnected_at=~w)~n"; + false -> ")~n" + end, + [format(K, maps:get(K, Info)) || K <- InfoKeys]); + +print({emqx_route, #route{topic = Topic, dest = {_, Node}}}) -> + emqx_ctl:print("~s -> ~s~n", [Topic, Node]); +print({emqx_route, #route{topic = Topic, dest = Node}}) -> + emqx_ctl:print("~s -> ~s~n", [Topic, Node]); + +print(#plugin{name = Name, descr = Descr, active = Active}) -> + emqx_ctl:print("Plugin(~s, description=~s, active=~s)~n", + [Name, Descr, Active]); + +print({module, {Name, Active}}) -> + emqx_ctl:print("Module(~s, description=~s, active=~s)~n", + [Name, Name:description(), Active]); + +print({emqx_suboption, {{Pid, Topic}, Options}}) when is_pid(Pid) -> + emqx_ctl:print("~s -> ~s~n", [maps:get(subid, Options), Topic]). + +format(_, undefined) -> + undefined; + +format(peername, {IPAddr, Port}) -> + IPStr = emqx_mgmt_util:ntoa(IPAddr), + io_lib:format("~s:~p", [IPStr, Port]); + +format(_, Val) -> + Val. + +bin(S) -> iolist_to_binary(S). diff --git a/apps/emqx_management/src/emqx_mgmt_http.erl b/apps/emqx_management/src/emqx_mgmt_http.erl new file mode 100644 index 000000000..3d91a26ad --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_http.erl @@ -0,0 +1,125 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_http). + +-import(proplists, [get_value/3]). + +-export([ start_listeners/0 + , handle_request/2 + , stop_listeners/0 + ]). + +-export([init/2]). + +-include_lib("emqx/include/emqx.hrl"). + +-define(APP, emqx_management). +-define(EXCEPT_PLUGIN, [emqx_dashboard]). +-ifdef(TEST). +-define(EXCEPT, []). +-else. +-define(EXCEPT, [add_app, del_app, list_apps, lookup_app, update_app]). +-endif. + +%%-------------------------------------------------------------------- +%% Start/Stop Listeners +%%-------------------------------------------------------------------- + +start_listeners() -> + lists:foreach(fun start_listener/1, listeners()). + +stop_listeners() -> + lists:foreach(fun stop_listener/1, listeners()). + +start_listener({Proto, Port, Options}) when Proto == http -> + Dispatch = [{"/status", emqx_mgmt_http, []}, + {"/api/v4/[...]", minirest, http_handlers()}], + minirest:start_http(listener_name(Proto), ranch_opts(Port, Options), Dispatch); + +start_listener({Proto, Port, Options}) when Proto == https -> + Dispatch = [{"/status", emqx_mgmt_http, []}, + {"/api/v4/[...]", minirest, http_handlers()}], + minirest:start_https(listener_name(Proto), ranch_opts(Port, Options), Dispatch). + +ranch_opts(Port, Options0) -> + NumAcceptors = get_value(num_acceptors, Options0, 4), + MaxConnections = get_value(max_connections, Options0, 512), + Options = lists:foldl(fun({K, _V}, Acc) when K =:= max_connections orelse K =:= num_acceptors -> + Acc; + ({inet6, true}, Acc) -> [inet6 | Acc]; + ({inet6, false}, Acc) -> Acc; + ({ipv6_v6only, true}, Acc) -> [{ipv6_v6only, true} | Acc]; + ({ipv6_v6only, false}, Acc) -> Acc; + ({K, V}, Acc)-> + [{K, V} | Acc] + end, [], Options0), + + Res = #{num_acceptors => NumAcceptors, + max_connections => MaxConnections, + socket_opts => [{port, Port} | Options]}, + Res. + +stop_listener({Proto, _Port, _}) -> + minirest:stop_http(listener_name(Proto)). + +listeners() -> + application:get_env(?APP, listeners, []). + +listener_name(Proto) -> + list_to_atom(atom_to_list(Proto) ++ ":management"). + +http_handlers() -> + Plugins = lists:map(fun(Plugin) -> Plugin#plugin.name end, emqx_plugins:list()), + [{"/api/v4", minirest:handler(#{apps => Plugins -- ?EXCEPT_PLUGIN, + except => ?EXCEPT, + filter => fun filter/1}), + [{authorization, fun authorize_appid/1}]}]. + +%%-------------------------------------------------------------------- +%% Handle 'status' request +%%-------------------------------------------------------------------- +init(Req, Opts) -> + Req1 = handle_request(cowboy_req:path(Req), Req), + {ok, Req1, Opts}. + +handle_request(Path, Req) -> + handle_request(cowboy_req:method(Req), Path, Req). + +handle_request(<<"GET">>, <<"/status">>, Req) -> + {InternalStatus, _ProvidedStatus} = init:get_status(), + AppStatus = case lists:keysearch(emqx, 1, application:which_applications()) of + false -> not_running; + {value, _Val} -> running + end, + Status = io_lib:format("Node ~s is ~s~nemqx is ~s", + [node(), InternalStatus, AppStatus]), + cowboy_req:reply(200, #{<<"content-type">> => <<"text/plain">>}, Status, Req); + +handle_request(_Method, _Path, Req) -> + cowboy_req:reply(400, #{<<"content-type">> => <<"text/plain">>}, <<"Not found.">>, Req). + +authorize_appid(Req) -> + case cowboy_req:parse_header(<<"authorization">>, Req) of + {basic, AppId, AppSecret} -> emqx_mgmt_auth:is_authorized(AppId, AppSecret); + _ -> false + end. + +filter(#{app := App}) -> + case emqx_plugins:find_plugin(App) of + false -> false; + Plugin -> Plugin#plugin.active + end. diff --git a/apps/emqx_management/src/emqx_mgmt_sup.erl b/apps/emqx_management/src/emqx_mgmt_sup.erl new file mode 100644 index 000000000..8a8e9ad1c --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_sup.erl @@ -0,0 +1,30 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_sup). + +-behaviour(supervisor). + +-export([start_link/0]). + +-export([init/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + {ok, {{one_for_one, 1, 5}, []}}. + diff --git a/apps/emqx_management/src/emqx_mgmt_util.erl b/apps/emqx_management/src/emqx_mgmt_util.erl new file mode 100644 index 000000000..7c6ce4ef9 --- /dev/null +++ b/apps/emqx_management/src/emqx_mgmt_util.erl @@ -0,0 +1,84 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_util). + +-export([ strftime/1 + , datetime/1 + , kmg/1 + , ntoa/1 + , merge_maps/2 + ]). + +-export([urldecode/1]). + +-define(KB, 1024). +-define(MB, (1024*1024)). +-define(GB, (1024*1024*1024)). + +%%-------------------------------------------------------------------- +%% Strftime +%%-------------------------------------------------------------------- + +strftime({MegaSecs, Secs, _MicroSecs}) -> + strftime(datetime(MegaSecs * 1000000 + Secs)); + +strftime(Secs) when is_integer(Secs) -> + strftime(datetime(Secs)); + +strftime({{Y,M,D}, {H,MM,S}}) -> + lists:flatten( + io_lib:format( + "~4..0w-~2..0w-~2..0w ~2..0w:~2..0w:~2..0w", [Y, M, D, H, MM, S])). + +datetime(Timestamp) when is_integer(Timestamp) -> + Epoch = calendar:datetime_to_gregorian_seconds({{1970,1,1}, {0,0,0}}), + Universal = calendar:gregorian_seconds_to_datetime(Timestamp + Epoch), + calendar:universal_time_to_local_time(Universal). + +kmg(Byte) when Byte > ?GB -> + kmg(Byte / ?GB, "G"); +kmg(Byte) when Byte > ?MB -> + kmg(Byte / ?MB, "M"); +kmg(Byte) when Byte > ?KB -> + kmg(Byte / ?KB, "K"); +kmg(Byte) -> + Byte. +kmg(F, S) -> + iolist_to_binary(io_lib:format("~.2f~s", [F, S])). + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256}); +ntoa(IP) -> + inet_parse:ntoa(IP). + +merge_maps(Default, New) -> + maps:fold(fun(K, V, Acc) -> + case maps:get(K, Acc, undefined) of + OldV when is_map(OldV), + is_map(V) -> Acc#{K => merge_maps(OldV, V)}; + _ -> Acc#{K => V} + end + end, Default, New). + +-if(?OTP_RELEASE >= 23). +urldecode(S) -> + [{R, _}] = uri_string:dissect_query(S), R. +-else. +urldecode(S) -> + http_uri:decode(S). +-endif. + diff --git a/apps/emqx_management/test/emqx_mgmt_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_SUITE.erl new file mode 100644 index 000000000..c987d8ca2 --- /dev/null +++ b/apps/emqx_management/test/emqx_mgmt_SUITE.erl @@ -0,0 +1,288 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("common_test/include/ct.hrl"). + +-define(LOG_LEVELS,["debug", "error", "info"]). +-define(LOG_HANDLER_ID, [file, default]). + +all() -> + [{group, manage_apps}, + {group, check_cli}]. + +groups() -> + [{manage_apps, [sequence], + [t_app + ]}, + {check_cli, [sequence], + [t_cli, + t_log_cmd, + t_mgmt_cmd, + t_status_cmd, + t_clients_cmd, + t_vm_cmd, + t_plugins_cmd, + t_modules_cmd, + t_trace_cmd, + t_broker_cmd, + t_router_cmd, + t_subscriptions_cmd, + t_listeners_cmd + ]}]. + +apps() -> + [emqx, emqx_management, emqx_reloader]. + +init_per_suite(Config) -> + ekka_mnesia:start(), + emqx_mgmt_auth:mnesia(boot), + emqx_ct_helpers:start_apps([emqx_management, emqx_reloader]), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_management, emqx_reloader, emqx]). + +t_app(_Config) -> + {ok, AppSecret} = emqx_mgmt_auth:add_app(<<"app_id">>, <<"app_name">>), + ?assert(emqx_mgmt_auth:is_authorized(<<"app_id">>, AppSecret)), + ?assertEqual(AppSecret, emqx_mgmt_auth:get_appsecret(<<"app_id">>)), + ?assertEqual({<<"app_id">>, AppSecret, + <<"app_name">>, <<"Application user">>, + true, undefined}, + lists:keyfind(<<"app_id">>, 1, emqx_mgmt_auth:list_apps())), + emqx_mgmt_auth:del_app(<<"app_id">>), + %% Use the default application secret + application:set_env(emqx_management, application, [{default_secret, <<"public">>}]), + {ok, AppSecret1} = emqx_mgmt_auth:add_app(<<"app_id">>, <<"app_name">>, <<"app_desc">>, true, undefined), + ?assert(emqx_mgmt_auth:is_authorized(<<"app_id">>, AppSecret1)), + ?assertEqual(AppSecret1, emqx_mgmt_auth:get_appsecret(<<"app_id">>)), + ?assertEqual(AppSecret1, <<"public">>), + ?assertEqual({<<"app_id">>, AppSecret1, <<"app_name">>, <<"app_desc">>, true, undefined}, + lists:keyfind(<<"app_id">>, 1, emqx_mgmt_auth:list_apps())), + emqx_mgmt_auth:del_app(<<"app_id">>), + application:set_env(emqx_management, application, []), + %% Specify the application secret + {ok, AppSecret2} = emqx_mgmt_auth:add_app(<<"app_id">>, <<"app_name">>, <<"secret">>, <<"app_desc">>, true, undefined), + ?assert(emqx_mgmt_auth:is_authorized(<<"app_id">>, AppSecret2)), + ?assertEqual(AppSecret2, emqx_mgmt_auth:get_appsecret(<<"app_id">>)), + ?assertEqual({<<"app_id">>, AppSecret2, <<"app_name">>, <<"app_desc">>, true, undefined}, + lists:keyfind(<<"app_id">>, 1, emqx_mgmt_auth:list_apps())), + emqx_mgmt_auth:del_app(<<"app_id">>), + ok. + +t_log_cmd(_) -> + print_mock(), + lists:foreach(fun(Level) -> + emqx_mgmt_cli:log(["primary-level", Level]), + ?assertEqual(Level++"\n", emqx_mgmt_cli:log(["primary-level"])) + end, ?LOG_LEVELS), + lists:foreach(fun(Level) -> + emqx_mgmt_cli:log(["set-level", Level]), + ?assertEqual(Level++"\n", emqx_mgmt_cli:log(["primary-level"])) + end, ?LOG_LEVELS), + [lists:foreach(fun(Level) -> + ?assertEqual(Level++"\n", emqx_mgmt_cli:log(["handlers", "set-level", + atom_to_list(Id), Level])) + end, ?LOG_LEVELS) + || #{id := Id} <- emqx_logger:get_log_handlers()]. + +t_mgmt_cmd(_) -> + ct:pal("start testing the mgmt command"), + print_mock(), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["lookup", "emqx_appid"]), "Not Found.")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["delete", "emqx_appid"]), "ok")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["insert", "emqx_appid", "emqx_name"]), "AppSecret:")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["insert", "emqx_appid", "emqx_name"]), "Error:")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["lookup", "emqx_appid"]), "app_id:")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["update", "emqx_appid", "ts"]), "update successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:mgmt(["delete", "emqx_appid"]), "ok")), + ok = emqx_mgmt_cli:mgmt(["list"]). + +t_status_cmd(_) -> + ct:pal("start testing status command"), + print_mock(), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:status([]), "is running")). + +t_broker_cmd(_) -> + ct:pal("start testing the broker command"), + print_mock(), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:broker([]), "sysdescr")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:broker(["stats"]), "subscriptions.shared")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:broker(["metrics"]), "bytes.sent")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:broker([undefined]), "broker")). + +t_clients_cmd(_) -> + ct:pal("start testing the client command"), + print_mock(), + process_flag(trap_exit, true), + {ok, T} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client12">>}, + {username, <<"testuser1">>}, + {password, <<"pass1">>}]), + {ok, _} = emqtt:connect(T), + timer:sleep(300), + emqx_mgmt_cli:clients(["list"]), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:clients(["show", "client12"]), "client12")), + ?assertEqual((emqx_mgmt_cli:clients(["kick", "client12"])), "ok~n"), + timer:sleep(500), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:clients(["show", "client12"]), "Not Found")), + receive + {'EXIT', T, Reason} -> + ct:pal("Connection closed: ~p~n", [Reason]) + after + 500 -> + erlang:error("Client is not kick") + end, + WS = rfc6455_client:new("ws://127.0.0.1:8083" ++ "/mqtt", self()), + {ok, _} = rfc6455_client:open(WS), + Packet = raw_send_serialize(?CONNECT_PACKET(#mqtt_packet_connect{ + clientid = <<"client13">>})), + ok = rfc6455_client:send_binary(WS, Packet), + Connack = ?CONNACK_PACKET(?CONNACK_ACCEPT), + {binary, Bin} = rfc6455_client:recv(WS), + {ok, Connack, <<>>, _} = raw_recv_pase(Bin), + timer:sleep(300), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:clients(["show", "client13"]), "client13")). + % emqx_mgmt_cli:clients(["kick", "client13"]), + % timer:sleep(500), + % ?assertMatch({match, _}, re:run(emqx_mgmt_cli:clients(["show", "client13"]), "Not Found")). + +raw_recv_pase(Packet) -> + emqx_frame:parse(Packet). + +raw_send_serialize(Packet) -> + emqx_frame:serialize(Packet). + +t_vm_cmd(_) -> + ct:pal("start testing the vm command"), + print_mock(), + [[?assertMatch({match, _}, re:run(Result, Name)) || Result <- emqx_mgmt_cli:vm([Name])] || Name <- ["load", "memory", "process", "io", "ports"]], + [?assertMatch({match, _}, re:run(Result, "load")) || Result <- emqx_mgmt_cli:vm(["load"])], + [?assertMatch({match, _}, re:run(Result, "memory"))|| Result <- emqx_mgmt_cli:vm(["memory"])], + [?assertMatch({match, _}, re:run(Result, "process")) || Result <- emqx_mgmt_cli:vm(["process"])], + [?assertMatch({match, _}, re:run(Result, "io")) || Result <- emqx_mgmt_cli:vm(["io"])], + [?assertMatch({match, _}, re:run(Result, "ports")) || Result <- emqx_mgmt_cli:vm(["ports"])]. + +t_trace_cmd(_) -> + ct:pal("start testing the trace command"), + print_mock(), + logger:set_primary_config(level, debug), + {ok, T} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client">>}, + {username, <<"testuser">>}, + {password, <<"pass">>} + ]), + emqtt:connect(T), + emqtt:subscribe(T, <<"a/b/c">>), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["start", "client", "client", "log/clientid_trace.log"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["stop", "client", "client"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["start", "client", "client", "log/clientid_trace.log", "error"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["stop", "client", "client"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["start", "topic", "a/b/c", "log/clientid_trace.log"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["stop", "topic", "a/b/c"]), "successfully")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:trace(["start", "topic", "a/b/c", "log/clientid_trace.log", "error"]), "successfully")), + logger:set_primary_config(level, error). + +t_router_cmd(_) -> + ct:pal("start testing the router command"), + print_mock(), + {ok, T} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client1">>}, + {username, <<"testuser1">>}, + {password, <<"pass1">>}]), + emqtt:connect(T), + emqtt:subscribe(T, <<"a/b/c">>), + {ok, T1} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client2">>}, + {username, <<"testuser2">>}, + {password, <<"pass2">>} + ]), + emqtt:connect(T1), + emqtt:subscribe(T1, <<"a/b/c/d">>), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:routes(["list"]), "a/b/c | a/b/c")), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:routes(["show", "a/b/c"]), "a/b/c")). + +t_subscriptions_cmd(_) -> + ct:pal("Start testing the subscriptions command"), + print_mock(), + {ok, T3} = emqtt:start_link([{host, "localhost"}, + {clientid, <<"client">>}, + {username, <<"testuser">>}, + {password, <<"pass">>}]), + {ok, _} = emqtt:connect(T3), + {ok, _, _} = emqtt:subscribe(T3, <<"b/b/c">>), + timer:sleep(300), + [?assertMatch({match, _} , re:run(Result, "b/b/c")) + || Result <- emqx_mgmt_cli:subscriptions(["show", <<"client">>])], + ?assertEqual(emqx_mgmt_cli:subscriptions(["add", "client", "b/b/c", "0"]), "ok~n"), + ?assertEqual(emqx_mgmt_cli:subscriptions(["del", "client", "b/b/c"]), "ok~n"). + +t_listeners_cmd(_) -> + print_mock(), + ?assertEqual(emqx_mgmt_cli:listeners([]), ok), + ?assertEqual(emqx_mgmt_cli:listeners(["stop", "wss", "8084"]), "Stop wss listener on 8084 successfully.\n"). + +t_plugins_cmd(_) -> + print_mock(), + meck:new(emqx_plugins, [non_strict, passthrough]), + meck:expect(emqx_plugins, load, fun(_) -> ok end), + meck:expect(emqx_plugins, unload, fun(_) -> ok end), + meck:expect(emqx_plugins, reload, fun(_) -> ok end), + ?assertEqual(emqx_mgmt_cli:plugins(["list"]), ok), + ?assertEqual(emqx_mgmt_cli:plugins(["unload", "emqx_reloader"]), "Plugin emqx_reloader unloaded successfully.\n"), + ?assertEqual(emqx_mgmt_cli:plugins(["load", "emqx_reloader"]),"Plugin emqx_reloader loaded successfully.\n"), + ?assertEqual(emqx_mgmt_cli:plugins(["unload", "emqx_management"]), "Plugin emqx_management can not be unloaded.~n"). + +t_modules_cmd(_) -> + print_mock(), + meck:new(emqx_modules, [non_strict, passthrough]), + meck:expect(emqx_modules, load, fun(_) -> ok end), + meck:expect(emqx_modules, unload, fun(_) -> ok end), + meck:expect(emqx_modules, reload, fun(_) -> ok end), + ?assertEqual(emqx_mgmt_cli:modules(["list"]), ok), + ?assertEqual(emqx_mgmt_cli:modules(["load", "emqx_mod_presence"]),"Module emqx_mod_presence loaded successfully.\n"), + ?assertEqual(emqx_mgmt_cli:modules(["unload", "emqx_mod_presence"]), "Module emqx_mod_presence unloaded successfully.\n"). + +t_cli(_) -> + print_mock(), + ?assertMatch({match, _}, re:run(emqx_mgmt_cli:status([""]), "status")), + [?assertMatch({match, _}, re:run(Value, "broker")) || Value <- emqx_mgmt_cli:broker([""])], + [?assertMatch({match, _}, re:run(Value, "cluster")) || Value <- emqx_mgmt_cli:cluster([""])], + [?assertMatch({match, _}, re:run(Value, "clients")) || Value <- emqx_mgmt_cli:clients([""])], + [?assertMatch({match, _}, re:run(Value, "routes")) || Value <- emqx_mgmt_cli:routes([""])], + [?assertMatch({match, _}, re:run(Value, "subscriptions")) || Value <- emqx_mgmt_cli:subscriptions([""])], + [?assertMatch({match, _}, re:run(Value, "plugins")) || Value <- emqx_mgmt_cli:plugins([""])], + [?assertMatch({match, _}, re:run(Value, "listeners")) || Value <- emqx_mgmt_cli:listeners([""])], + [?assertMatch({match, _}, re:run(Value, "vm")) || Value <- emqx_mgmt_cli:vm([""])], + [?assertMatch({match, _}, re:run(Value, "mnesia")) || Value <- emqx_mgmt_cli:mnesia([""])], + [?assertMatch({match, _}, re:run(Value, "trace")) || Value <- emqx_mgmt_cli:trace([""])], + [?assertMatch({match, _}, re:run(Value, "mgmt")) || Value <- emqx_mgmt_cli:mgmt([""])]. + +print_mock() -> + meck:new(emqx_ctl, [non_strict, passthrough]), + meck:expect(emqx_ctl, print, fun(Arg) -> emqx_ctl:format(Arg) end), + meck:expect(emqx_ctl, print, fun(Msg, Arg) -> emqx_ctl:format(Msg, Arg) end), + meck:expect(emqx_ctl, usage, fun(Usages) -> emqx_ctl:format_usage(Usages) end), + meck:expect(emqx_ctl, usage, fun(Cmd, Descr) -> emqx_ctl:format_usage(Cmd, Descr) end). diff --git a/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl b/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl new file mode 100644 index 000000000..2b39b95cb --- /dev/null +++ b/apps/emqx_management/test/emqx_mgmt_api_SUITE.erl @@ -0,0 +1,645 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_mgmt_api_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("eunit/include/eunit.hrl"). + +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). + +-include("emqx_mgmt.hrl"). + +-define(CONTENT_TYPE, "application/x-www-form-urlencoded"). + +-define(HOST, "http://127.0.0.1:8081/"). + +-define(API_VERSION, "v4"). + +-define(BASE_PATH, "api"). + +all() -> + [{group, rest_api}]. + +groups() -> + [{rest_api, + [sequence], + [alarms, + apps, + banned, + brokers, + clients, + listeners, + metrics, + nodes, + plugins, + modules, + acl_cache, + pubsub, + routes_and_subscriptions, + stats]}]. + +init_per_suite(Config) -> + emqx_ct_helpers:start_apps([emqx, emqx_management, emqx_reloader]), + ekka_mnesia:start(), + emqx_mgmt_auth:mnesia(boot), + Config. + +end_per_suite(_Config) -> + emqx_ct_helpers:stop_apps([emqx_reloader, emqx_management, emqx]), + ekka_mnesia:ensure_stopped(). + +get(Key, ResponseBody) -> + maps:get(Key, jiffy:decode(list_to_binary(ResponseBody), [return_maps])). + +lookup_alarm(Name, [#{<<"name">> := Name} | _More]) -> + true; +lookup_alarm(Name, [_Alarm | More]) -> + lookup_alarm(Name, More); +lookup_alarm(_Name, []) -> + false. + +is_existing(Name, [#{name := Name} | _More]) -> + true; +is_existing(Name, [_Alarm | More]) -> + is_existing(Name, More); +is_existing(_Name, []) -> + false. + +alarms(_) -> + emqx_alarm:activate(alarm1), + emqx_alarm:activate(alarm2), + + ?assert(is_existing(alarm1, emqx_alarm:get_alarms(activated))), + ?assert(is_existing(alarm2, emqx_alarm:get_alarms(activated))), + + {ok, Return1} = request_api(get, api_path(["alarms/activated"]), auth_header_()), + ?assert(lookup_alarm(<<"alarm1">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return1))))), + ?assert(lookup_alarm(<<"alarm2">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return1))))), + + emqx_alarm:deactivate(alarm1), + + {ok, Return2} = request_api(get, api_path(["alarms"]), auth_header_()), + ?assert(lookup_alarm(<<"alarm1">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return2))))), + ?assert(lookup_alarm(<<"alarm2">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return2))))), + + {ok, Return3} = request_api(get, api_path(["alarms/deactivated"]), auth_header_()), + ?assert(lookup_alarm(<<"alarm1">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return3))))), + ?assertNot(lookup_alarm(<<"alarm2">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return3))))), + + emqx_alarm:deactivate(alarm2), + + {ok, Return4} = request_api(get, api_path(["alarms/deactivated"]), auth_header_()), + ?assert(lookup_alarm(<<"alarm1">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return4))))), + ?assert(lookup_alarm(<<"alarm2">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return4))))), + + {ok, _} = request_api(delete, api_path(["alarms/deactivated"]), auth_header_()), + + {ok, Return5} = request_api(get, api_path(["alarms/deactivated"]), auth_header_()), + ?assertNot(lookup_alarm(<<"alarm1">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return5))))), + ?assertNot(lookup_alarm(<<"alarm2">>, maps:get(<<"alarms">>, lists:nth(1, get(<<"data">>, Return5))))). + +apps(_) -> + AppId = <<"123456">>, + meck:new(emqx_mgmt_auth, [passthrough, no_history]), + meck:expect(emqx_mgmt_auth, add_app, 6, fun(_, _, _, _, _, _) -> {error, undefined} end), + {ok, Error1} = request_api(post, api_path(["apps"]), [], + auth_header_(), #{<<"app_id">> => AppId, + <<"name">> => <<"test">>, + <<"status">> => true}), + ?assertEqual(?ERROR2, get(<<"code">>, Error1)), + + meck:expect(emqx_mgmt_auth, del_app, 1, fun(_) -> {error, undefined} end), + {ok, Error2} = request_api(delete, api_path(["apps", binary_to_list(AppId)]), auth_header_()), + ?assertEqual(?ERROR2, get(<<"code">>, Error2)), + meck:unload(emqx_mgmt_auth), + + {ok, NoApp} = request_api(get, api_path(["apps", binary_to_list(AppId)]), auth_header_()), + ?assertEqual(0, maps:size(get(<<"data">>, NoApp))), + {ok, NotFound} = request_api(put, api_path(["apps", binary_to_list(AppId)]), [], + auth_header_(), #{<<"name">> => <<"test 2">>, + <<"status">> => true}), + ?assertEqual(<<"not_found">>, get(<<"message">>, NotFound)), + + {ok, _} = request_api(post, api_path(["apps"]), [], + auth_header_(), #{<<"app_id">> => AppId, + <<"name">> => <<"test">>, + <<"status">> => true}), + {ok, _} = request_api(get, api_path(["apps"]), auth_header_()), + {ok, _} = request_api(get, api_path(["apps", binary_to_list(AppId)]), auth_header_()), + {ok, _} = request_api(put, api_path(["apps", binary_to_list(AppId)]), [], + auth_header_(), #{<<"name">> => <<"test 2">>, + <<"status">> => true}), + {ok, AppInfo} = request_api(get, api_path(["apps", binary_to_list(AppId)]), auth_header_()), + ?assertEqual(<<"test 2">>, maps:get(<<"name">>, get(<<"data">>, AppInfo))), + {ok, _} = request_api(delete, api_path(["apps", binary_to_list(AppId)]), auth_header_()), + {ok, Result} = request_api(get, api_path(["apps"]), auth_header_()), + [App] = get(<<"data">>, Result), + ?assertEqual(<<"admin">>, maps:get(<<"app_id">>, App)). + +banned(_) -> + Who = <<"myclient">>, + {ok, _} = request_api(post, api_path(["banned"]), [], + auth_header_(), #{<<"who">> => Who, + <<"as">> => <<"clientid">>, + <<"reason">> => <<"test">>, + <<"by">> => <<"dashboard">>, + <<"at">> => erlang:system_time(second), + <<"until">> => erlang:system_time(second) + 10}), + + {ok, Result} = request_api(get, api_path(["banned"]), auth_header_()), + [Banned] = get(<<"data">>, Result), + ?assertEqual(Who, maps:get(<<"who">>, Banned)), + + {ok, _} = request_api(delete, api_path(["banned", "clientid", binary_to_list(Who)]), auth_header_()), + {ok, Result2} = request_api(get, api_path(["banned"]), auth_header_()), + ?assertEqual([], get(<<"data">>, Result2)). + +brokers(_) -> + {ok, _} = request_api(get, api_path(["brokers"]), auth_header_()), + {ok, _} = request_api(get, api_path(["brokers", atom_to_list(node())]), auth_header_()), + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, lookup_broker, 1, fun(_) -> {error, undefined} end), + {ok, Error} = request_api(get, api_path(["brokers", atom_to_list(node())]), auth_header_()), + ?assertEqual(<<"undefined">>, get(<<"message">>, Error)), + meck:unload(emqx_mgmt). + +clients(_) -> + process_flag(trap_exit, true), + Username1 = <<"user1">>, + Username2 = <<"user2">>, + ClientId1 = <<"client1">>, + ClientId2 = <<"client2">>, + {ok, C1} = emqtt:start_link(#{username => Username1, clientid => ClientId1}), + {ok, _} = emqtt:connect(C1), + {ok, C2} = emqtt:start_link(#{username => Username2, clientid => ClientId2}), + {ok, _} = emqtt:connect(C2), + timer:sleep(300), + + {ok, Clients1} = request_api(get, api_path(["clients", binary_to_list(ClientId1)]) + , auth_header_()), + ?assertEqual(<<"client1">>, maps:get(<<"clientid">>, lists:nth(1, get(<<"data">>, Clients1)))), + + {ok, Clients2} = request_api(get, api_path(["nodes", atom_to_list(node()), + "clients", binary_to_list(ClientId2)]) + , auth_header_()), + ?assertEqual(<<"client2">>, maps:get(<<"clientid">>, lists:nth(1, get(<<"data">>, Clients2)))), + + {ok, Clients3} = request_api(get, api_path(["clients", + "username", binary_to_list(Username1)]), + auth_header_()), + ?assertEqual(<<"client1">>, maps:get(<<"clientid">>, lists:nth(1, get(<<"data">>, Clients3)))), + + {ok, Clients4} = request_api(get, api_path(["nodes", atom_to_list(node()), + "clients", + "username", binary_to_list(Username2)]) + , auth_header_()), + ?assertEqual(<<"client2">>, maps:get(<<"clientid">>, lists:nth(1, get(<<"data">>, Clients4)))), + + {ok, Clients5} = request_api(get, api_path(["clients"]), "_limit=100&_page=1", auth_header_()), + ?assertEqual(2, maps:get(<<"count">>, get(<<"meta">>, Clients5))), + + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, kickout_client, 1, fun(_) -> {error, undefined} end), + + {ok, MeckRet1} = request_api(delete, api_path(["clients", binary_to_list(ClientId1)]), auth_header_()), + ?assertEqual(?ERROR1, get(<<"code">>, MeckRet1)), + + meck:expect(emqx_mgmt, clean_acl_cache, 1, fun(_) -> {error, undefined} end), + {ok, MeckRet2} = request_api(delete, api_path(["clients", binary_to_list(ClientId1), "acl_cache"]), auth_header_()), + ?assertEqual(?ERROR1, get(<<"code">>, MeckRet2)), + + meck:expect(emqx_mgmt, list_acl_cache, 1, fun(_) -> {error, undefined} end), + {ok, MeckRet3} = request_api(get, api_path(["clients", binary_to_list(ClientId2), "acl_cache"]), auth_header_()), + ?assertEqual(?ERROR1, get(<<"code">>, MeckRet3)), + + meck:unload(emqx_mgmt), + + {ok, Ok} = request_api(delete, api_path(["clients", binary_to_list(ClientId1)]), auth_header_()), + ?assertEqual(?SUCCESS, get(<<"code">>, Ok)), + + {ok, NotFound0} = request_api(delete, api_path(["clients", binary_to_list(ClientId1)]), auth_header_()), + ?assertEqual(?ERROR12, get(<<"code">>, NotFound0)), + + {ok, Clients6} = request_api(get, api_path(["clients"]), "_limit=100&_page=1", auth_header_()), + ?assertEqual(1, maps:get(<<"count">>, get(<<"meta">>, Clients6))), + + {ok, NotFound1} = request_api(get, api_path(["clients", binary_to_list(ClientId1), "acl_cache"]), auth_header_()), + ?assertEqual(?ERROR12, get(<<"code">>, NotFound1)), + + {ok, NotFound2} = request_api(delete, api_path(["clients", binary_to_list(ClientId1), "acl_cache"]), auth_header_()), + ?assertEqual(?ERROR12, get(<<"code">>, NotFound2)), + + {ok, EmptyAclCache} = request_api(get, api_path(["clients", binary_to_list(ClientId2), "acl_cache"]), auth_header_()), + ?assertEqual(0, length(get(<<"data">>, EmptyAclCache))), + + {ok, Ok1} = request_api(delete, api_path(["clients", binary_to_list(ClientId2), "acl_cache"]), auth_header_()), + ?assertEqual(?SUCCESS, get(<<"code">>, Ok1)). + +receive_exit(0) -> + ok; +receive_exit(Count) -> + receive + {'EXIT', Client, {shutdown, tcp_closed}} -> + ct:log("receive exit signal, Client: ~p", [Client]), + receive_exit(Count - 1); + {'EXIT', Client, _Reason} -> + ct:log("receive exit signal, Client: ~p", [Client]), + receive_exit(Count - 1) + after 1000 -> + ct:log("timeout") + end. + +listeners(_) -> + {ok, _} = request_api(get, api_path(["listeners"]), auth_header_()), + {ok, _} = request_api(get, api_path(["nodes", atom_to_list(node()), "listeners"]), auth_header_()), + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, list_listeners, 0, fun() -> [{node(), {error, undefined}}] end), + {ok, Return} = request_api(get, api_path(["listeners"]), auth_header_()), + [Error] = get(<<"data">>, Return), + ?assertEqual(<<"undefined">>, + maps:get(<<"error">>, maps:get(<<"listeners">>, Error))), + meck:unload(emqx_mgmt). + +metrics(_) -> + {ok, _} = request_api(get, api_path(["metrics"]), auth_header_()), + {ok, _} = request_api(get, api_path(["nodes", atom_to_list(node()), "metrics"]), auth_header_()), + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, get_metrics, 1, fun(_) -> {error, undefined} end), + {ok, "{\"message\":\"undefined\"}"} = request_api(get, api_path(["nodes", atom_to_list(node()), "metrics"]), auth_header_()), + meck:unload(emqx_mgmt). + +nodes(_) -> + {ok, _} = request_api(get, api_path(["nodes"]), auth_header_()), + {ok, _} = request_api(get, api_path(["nodes", atom_to_list(node())]), auth_header_()), + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, list_nodes, 0, fun() -> [{node(), {error, undefined}}] end), + {ok, Return} = request_api(get, api_path(["nodes"]), auth_header_()), + [Error] = get(<<"data">>, Return), + ?assertEqual(<<"undefined">>, maps:get(<<"error">>, Error)), + meck:unload(emqx_mgmt). + +plugins(_) -> + {ok, Plugins1} = request_api(get, api_path(["plugins"]), auth_header_()), + [Plugins11] = filter(get(<<"data">>, Plugins1), <<"node">>, atom_to_binary(node(), utf8)), + [Plugin1] = filter(maps:get(<<"plugins">>, Plugins11), <<"name">>, <<"emqx_reloader">>), + ?assertEqual(<<"emqx_reloader">>, maps:get(<<"name">>, Plugin1)), + ?assertEqual(true, maps:get(<<"active">>, Plugin1)), + + {ok, _} = request_api(put, + api_path(["plugins", + atom_to_list(emqx_reloader), + "unload"]), + auth_header_()), + {ok, Error1} = request_api(put, + api_path(["plugins", + atom_to_list(emqx_reloader), + "unload"]), + auth_header_()), + ?assertEqual(<<"not_started">>, get(<<"message">>, Error1)), + {ok, Plugins2} = request_api(get, + api_path(["nodes", atom_to_list(node()), "plugins"]), + auth_header_()), + [Plugin2] = filter(get(<<"data">>, Plugins2), <<"name">>, <<"emqx_reloader">>), + ?assertEqual(<<"emqx_reloader">>, maps:get(<<"name">>, Plugin2)), + ?assertEqual(false, maps:get(<<"active">>, Plugin2)), + + {ok, _} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "plugins", + atom_to_list(emqx_reloader), + "load"]), + auth_header_()), + {ok, Plugins3} = request_api(get, + api_path(["nodes", atom_to_list(node()), "plugins"]), + auth_header_()), + [Plugin3] = filter(get(<<"data">>, Plugins3), <<"name">>, <<"emqx_reloader">>), + ?assertEqual(<<"emqx_reloader">>, maps:get(<<"name">>, Plugin3)), + ?assertEqual(false, maps:get(<<"active">>, Plugin3)), + + {ok, _} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "plugins", + atom_to_list(emqx_reloader), + "unload"]), + auth_header_()), + {ok, Error2} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "plugins", + atom_to_list(emqx_reloader), + "unload"]), + auth_header_()), + ?assertEqual(<<"not_started">>, get(<<"message">>, Error2)). + +modules(_) -> + emqx_modules:load_module(emqx_mod_presence, false), + timer:sleep(50), + {ok, Modules1} = request_api(get, api_path(["modules"]), auth_header_()), + [Modules11] = filter(get(<<"data">>, Modules1), <<"node">>, atom_to_binary(node(), utf8)), + [Module1] = filter(maps:get(<<"modules">>, Modules11), <<"name">>, <<"emqx_mod_presence">>), + ?assertEqual(<<"emqx_mod_presence">>, maps:get(<<"name">>, Module1)), + ?assertEqual(true, maps:get(<<"active">>, Module1)), + + {ok, _} = request_api(put, + api_path(["modules", + atom_to_list(emqx_mod_presence), + "unload"]), + auth_header_()), + {ok, Error1} = request_api(put, + api_path(["modules", + atom_to_list(emqx_mod_presence), + "unload"]), + auth_header_()), + ?assertEqual(<<"not_started">>, get(<<"message">>, Error1)), + {ok, Modules2} = request_api(get, + api_path(["nodes", atom_to_list(node()), "modules"]), + auth_header_()), + [Module2] = filter(get(<<"data">>, Modules2), <<"name">>, <<"emqx_mod_presence">>), + ?assertEqual(<<"emqx_mod_presence">>, maps:get(<<"name">>, Module2)), + ?assertEqual(false, maps:get(<<"active">>, Module2)), + + {ok, _} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "modules", + atom_to_list(emqx_mod_presence), + "load"]), + auth_header_()), + {ok, Modules3} = request_api(get, + api_path(["nodes", atom_to_list(node()), "modules"]), + auth_header_()), + [Module3] = filter(get(<<"data">>, Modules3), <<"name">>, <<"emqx_mod_presence">>), + ?assertEqual(<<"emqx_mod_presence">>, maps:get(<<"name">>, Module3)), + ?assertEqual(true, maps:get(<<"active">>, Module3)), + + {ok, _} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "modules", + atom_to_list(emqx_mod_presence), + "unload"]), + auth_header_()), + {ok, Error2} = request_api(put, + api_path(["nodes", + atom_to_list(node()), + "modules", + atom_to_list(emqx_mod_presence), + "unload"]), + auth_header_()), + ?assertEqual(<<"not_started">>, get(<<"message">>, Error2)), + emqx_modules:unload(emqx_mod_presence). + +acl_cache(_) -> + ClientId = <<"client1">>, + Topic = <<"mytopic">>, + {ok, C1} = emqtt:start_link(#{clientid => ClientId}), + {ok, _} = emqtt:connect(C1), + {ok, _, _} = emqtt:subscribe(C1, Topic, 2), + %% get acl cache, should not be empty + {ok, Result} = request_api(get, api_path(["clients", binary_to_list(ClientId), "acl_cache"]), [], auth_header_()), + #{<<"code">> := 0, <<"data">> := Caches} = jiffy:decode(list_to_binary(Result), [return_maps]), + ?assert(length(Caches) > 0), + ?assertMatch(#{<<"access">> := <<"subscribe">>, + <<"topic">> := Topic, + <<"result">> := <<"allow">>, + <<"updated_time">> := _}, hd(Caches)), + %% clear acl cache + {ok, Result2} = request_api(delete, api_path(["clients", binary_to_list(ClientId), "acl_cache"]), [], auth_header_()), + ?assertMatch(#{<<"code">> := 0}, jiffy:decode(list_to_binary(Result2), [return_maps])), + %% get acl cache again, after the acl cache is cleared + {ok, Result3} = request_api(get, api_path(["clients", binary_to_list(ClientId), "acl_cache"]), [], auth_header_()), + #{<<"code">> := 0, <<"data">> := Caches3} = jiffy:decode(list_to_binary(Result3), [return_maps]), + ?assertEqual(0, length(Caches3)), + ok = emqtt:disconnect(C1). + +pubsub(_) -> + ClientId = <<"client1">>, + Options = #{clientid => ClientId, + proto_ver => 5}, + Topic = <<"mytopic">>, + {ok, C1} = emqtt:start_link(Options), + {ok, _} = emqtt:connect(C1), + + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, subscribe, 2, fun(_, _) -> {error, undefined} end), + {ok, NotFound1} = request_api(post, api_path(["mqtt/subscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => Topic, + <<"qos">> => 2}), + ?assertEqual(?ERROR12, get(<<"code">>, NotFound1)), + meck:unload(emqx_mgmt), + + {ok, BadTopic1} = request_api(post, api_path(["mqtt/subscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topics">> => <<"">>, + <<"qos">> => 2}), + ?assertEqual(?ERROR15, get(<<"code">>, BadTopic1)), + + {ok, BadTopic2} = request_api(post, api_path(["mqtt/publish"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topics">> => <<"">>, + <<"qos">> => 1, + <<"payload">> => <<"hello">>}), + ?assertEqual(?ERROR15, get(<<"code">>, BadTopic2)), + + {ok, BadTopic3} = request_api(post, api_path(["mqtt/unsubscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => <<"">>}), + ?assertEqual(?ERROR15, get(<<"code">>, BadTopic3)), + + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, unsubscribe, 2, fun(_, _) -> {error, undefined} end), + {ok, NotFound2} = request_api(post, api_path(["mqtt/unsubscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => Topic}), + ?assertEqual(?ERROR12, get(<<"code">>, NotFound2)), + meck:unload(emqx_mgmt), + + {ok, Code} = request_api(post, api_path(["mqtt/subscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => Topic, + <<"qos">> => 2}), + ?assertEqual(?SUCCESS, get(<<"code">>, Code)), + {ok, Code} = request_api(post, api_path(["mqtt/publish"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => <<"mytopic">>, + <<"qos">> => 1, + <<"payload">> => <<"hello">>}), + ?assert(receive + {publish, #{payload := <<"hello">>}} -> + true + after 100 -> + false + end), + %% json payload + {ok, Code} = request_api(post, api_path(["mqtt/publish"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => <<"mytopic">>, + <<"qos">> => 1, + <<"payload">> => #{body => "hello world"}}), + Payload = emqx_json:encode(#{body => "hello world"}), + ?assert(receive + {publish, #{payload := Payload}} -> + true + after 100 -> + false + end), + + {ok, Code} = request_api(post, api_path(["mqtt/unsubscribe"]), [], auth_header_(), + #{<<"clientid">> => ClientId, + <<"topic">> => Topic}), + + %% tests subscribe_batch + Topic_list = [<<"mytopic1">>, <<"mytopic2">>], + [ {ok, _, [2]} = emqtt:subscribe(C1, Topics, 2) || Topics <- Topic_list], + + Body1 = [ #{<<"clientid">> => ClientId, <<"topic">> => Topics, <<"qos">> => 2} || Topics <- Topic_list], + {ok, Data1} = request_api(post, api_path(["mqtt/subscribe_batch"]), [], auth_header_(), Body1), + loop(maps:get(<<"data">>, jiffy:decode(list_to_binary(Data1), [return_maps]))), + + %% tests publish_batch + Body2 = [ #{<<"clientid">> => ClientId, <<"topic">> => Topics, <<"qos">> => 2, <<"retain">> => <<"false">>, <<"payload">> => #{body => "hello world"}} || Topics <- Topic_list ], + {ok, Data2} = request_api(post, api_path(["mqtt/publish_batch"]), [], auth_header_(), Body2), + loop(maps:get(<<"data">>, jiffy:decode(list_to_binary(Data2), [return_maps]))), + [ ?assert(receive + {publish, #{topic := Topics}} -> + true + after 100 -> + false + end) || Topics <- Topic_list ], + + %% tests unsubscribe_batch + Body3 = [#{<<"clientid">> => ClientId, <<"topic">> => Topics} || Topics <- Topic_list], + {ok, Data3} = request_api(post, api_path(["mqtt/unsubscribe_batch"]), [], auth_header_(), Body3), + loop(maps:get(<<"data">>, jiffy:decode(list_to_binary(Data3), [return_maps]))), + + ok = emqtt:disconnect(C1). + +loop([]) -> []; + +loop(Data) -> + [H | T] = Data, + ct:pal("H: ~p~n", [H]), + ?assertEqual(0, maps:get(<<"code">>, H)), + loop(T). + +routes_and_subscriptions(_) -> + ClientId = <<"myclient">>, + Topic = <<"mytopic">>, + {ok, NonRoute} = request_api(get, api_path(["routes"]), auth_header_()), + ?assertEqual([], get(<<"data">>, NonRoute)), + {ok, NonSubscription} = request_api(get, api_path(["subscriptions"]), auth_header_()), + ?assertEqual([], get(<<"data">>, NonSubscription)), + {ok, NonSubscription1} = request_api(get, api_path(["nodes", atom_to_list(node()), "subscriptions"]), auth_header_()), + ?assertEqual([], get(<<"data">>, NonSubscription1)), + {ok, NonSubscription2} = request_api(get, + api_path(["subscriptions", binary_to_list(ClientId)]), + auth_header_()), + ?assertEqual([], get(<<"data">>, NonSubscription2)), + {ok, NonSubscription3} = request_api(get, api_path(["nodes", + atom_to_list(node()), + "subscriptions", + binary_to_list(ClientId)]) + , auth_header_()), + ?assertEqual([], get(<<"data">>, NonSubscription3)), + {ok, C1} = emqtt:start_link(#{clean_start => true, + clientid => ClientId, + proto_ver => ?MQTT_PROTO_V5}), + {ok, _} = emqtt:connect(C1), + {ok, _, [2]} = emqtt:subscribe(C1, Topic, qos2), + {ok, Result} = request_api(get, api_path(["routes"]), auth_header_()), + [Route] = get(<<"data">>, Result), + ?assertEqual(Topic, maps:get(<<"topic">>, Route)), + + {ok, Result2} = request_api(get, api_path(["routes", binary_to_list(Topic)]), auth_header_()), + [Route] = get(<<"data">>, Result2), + + {ok, Result3} = request_api(get, api_path(["subscriptions"]), auth_header_()), + [Subscription] = get(<<"data">>, Result3), + ?assertEqual(Topic, maps:get(<<"topic">>, Subscription)), + ?assertEqual(ClientId, maps:get(<<"clientid">>, Subscription)), + + {ok, Result3} = request_api(get, api_path(["nodes", atom_to_list(node()), "subscriptions"]), auth_header_()), + + {ok, Result4} = request_api(get, api_path(["subscriptions", binary_to_list(ClientId)]), auth_header_()), + [Subscription] = get(<<"data">>, Result4), + {ok, Result4} = request_api(get, api_path(["nodes", atom_to_list(node()), "subscriptions", binary_to_list(ClientId)]) + , auth_header_()), + + ok = emqtt:disconnect(C1). + +stats(_) -> + {ok, _} = request_api(get, api_path(["stats"]), auth_header_()), + {ok, _} = request_api(get, api_path(["nodes", atom_to_list(node()), "stats"]), auth_header_()), + meck:new(emqx_mgmt, [passthrough, no_history]), + meck:expect(emqx_mgmt, get_stats, 1, fun(_) -> {error, undefined} end), + {ok, Return} = request_api(get, api_path(["nodes", atom_to_list(node()), "stats"]), auth_header_()), + ?assertEqual(<<"undefined">>, get(<<"message">>, Return)), + meck:unload(emqx_mgmt). + +request_api(Method, Url, Auth) -> + request_api(Method, Url, [], Auth, []). + +request_api(Method, Url, QueryParams, Auth) -> + request_api(Method, Url, QueryParams, Auth, []). + +request_api(Method, Url, QueryParams, Auth, []) -> + NewUrl = case QueryParams of + "" -> Url; + _ -> Url ++ "?" ++ QueryParams + end, + do_request_api(Method, {NewUrl, [Auth]}); +request_api(Method, Url, QueryParams, Auth, Body) -> + NewUrl = case QueryParams of + "" -> Url; + _ -> Url ++ "?" ++ QueryParams + end, + do_request_api(Method, {NewUrl, [Auth], "application/json", emqx_json:encode(Body)}). + +do_request_api(Method, Request)-> + ct:pal("Method: ~p, Request: ~p", [Method, Request]), + case httpc:request(Method, Request, [], []) of + {error, socket_closed_remotely} -> + {error, socket_closed_remotely}; + {ok, {{"HTTP/1.1", Code, _}, _, Return} } + when Code =:= 200 orelse Code =:= 201 -> + {ok, Return}; + {ok, {Reason, _, _}} -> + {error, Reason} + end. + +auth_header_() -> + AppId = <<"admin">>, + AppSecret = <<"public">>, + auth_header_(binary_to_list(AppId), binary_to_list(AppSecret)). + +auth_header_(User, Pass) -> + Encoded = base64:encode_to_string(lists:append([User,":",Pass])), + {"Authorization","Basic " ++ Encoded}. + +api_path(Parts)-> + ?HOST ++ filename:join([?BASE_PATH, ?API_VERSION] ++ Parts). + +filter(List, Key, Value) -> + lists:filter(fun(Item) -> + maps:get(Key, Item) == Value + end, List). diff --git a/apps/emqx_management/test/etc/emqx_management.conf b/apps/emqx_management/test/etc/emqx_management.conf new file mode 100644 index 000000000..cec70cc8e --- /dev/null +++ b/apps/emqx_management/test/etc/emqx_management.conf @@ -0,0 +1,35 @@ +##-------------------------------------------------------------------- +## EMQ X Management Plugin +##-------------------------------------------------------------------- + +## Max Row Limit +management.max_row_limit = 10000 + +## Application default secret +# +# management.application.default_secret = public + +##-------------------------------------------------------------------- +## HTTP Listener + +management.listener.http = 8080 +management.listener.http.acceptors = 2 +management.listener.http.max_clients = 512 +management.listener.http.backlog = 512 +management.listener.http.send_timeout = 15s +management.listener.http.send_timeout_close = on + +##-------------------------------------------------------------------- +## HTTPS Listener + +## management.listener.https = 8081 +## management.listener.https.acceptors = 2 +## management.listener.https.max_clients = 512 +## management.listener.https.backlog = 512 +## management.listener.https.send_timeout = 15s +## management.listener.https.send_timeout_close = on +## management.listener.https.certfile = etc/certs/cert.pem +## management.listener.https.keyfile = etc/certs/key.pem +## management.listener.https.cacertfile = etc/certs/cacert.pem +## management.listener.https.verify = verify_peer +## management.listener.https.fail_if_no_peer_cert = true diff --git a/apps/emqx_management/test/etc/emqx_reloader.conf b/apps/emqx_management/test/etc/emqx_reloader.conf new file mode 100644 index 000000000..0919c8411 --- /dev/null +++ b/apps/emqx_management/test/etc/emqx_reloader.conf @@ -0,0 +1,24 @@ +##-------------------------------------------------------------------- +## Reloader Plugin +##-------------------------------------------------------------------- + +## Interval of hot code reloading. +## +## Value: Duration +## - h: hour +## - m: minute +## - s: second +## +## Examples: +## - 2h: 2 hours +## - 30m: 30 minutes +## - 20s: 20 seconds +## +## Defaut: 60s +reloader.interval = 60s + +## Logfile of reloader. +## +## Value: File +reloader.logfile = reloader.log + diff --git a/apps/emqx_management/test/rfc6455_client.erl b/apps/emqx_management/test/rfc6455_client.erl new file mode 100644 index 000000000..987b72407 --- /dev/null +++ b/apps/emqx_management/test/rfc6455_client.erl @@ -0,0 +1,252 @@ +%% The contents of this file are subject to the Mozilla Public License +%% Version 1.1 (the "License"); you may not use this file except in +%% compliance with the License. You may obtain a copy of the License at +%% http://www.mozilla.org/MPL/ +%% +%% Software distributed under the License is distributed on an "AS IS" +%% basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the +%% License for the specific language governing rights and limitations +%% under the License. +%% +%% The Original Code is RabbitMQ Management Console. +%% +%% The Initial Developer of the Original Code is GoPivotal, Inc. +%% Copyright (c) 2012-2016 Pivotal Software, Inc. All rights reserved. +%% + +-module(rfc6455_client). + +-export([new/2, open/1, recv/1, send/2, send_binary/2, close/1, close/2]). + +-record(state, {host, port, addr, path, ppid, socket, data, phase}). + +%% -------------------------------------------------------------------------- + +new(WsUrl, PPid) -> + crypto:start(), + "ws://" ++ Rest = WsUrl, + [Addr, Path] = split("/", Rest, 1), + [Host, MaybePort] = split(":", Addr, 1, empty), + Port = case MaybePort of + empty -> 80; + V -> {I, ""} = string:to_integer(V), I + end, + State = #state{host = Host, + port = Port, + addr = Addr, + path = "/" ++ Path, + ppid = PPid}, + spawn(fun() -> + start_conn(State) + end). + +open(WS) -> + receive + {rfc6455, open, WS, Opts} -> + {ok, Opts}; + {rfc6455, close, WS, R} -> + {close, R} + end. + +recv(WS) -> + receive + {rfc6455, recv, WS, Payload} -> + {ok, Payload}; + {rfc6455, recv_binary, WS, Payload} -> + {binary, Payload}; + {rfc6455, close, WS, R} -> + {close, R} + end. + +send(WS, IoData) -> + WS ! {send, IoData}, + ok. + +send_binary(WS, IoData) -> + WS ! {send_binary, IoData}, + ok. + +close(WS) -> + close(WS, {1000, ""}). + +close(WS, WsReason) -> + WS ! {close, WsReason}, + receive + {rfc6455, close, WS, R} -> + {close, R} + end. + + +%% -------------------------------------------------------------------------- + +start_conn(State) -> + {ok, Socket} = gen_tcp:connect(State#state.host, State#state.port, + [binary, + {packet, 0}]), + Key = base64:encode_to_string(crypto:strong_rand_bytes(16)), + gen_tcp:send(Socket, + "GET " ++ State#state.path ++ " HTTP/1.1\r\n" ++ + "Host: " ++ State#state.addr ++ "\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: Upgrade\r\n" ++ + "Sec-WebSocket-Key: " ++ Key ++ "\r\n" ++ + "Origin: null\r\n" ++ + "Sec-WebSocket-Protocol: mqtt\r\n" ++ + "Sec-WebSocket-Version: 13\r\n\r\n"), + + loop(State#state{socket = Socket, + data = <<>>, + phase = opening}). + +do_recv(State = #state{phase = opening, ppid = PPid, data = Data}) -> + case split("\r\n\r\n", binary_to_list(Data), 1, empty) of + [_Http, empty] -> State; + [Http, Data1] -> + %% TODO: don't ignore http response data, verify key + PPid ! {rfc6455, open, self(), [{http_response, Http}]}, + State#state{phase = open, + data = Data1} + end; +do_recv(State = #state{phase = Phase, data = Data, socket = Socket, ppid = PPid}) + when Phase =:= open orelse Phase =:= closing -> + R = case Data of + <> + when L < 126 -> + {F, O, Payload, Rest}; + + <> -> + {F, O, Payload, Rest}; + + <> -> + {F, O, Payload, Rest}; + + <<_:1, _:3, _:4, 1:1, _/binary>> -> + %% According o rfc6455 5.1 the server must not mask any frames. + die(Socket, PPid, {1006, "Protocol error"}, normal); + _ -> + moredata + end, + case R of + moredata -> + State; + _ -> do_recv2(State, R) + end. + +do_recv2(State = #state{phase = Phase, socket = Socket, ppid = PPid}, R) -> + case R of + {1, 1, Payload, Rest} -> + PPid ! {rfc6455, recv, self(), Payload}, + State#state{data = Rest}; + {1, 2, Payload, Rest} -> + PPid ! {rfc6455, recv_binary, self(), Payload}, + State#state{data = Rest}; + {1, 8, Payload, _Rest} -> + WsReason = case Payload of + <> -> {WC, WR}; + <<>> -> {1005, "No status received"} + end, + case Phase of + open -> %% echo + do_close(State, WsReason), + gen_tcp:close(Socket); + closing -> + ok + end, + die(Socket, PPid, WsReason, normal); + {_, _, _, _Rest2} -> + io:format("Unknown frame type~n"), + die(Socket, PPid, {1006, "Unknown frame type"}, normal) + end. + +encode_frame(F, O, Payload) -> + Mask = crypto:strong_rand_bytes(4), + MaskedPayload = apply_mask(Mask, iolist_to_binary(Payload)), + + L = byte_size(MaskedPayload), + IoData = case L of + _ when L < 126 -> + [<>, Mask, MaskedPayload]; + _ when L < 65536 -> + [<>, Mask, MaskedPayload]; + _ -> + [<>, Mask, MaskedPayload] + end, + iolist_to_binary(IoData). + +do_send(State = #state{socket = Socket}, Payload) -> + gen_tcp:send(Socket, encode_frame(1, 1, Payload)), + State. + +do_send_binary(State = #state{socket = Socket}, Payload) -> + gen_tcp:send(Socket, encode_frame(1, 2, Payload)), + State. + +do_close(State = #state{socket = Socket}, {Code, Reason}) -> + Payload = iolist_to_binary([<>, Reason]), + gen_tcp:send(Socket, encode_frame(1, 8, Payload)), + State#state{phase = closing}. + + +loop(State = #state{socket = Socket, ppid = PPid, data = Data, + phase = Phase}) -> + receive + {tcp, Socket, Bin} -> + State1 = State#state{data = iolist_to_binary([Data, Bin])}, + loop(do_recv(State1)); + {send, Payload} when Phase == open -> + loop(do_send(State, Payload)); + {send_binary, Payload} when Phase == open -> + loop(do_send_binary(State, Payload)); + {tcp_closed, Socket} -> + die(Socket, PPid, {1006, "Connection closed abnormally"}, normal); + {close, WsReason} when Phase == open -> + loop(do_close(State, WsReason)) + end. + + +die(Socket, PPid, WsReason, Reason) -> + gen_tcp:shutdown(Socket, read_write), + PPid ! {rfc6455, close, self(), WsReason}, + exit(Reason). + + +%% -------------------------------------------------------------------------- + +split(SubStr, Str, Limit) -> + split(SubStr, Str, Limit, ""). + +split(SubStr, Str, Limit, Default) -> + Acc = split(SubStr, Str, Limit, [], Default), + lists:reverse(Acc). +split(_SubStr, Str, 0, Acc, _Default) -> [Str | Acc]; +split(SubStr, Str, Limit, Acc, Default) -> + {L, R} = case string:str(Str, SubStr) of + 0 -> {Str, Default}; + I -> {string:substr(Str, 1, I-1), + string:substr(Str, I+length(SubStr))} + end, + split(SubStr, R, Limit-1, [L | Acc], Default). + + +apply_mask(Mask, Data) when is_number(Mask) -> + apply_mask(<>, Data); + +apply_mask(<<0:32>>, Data) -> + Data; +apply_mask(Mask, Data) -> + iolist_to_binary(lists:reverse(apply_mask2(Mask, Data, []))). + +apply_mask2(M = <>, <>, Acc) -> + T = Data bxor Mask, + apply_mask2(M, Rest, [<> | Acc]); +apply_mask2(<>, <>, Acc) -> + T = Data bxor Mask, + [<> | Acc]; +apply_mask2(<>, <>, Acc) -> + T = Data bxor Mask, + [<> | Acc]; +apply_mask2(<>, <>, Acc) -> + T = Data bxor Mask, + [<> | Acc]; +apply_mask2(_, <<>>, Acc) -> + Acc. diff --git a/rebar.config b/rebar.config index df71f9762..446539305 100644 --- a/rebar.config +++ b/rebar.config @@ -9,7 +9,8 @@ {esockd, {git, "https://github.com/emqx/esockd", {tag, "5.7.4"}}}, {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.7.4"}}}, {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.5.0"}}}, - {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} + {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}}, + {minirest, {git, "https://github.com/emqx/minirest", {tag, "v0.3.0"}}} ]}. {erl_opts, [warn_unused_vars,