From 2fda0a1565f7a7a741e0f3708a03c8ff5b4033f9 Mon Sep 17 00:00:00 2001 From: Zaiming Shi Date: Mon, 7 Dec 2020 21:30:21 +0100 Subject: [PATCH] chore(apps): Sync again all apps --- apps/emqx_auth_http/etc/emqx_auth_http.conf | 6 +- .../emqx_auth_http/include/emqx_auth_http.hrl | 2 +- .../emqx_auth_http/priv/emqx_auth_http.schema | 76 +-- apps/emqx_auth_http/rebar.config | 8 +- apps/emqx_auth_http/src/emqx_acl_http.erl | 29 +- .../emqx_auth_http/src/emqx_auth_http.app.src | 2 +- apps/emqx_auth_http/src/emqx_auth_http.erl | 54 +- .../emqx_auth_http/src/emqx_auth_http_app.erl | 104 +++- .../emqx_auth_http/src/emqx_auth_http_cli.erl | 49 +- apps/emqx_auth_http/src/emqx_http_client.erl | 256 +++++++++ .../src/emqx_http_client_sup.erl | 48 ++ .../test/emqx_auth_http_SUITE.erl | 40 +- apps/emqx_auth_jwt/.gitignore | 1 + apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf | 28 +- apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema | 25 +- apps/emqx_auth_jwt/rebar.config | 3 +- apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src | 2 +- .../emqx_auth_jwt/src/emqx_auth_jwt.appup.src | 10 + apps/emqx_auth_jwt/src/emqx_auth_jwt.erl | 81 +-- apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl | 53 +- apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl | 222 ++++++++ .../test/emqx_auth_jwt_SUITE.erl | 53 +- apps/emqx_auth_ldap/rebar.config | 2 +- apps/emqx_auth_mongo/rebar.config | 4 +- apps/emqx_auth_mysql/rebar.config | 2 +- .../src/emqx_auth_mysql.appup.src | 22 +- apps/emqx_auth_pgsql/rebar.config | 4 +- .../src/emqx_auth_pgsql.appup.src | 22 +- apps/emqx_auth_redis/.gitignore | 2 + apps/emqx_auth_redis/rebar.config | 6 +- .../src/emqx_auth_redis.appup.src | 38 +- .../src/emqx_auth_redis_sup.erl | 2 +- apps/emqx_bridge_mqtt/rebar.config | 4 +- .../src/emqx_bridge_mqtt.appup.src | 28 +- .../emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl | 18 +- .../src/emqx_bridge_mqtt_actions.erl | 55 +- .../src/emqx_bridge_worker.erl | 19 +- apps/emqx_coap/src/emqx_coap_server.erl | 6 + apps/emqx_dashboard/src/emqx_dashboard.erl | 9 +- .../.github/workflows/run_test_cases.yaml | 21 +- apps/emqx_exhook/.gitignore | 4 + apps/emqx_exhook/README.md | 76 +-- apps/emqx_exhook/docs/design.md | 329 ++++------- apps/emqx_exhook/docs/introduction.md | 84 --- apps/emqx_exhook/docs/sdk-specification.md | 79 --- apps/emqx_exhook/etc/emqx_exhook.conf | 15 + apps/emqx_exhook/etc/emqx_extension_hook.conf | 24 - ...mqx_extension_hook.hrl => emqx_exhook.hrl} | 6 +- apps/emqx_exhook/priv/emqx_exhook.schema | 38 ++ .../priv/emqx_extension_hook.schema | 43 -- apps/emqx_exhook/priv/protos/exhook.proto | 395 +++++++++++++ apps/emqx_exhook/rebar.config | 31 +- apps/emqx_exhook/sdk/README.md | 12 - apps/emqx_exhook/src/emqx_exhook.app.src | 12 + .../src/emqx_exhook.app.src.script | 24 + apps/emqx_exhook/src/emqx_exhook.appup.src | 9 + ...mqx_extension_hook.erl => emqx_exhook.erl} | 88 ++- ...nsion_hook_app.erl => emqx_exhook_app.erl} | 41 +- ...nsion_hook_cli.erl => emqx_exhook_cli.erl} | 36 +- apps/emqx_exhook/src/emqx_exhook_handler.erl | 288 ++++++++++ apps/emqx_exhook/src/emqx_exhook_server.erl | 286 ++++++++++ ...nsion_hook_sup.erl => emqx_exhook_sup.erl} | 29 +- .../src/emqx_extension_hook.app.src | 14 - .../src/emqx_extension_hook_driver.erl | 305 ---------- .../src/emqx_extension_hook_handler.erl | 249 -------- apps/emqx_exhook/test/emqx_exhook_SUITE.erl | 53 ++ .../emqx_exhook/test/emqx_exhook_demo_svr.erl | 297 ++++++++++ .../test/emqx_extension_hook_SUITE.erl | 139 ----- .../test/props/prop_exhook_hooks.erl | 537 ++++++++++++++++++ apps/emqx_exhook/test/scripts/Main.java | 160 ------ apps/emqx_exhook/test/scripts/main.py | 134 ----- apps/emqx_exproto/.gitignore | 7 +- apps/emqx_exproto/README.md | 48 +- apps/emqx_exproto/docs/design.md | 219 +++---- .../emqx_exproto/docs/images/exproto-arch.jpg | Bin 85466 -> 72633 bytes .../docs/images/exproto-grpc-arch.jpg | Bin 0 -> 97464 bytes apps/emqx_exproto/docs/sdk-specification.md | 84 --- apps/emqx_exproto/etc/emqx_exproto.conf | 22 +- apps/emqx_exproto/example/Main.java | 136 ----- apps/emqx_exproto/example/main.py | 80 --- apps/emqx_exproto/include/emqx_exproto.hrl | 13 + apps/emqx_exproto/priv/emqx_exproto.schema | 90 ++- apps/emqx_exproto/priv/protos/exproto.proto | 259 +++++++++ apps/emqx_exproto/rebar.config | 39 +- apps/emqx_exproto/sdk/README.md | 11 - apps/emqx_exproto/src/emqx_exproto.app.src | 6 +- .../src/emqx_exproto.app.src.script | 24 + apps/emqx_exproto/src/emqx_exproto.appup.src | 9 + apps/emqx_exproto/src/emqx_exproto.erl | 159 +++--- apps/emqx_exproto/src/emqx_exproto_app.erl | 3 +- .../emqx_exproto/src/emqx_exproto_channel.erl | 479 +++++++++++----- apps/emqx_exproto/src/emqx_exproto_conn.erl | 62 +- .../src/emqx_exproto_driver_mngr.erl | 302 ---------- apps/emqx_exproto/src/emqx_exproto_gcli.erl | 110 ++++ apps/emqx_exproto/src/emqx_exproto_gsvr.erl | 154 +++++ apps/emqx_exproto/src/emqx_exproto_sup.erl | 63 +- apps/emqx_exproto/src/emqx_exproto_types.erl | 179 ------ apps/emqx_exproto/test/emqx_exproto_SUITE.erl | 328 +++++++++-- .../test/emqx_exproto_echo_svr.erl | 249 ++++++++ apps/emqx_prometheus/rebar.config | 2 +- apps/emqx_retainer/rebar.config | 2 +- apps/emqx_retainer/src/emqx_retainer.erl | 5 +- .../emqx_rule_engine/include/rule_actions.hrl | 11 + apps/emqx_rule_engine/include/rule_engine.hrl | 3 +- apps/emqx_rule_engine/rebar.config | 2 +- .../src/emqx_rule_actions.erl | 164 ++++-- .../src/emqx_rule_actions_trans.erl | 70 +++ .../src/emqx_rule_engine.appup.src | 50 +- .../emqx_rule_engine/src/emqx_rule_engine.erl | 48 +- .../src/emqx_rule_engine_api.erl | 18 +- .../src/emqx_rule_engine_app.erl | 1 + .../src/emqx_rule_engine_sup.erl | 10 + apps/emqx_rule_engine/src/emqx_rule_funcs.erl | 48 +- .../emqx_rule_engine/src/emqx_rule_locker.erl | 34 ++ apps/emqx_rule_engine/src/emqx_rule_maps.erl | 16 +- .../src/emqx_rule_metrics.erl | 187 +++++- .../src/emqx_rule_runtime.erl | 57 +- .../src/emqx_rule_sqlparser.erl | 9 - .../src/emqx_rule_sqltester.erl | 7 +- apps/emqx_rule_engine/src/emqx_rule_utils.erl | 73 ++- .../src/emqx_rule_validator.erl | 8 +- .../test/emqx_rule_engine_SUITE.erl | 68 ++- .../test/emqx_rule_funcs_SUITE.erl | 8 +- .../test/emqx_rule_maps_SUITE.erl | 6 - .../test/emqx_rule_metrics_SUITE.erl | 75 ++- .../test/emqx_rule_utils_SUITE.erl | 40 +- apps/emqx_web_hook/rebar.config | 2 +- .../emqx_web_hook/src/emqx_web_hook.appup.src | 41 +- .../src/emqx_web_hook_actions.erl | 31 +- 129 files changed, 5784 insertions(+), 3700 deletions(-) create mode 100644 apps/emqx_auth_http/src/emqx_http_client.erl create mode 100644 apps/emqx_auth_http/src/emqx_http_client_sup.erl create mode 100644 apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src create mode 100644 apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl delete mode 100644 apps/emqx_exhook/docs/introduction.md delete mode 100644 apps/emqx_exhook/docs/sdk-specification.md create mode 100644 apps/emqx_exhook/etc/emqx_exhook.conf delete mode 100644 apps/emqx_exhook/etc/emqx_extension_hook.conf rename apps/emqx_exhook/include/{emqx_extension_hook.hrl => emqx_exhook.hrl} (87%) create mode 100644 apps/emqx_exhook/priv/emqx_exhook.schema delete mode 100644 apps/emqx_exhook/priv/emqx_extension_hook.schema create mode 100644 apps/emqx_exhook/priv/protos/exhook.proto delete mode 100644 apps/emqx_exhook/sdk/README.md create mode 100644 apps/emqx_exhook/src/emqx_exhook.app.src create mode 100644 apps/emqx_exhook/src/emqx_exhook.app.src.script create mode 100644 apps/emqx_exhook/src/emqx_exhook.appup.src rename apps/emqx_exhook/src/{emqx_extension_hook.erl => emqx_exhook.erl} (55%) rename apps/emqx_exhook/src/{emqx_extension_hook_app.erl => emqx_exhook_app.erl} (80%) rename apps/emqx_exhook/src/{emqx_extension_hook_cli.erl => emqx_exhook_cli.erl} (64%) create mode 100644 apps/emqx_exhook/src/emqx_exhook_handler.erl create mode 100644 apps/emqx_exhook/src/emqx_exhook_server.erl rename apps/emqx_exhook/src/{emqx_extension_hook_sup.erl => emqx_exhook_sup.erl} (67%) delete mode 100644 apps/emqx_exhook/src/emqx_extension_hook.app.src delete mode 100644 apps/emqx_exhook/src/emqx_extension_hook_driver.erl delete mode 100644 apps/emqx_exhook/src/emqx_extension_hook_handler.erl create mode 100644 apps/emqx_exhook/test/emqx_exhook_SUITE.erl create mode 100644 apps/emqx_exhook/test/emqx_exhook_demo_svr.erl delete mode 100644 apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl create mode 100644 apps/emqx_exhook/test/props/prop_exhook_hooks.erl delete mode 100644 apps/emqx_exhook/test/scripts/Main.java delete mode 100644 apps/emqx_exhook/test/scripts/main.py create mode 100644 apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg delete mode 100644 apps/emqx_exproto/docs/sdk-specification.md delete mode 100644 apps/emqx_exproto/example/Main.java delete mode 100644 apps/emqx_exproto/example/main.py create mode 100644 apps/emqx_exproto/priv/protos/exproto.proto delete mode 100644 apps/emqx_exproto/sdk/README.md create mode 100644 apps/emqx_exproto/src/emqx_exproto.app.src.script create mode 100644 apps/emqx_exproto/src/emqx_exproto.appup.src delete mode 100644 apps/emqx_exproto/src/emqx_exproto_driver_mngr.erl create mode 100644 apps/emqx_exproto/src/emqx_exproto_gcli.erl create mode 100644 apps/emqx_exproto/src/emqx_exproto_gsvr.erl delete mode 100644 apps/emqx_exproto/src/emqx_exproto_types.erl create mode 100644 apps/emqx_exproto/test/emqx_exproto_echo_svr.erl create mode 100644 apps/emqx_rule_engine/include/rule_actions.hrl create mode 100644 apps/emqx_rule_engine/src/emqx_rule_actions_trans.erl create mode 100644 apps/emqx_rule_engine/src/emqx_rule_locker.erl diff --git a/apps/emqx_auth_http/etc/emqx_auth_http.conf b/apps/emqx_auth_http/etc/emqx_auth_http.conf index 86c4ac002..0df589169 100644 --- a/apps/emqx_auth_http/etc/emqx_auth_http.conf +++ b/apps/emqx_auth_http/etc/emqx_auth_http.conf @@ -101,8 +101,8 @@ auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t, ## -m: minute, e.g. '5m' for 5 minutes ## -s: second, e.g. '30s' for 30 seconds ## -## Default: 0 -## auth.http.request.timeout = 0 +## Default: 5s +## auth.http.request.timeout = 5s ## Connection time-out time, used during the initial request ## when the client is connecting to the server @@ -117,7 +117,7 @@ auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t, ## Value: integer ## ## Default: 3 -auth.http.request.retry_times = 3 +auth.http.request.retry_times = 5 ## The interval for re-sending the http request ## diff --git a/apps/emqx_auth_http/include/emqx_auth_http.hrl b/apps/emqx_auth_http/include/emqx_auth_http.hrl index 09e58e324..2bbe12827 100644 --- a/apps/emqx_auth_http/include/emqx_auth_http.hrl +++ b/apps/emqx_auth_http/include/emqx_auth_http.hrl @@ -1,7 +1,7 @@ -define(APP, emqx_auth_http). --record(http_request, {method = post, content_type, url, params, options = []}). +-record(http_request, {method = post, path, headers, params, request_timeout}). -record(auth_metrics, { success = 'client.auth.success', diff --git a/apps/emqx_auth_http/priv/emqx_auth_http.schema b/apps/emqx_auth_http/priv/emqx_auth_http.schema index e6a986344..4f4289db0 100644 --- a/apps/emqx_auth_http/priv/emqx_auth_http.schema +++ b/apps/emqx_auth_http/priv/emqx_auth_http.schema @@ -11,7 +11,7 @@ {mapping, "auth.http.auth_req.content_type", "emqx_auth_http.auth_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.auth_req.params", "emqx_auth_http.auth_req", [ @@ -25,7 +25,7 @@ Params = cuttlefish:conf_get("auth.http.auth_req.params", Conf), [{url, Url}, {method, cuttlefish:conf_get("auth.http.auth_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.auth_req.content_type", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.auth_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. @@ -41,7 +41,7 @@ end}. {mapping, "auth.http.super_req.content_type", "emqx_auth_http.super_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.super_req.params", "emqx_auth_http.super_req", [ @@ -53,7 +53,7 @@ end}. undefined -> cuttlefish:unset(); Url -> Params = cuttlefish:conf_get("auth.http.super_req.params", Conf), [{url, Url}, {method, cuttlefish:conf_get("auth.http.super_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.super_req.content_type", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.super_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. @@ -70,7 +70,7 @@ end}. {mapping, "auth.http.acl_req.content_type", "emqx_auth_http.acl_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.acl_req.params", "emqx_auth_http.acl_req", [ @@ -81,34 +81,56 @@ end}. case cuttlefish:conf_get("auth.http.acl_req", Conf, undefined) of undefined -> cuttlefish:unset(); Url -> Params = cuttlefish:conf_get("auth.http.acl_req.params", Conf), - [{url, Url}, {method, cuttlefish:conf_get("auth.http.acl_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.acl_req.content_type", Conf)}, + [{url, Url}, + {method, cuttlefish:conf_get("auth.http.acl_req.method", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.acl_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. -{mapping, "auth.http.request.timeout", "emqx_auth_http.http_opts", [ - {default, 0}, +{mapping, "auth.http.request.timeout", "emqx_auth_http.request_timeout", [ + {default, "5s"}, {datatype, [integer, {duration, ms}]} ]}. -{mapping, "auth.http.request.connect_timeout", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.pool_size", "emqx_auth_http.pool_opts", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.http.request.connect_timeout", "emqx_auth_http.pool_opts", [ + {default, "5s"}, {datatype, [integer, {duration, ms}]} ]}. -{mapping, "auth.http.ssl.cacertfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.cacertfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{mapping, "auth.http.ssl.certfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.certfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{mapping, "auth.http.ssl.keyfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.keyfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{translation, "emqx_auth_http.http_opts", fun(Conf) -> +{mapping, "auth.http.request.retry_times", "emqx_auth_http.pool_opts", [ + {default, 5}, + {datatype, integer} +]}. + +{mapping, "auth.http.request.retry_interval", "emqx_auth_http.pool_opts", [ + {default, "1s"}, + {datatype, {duration, ms}} +]}. + +{mapping, "auth.http.request.retry_backoff", "emqx_auth_http.pool_opts", [ + {default, 2.0}, + {datatype, float} +]}. + +{translation, "emqx_auth_http.pool_opts", fun(Conf) -> Filter = fun(L) -> [{K, V} || {K, V} <- L, V =/= undefined] end, InfinityFun = fun(0) -> infinity; (Duration) -> Duration @@ -116,8 +138,10 @@ end}. SslOpts = Filter([{cacertfile, cuttlefish:conf_get("auth.http.ssl.cacertfile", Conf, undefined)}, {certfile, cuttlefish:conf_get("auth.http.ssl.certfile", Conf, undefined)}, {keyfile, cuttlefish:conf_get("auth.http.ssl.keyfile", Conf, undefined)}]), - Opts = [{timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.timeout", Conf))}, - {connect_timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.connect_timeout", Conf, undefined))}], + Opts = [{pool_size, cuttlefish:conf_get("auth.http.pool_size", Conf)}, + {connect_timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.connect_timeout", Conf))}, + {retry, cuttlefish:conf_get("auth.http.request.retry_times", Conf)}, + {retry_timeout, cuttlefish:conf_get("auth.http.request.retry_interval", Conf)}], case SslOpts of [] -> Filter(Opts); _ -> @@ -131,26 +155,6 @@ end}. end end}. -{mapping, "auth.http.request.retry_times", "emqx_auth_http.retry_opts", [ - {default, 3}, - {datatype, integer} -]}. - -{mapping, "auth.http.request.retry_interval", "emqx_auth_http.retry_opts", [ - {default, "1s"}, - {datatype, {duration, ms}} -]}. - -{mapping, "auth.http.request.retry_backoff", "emqx_auth_http.retry_opts", [ - {default, 2.0}, - {datatype, float} -]}. - -{translation, "emqx_auth_http.retry_opts", fun(Conf) -> - [{times, cuttlefish:conf_get("auth.http.request.retry_times", Conf)}, - {interval, cuttlefish:conf_get("auth.http.request.retry_interval", Conf)}, - {backoff, cuttlefish:conf_get("auth.http.request.retry_backoff", Conf)}] -end}. {mapping, "auth.http.header.$field", "emqx_auth_http.headers", [ {datatype, string} diff --git a/apps/emqx_auth_http/rebar.config b/apps/emqx_auth_http/rebar.config index 026e6fc9b..0b1959427 100644 --- a/apps/emqx_auth_http/rebar.config +++ b/apps/emqx_auth_http/rebar.config @@ -1,4 +1,8 @@ -{deps, []}. +{deps, + [{cowlib, {git, "https://github.com/ninenines/cowlib", {tag, "2.7.0"}}}, + {gun, {git, "https://github.com/emqx/gun", {tag, "1.3.4"}}}, + {gproc, {git, "https://github.com/uwiger/gproc", {tag, "0.8.0"}}} + ]}. {edoc_opts, [{preprocess, true}]}. {erl_opts, [warn_unused_vars, @@ -20,7 +24,7 @@ [{test, [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "v1.2.2"}}} ]} ]} ]}. diff --git a/apps/emqx_auth_http/src/emqx_acl_http.erl b/apps/emqx_auth_http/src/emqx_acl_http.erl index ebe415937..a6f60b465 100644 --- a/apps/emqx_auth_http/src/emqx_acl_http.erl +++ b/apps/emqx_auth_http/src/emqx_acl_http.erl @@ -24,7 +24,7 @@ -logger_header("[ACL http]"). -import(emqx_auth_http_cli, - [ request/8 + [ request/6 , feedvar/2 ]). @@ -48,18 +48,16 @@ check_acl(ClientInfo, PubSub, Topic, AclResult, State) -> do_check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _AclResult, _Config) -> ok; -do_check_acl(ClientInfo, PubSub, Topic, _AclResult, #{acl_req := AclReq, - http_opts := HttpOpts, - retry_opts := RetryOpts, - headers := Headers}) -> +do_check_acl(ClientInfo, PubSub, Topic, _AclResult, #{acl_req := AclReq, + pool_name := PoolName}) -> ClientInfo1 = ClientInfo#{access => access(PubSub), topic => Topic}, - case check_acl_request(AclReq, ClientInfo1, Headers, HttpOpts, RetryOpts) of - {ok, 200, "ignore"} -> ok; + case check_acl_request(PoolName, AclReq, ClientInfo1) of + {ok, 200, <<"ignore">>} -> ok; {ok, 200, _Body} -> {stop, allow}; {ok, _Code, _Body} -> {stop, deny}; {error, Error} -> - ?LOG(error, "Request ACL url ~s, error: ~p", - [AclReq#http_request.url, Error]), + ?LOG(error, "Request ACL path ~s, error: ~p", + [AclReq#http_request.path, Error]), ok end. @@ -79,13 +77,12 @@ inc_metrics({stop, deny}) -> return_with(Fun, Result) -> Fun(Result), Result. -check_acl_request(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, Headers, HttpOpts, RetryOpts) -> - request(Method, ContentType, Url, feedvar(Params, ClientInfo), Headers, HttpOpts, Options, RetryOpts). +check_acl_request(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout). access(subscribe) -> 1; access(publish) -> 2. diff --git a/apps/emqx_auth_http/src/emqx_auth_http.app.src b/apps/emqx_auth_http/src/emqx_auth_http.app.src index eaabdb37e..ce73e10ea 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http.app.src +++ b/apps/emqx_auth_http/src/emqx_auth_http.app.src @@ -3,7 +3,7 @@ {vsn, "git"}, {modules, []}, {registered, [emqx_auth_http_sup]}, - {applications, [kernel,stdlib]}, + {applications, [kernel,stdlib,gproc,gun]}, {mod, {emqx_auth_http_app, []}}, {env, []}, {licenses, ["Apache-2.0"]}, diff --git a/apps/emqx_auth_http/src/emqx_auth_http.erl b/apps/emqx_auth_http/src/emqx_auth_http.erl index 54e41c989..20026d6ee 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http.erl @@ -25,7 +25,7 @@ -logger_header("[Auth http]"). -import(emqx_auth_http_cli, - [ request/8 + [ request/6 , feedvar/2 ]). @@ -41,28 +41,26 @@ register_metrics() -> check(ClientInfo, AuthResult, #{auth_req := AuthReq, super_req := SuperReq, - http_opts := HttpOpts, - retry_opts := RetryOpts, - headers := Headers}) -> - case authenticate(AuthReq, ClientInfo, Headers, HttpOpts, RetryOpts) of - {ok, 200, "ignore"} -> + pool_name := PoolName}) -> + case authenticate(PoolName, AuthReq, ClientInfo) of + {ok, 200, <<"ignore">>} -> emqx_metrics:inc(?AUTH_METRICS(ignore)), ok; {ok, 200, Body} -> emqx_metrics:inc(?AUTH_METRICS(success)), - IsSuperuser = is_superuser(SuperReq, ClientInfo, Headers, HttpOpts, RetryOpts), + IsSuperuser = is_superuser(PoolName, SuperReq, ClientInfo), {stop, AuthResult#{is_superuser => IsSuperuser, auth_result => success, anonymous => false, mountpoint => mountpoint(Body, ClientInfo)}}; {ok, Code, _Body} -> - ?LOG(error, "Deny connection from url: ~s, response http code: ~p", - [AuthReq#http_request.url, Code]), + ?LOG(error, "Deny connection from path: ~s, response http code: ~p", + [AuthReq#http_request.path, Code]), emqx_metrics:inc(?AUTH_METRICS(failure)), {stop, AuthResult#{auth_result => http_to_connack_error(Code), anonymous => false}}; {error, Error} -> - ?LOG(error, "Request auth url: ~s, error: ~p", - [AuthReq#http_request.url, Error]), + ?LOG(error, "Request auth path: ~s, error: ~p", + [AuthReq#http_request.path, Error]), emqx_metrics:inc(?AUTH_METRICS(failure)), %%FIXME later: server_unavailable is not right. {stop, AuthResult#{auth_result => server_unavailable, @@ -75,32 +73,30 @@ description() -> "Authentication by HTTP API". %% Requests %%-------------------------------------------------------------------- -authenticate(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, HttpHeaders, HttpOpts, RetryOpts) -> - request(Method, ContentType, Url, feedvar(Params, ClientInfo), HttpHeaders, HttpOpts, Options, RetryOpts). +authenticate(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout). --spec(is_superuser(maybe(#http_request{}), emqx_types:client(), list(), list(), list()) -> boolean()). -is_superuser(undefined, _ClientInfo, _HttpHeaders, _HttpOpts, _RetryOpts) -> +-spec(is_superuser(atom(), maybe(#http_request{}), emqx_types:client()) -> boolean()). +is_superuser(_PoolName, undefined, _ClientInfo) -> false; -is_superuser(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, HttpHeaders, HttpOpts, RetryOpts) -> - case request(Method, ContentType, Url, feedvar(Params, ClientInfo), HttpHeaders, HttpOpts, Options, RetryOpts) of +is_superuser(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + case request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout) of {ok, 200, _Body} -> true; {ok, _Code, _Body} -> false; - {error, Error} -> ?LOG(error, "Request superuser url ~s, error: ~p", [Url, Error]), + {error, Error} -> ?LOG(error, "Request superuser path ~s, error: ~p", [Path, Error]), false end. mountpoint(Body, #{mountpoint := Mountpoint}) -> - case emqx_json:safe_decode(iolist_to_binary(Body), [return_maps]) of + case emqx_json:safe_decode(Body, [return_maps]) of {error, _} -> Mountpoint; {ok, Json} when is_map(Json) -> maps:get(<<"mountpoint">>, Json, Mountpoint); diff --git a/apps/emqx_auth_http/src/emqx_auth_http_app.erl b/apps/emqx_auth_http/src/emqx_auth_http_app.erl index 51f6762ee..1d235ca2a 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http_app.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http_app.erl @@ -17,7 +17,6 @@ -module(emqx_auth_http_app). -behaviour(application). --behaviour(supervisor). -emqx_plugin(auth). @@ -33,37 +32,35 @@ %%-------------------------------------------------------------------- start(_StartType, _StartArgs) -> - with_env(auth_req, fun load_auth_hook/1), - with_env(acl_req, fun load_acl_hook/1), - supervisor:start_link({local, ?MODULE}, ?MODULE, []). + case translate_env() of + ok -> + {ok, PoolOpts} = application:get_env(?APP, pool_opts), + {ok, Sup} = emqx_http_client_sup:start_link(?APP, ssl(inet(PoolOpts))), + with_env(auth_req, fun load_auth_hook/1), + with_env(acl_req, fun load_acl_hook/1), + {ok, Sup}; + {error, Reason} -> + {error, Reason} + end. load_auth_hook(AuthReq) -> ok = emqx_auth_http:register_metrics(), SuperReq = r(application:get_env(?APP, super_req, undefined)), - HttpOpts = application:get_env(?APP, http_opts, []), - RetryOpts = application:get_env(?APP, retry_opts, []), - Headers = application:get_env(?APP, headers, []), Params = #{auth_req => AuthReq, super_req => SuperReq, - http_opts => HttpOpts, - retry_opts => maps:from_list(RetryOpts), - headers => Headers}, + pool_name => ?APP}, emqx:hook('client.authenticate', {emqx_auth_http, check, [Params]}). load_acl_hook(AclReq) -> ok = emqx_acl_http:register_metrics(), - HttpOpts = application:get_env(?APP, http_opts, []), - RetryOpts = application:get_env(?APP, retry_opts, []), - Headers = application:get_env(?APP, headers, []), - Params = #{acl_req => AclReq, - http_opts => HttpOpts, - retry_opts => maps:from_list(RetryOpts), - headers => Headers}, + Params = #{acl_req => AclReq, + pool_name => ?APP}, emqx:hook('client.check_acl', {emqx_acl_http, check_acl, [Params]}). stop(_State) -> emqx:unhook('client.authenticate', {emqx_auth_http, check}), - emqx:unhook('client.check_acl', {emqx_acl_http, check_acl}). + emqx:unhook('client.check_acl', {emqx_acl_http, check_acl}), + emqx_http_client_sup:stop_pool(?APP). %%-------------------------------------------------------------------- %% Dummy supervisor @@ -85,19 +82,66 @@ with_env(Par, Fun) -> r(undefined) -> undefined; r(Config) -> + Headers = application:get_env(?APP, headers, []), Method = proplists:get_value(method, Config, post), - ContentType = proplists:get_value(content_type, Config, 'x-www-form-urlencoded'), - Url = proplists:get_value(url, Config), + Path = proplists:get_value(path, Config), + NewHeaders = [{<<"content_type">>, proplists:get_value(content_type, Config, <<"application/x-www-form-urlencoded">>)} | Headers], Params = proplists:get_value(params, Config), - #http_request{method = Method, content_type = ContentType, url = Url, params = Params, options = inet(Url)}. + {ok, RequestTimeout} = application:get_env(?APP, request_timeout), + #http_request{method = Method, path = Path, headers = NewHeaders, params = Params, request_timeout = RequestTimeout}. -inet(Url) -> - case uri_string:parse(Url) of - #{host := Host} -> - case inet:parse_address(Host) of - {ok, Ip} when tuple_size(Ip) =:= 8 -> - [{ipv6_host_with_brackets, true}, {socket_opts, [{ipfamily, inet6}]}]; - _ -> [] - end; - _ -> [] +inet(PoolOpts) -> + case proplists:get_value(host, PoolOpts) of + Host when tuple_size(Host) =:= 8 -> + TransOpts = proplists:get_value(transport_opts, PoolOpts, []), + NewPoolOpts = proplists:delete(transport_opts, PoolOpts), + [{transport_opts, [inet6 | TransOpts]} | NewPoolOpts]; + _ -> + PoolOpts end. + +ssl(PoolOpts) -> + case proplists:get_value(ssl, PoolOpts, []) of + [] -> + PoolOpts; + SSLOpts -> + TransOpts = proplists:get_value(transport_opts, PoolOpts, []), + NewPoolOpts = proplists:delete(transport_opts, PoolOpts), + [{transport_opts, SSLOpts ++ TransOpts}, {transport, ssl} | NewPoolOpts] + end. + +translate_env() -> + URLs = lists:foldl(fun(Name, Acc) -> + case application:get_env(?APP, Name, []) of + [] -> Acc; + Env -> + URL = proplists:get_value(url, Env), + #{host := Host0, + port := Port, + path := Path} = uri_string:parse(list_to_binary(URL)), + {ok, Host} = inet:parse_address(binary_to_list(Host0)), + [{Name, {Host, Port, binary_to_list(Path)}} | Acc] + end + end, [], [acl_req, auth_req, super_req]), + case same_host_and_port(URLs) of + true -> + [begin + {ok, Req} = application:get_env(?APP, Name), + application:set_env(?APP, Name, [{path, Path} | Req]) + end || {Name, {_, _, Path}} <- URLs], + {_, {Host, Port, _}} = lists:last(URLs), + PoolOpts = application:get_env(?APP, pool_opts, []), + application:set_env(?APP, pool_opts, [{host, Host}, {port, Port} | PoolOpts]), + ok; + false -> + {error, different_server} + end. + +same_host_and_port([_]) -> + true; +same_host_and_port([{_, {Host, Port, _}}, {_, {Host, Port, _}}]) -> + true; +same_host_and_port([{_, {Host, Port, _}}, URL = {_, {Host, Port, _}} | Rest]) -> + same_host_and_port([URL | Rest]); +same_host_and_port(_) -> + false. \ No newline at end of file diff --git a/apps/emqx_auth_http/src/emqx_auth_http_cli.erl b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl index 35a20d0f0..25fac4300 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http_cli.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl @@ -16,7 +16,9 @@ -module(emqx_auth_http_cli). --export([ request/8 +-include("emqx_auth_http.hrl"). + +-export([ request/6 , feedvar/2 , feedvar/3 ]). @@ -25,36 +27,25 @@ %% HTTP Request %%-------------------------------------------------------------------- -request(get, _ContentType, Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url ++ "?" ++ cow_qs:qs(bin_kw(Params)), HttpHeaders}, - reply(request_(get, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)); +request(PoolName, get, Path, Headers, Params, Timeout) -> + NewPath = Path ++ "?" ++ cow_qs:qs(bin_kw(Params)), + reply(emqx_http_client:request(get, PoolName, {NewPath, Headers}, Timeout)); -request(post, 'x-www-form-urlencoded', Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url, HttpHeaders, "application/x-www-form-urlencoded", cow_qs:qs(bin_kw(Params))}, - reply(request_(post, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)); +request(PoolName, post, Path, Headers, Params, Timeout) -> + Body = case proplists:get_value(<<"content_type">>, Headers) of + <<"application/x-www-form-urlencoded">> -> + cow_qs:qs(bin_kw(Params)); + <<"application/json">> -> + emqx_json:encode(bin_kw(Params)) + end, + reply(emqx_http_client:request(post, PoolName, {Path, Headers, Body}, Timeout)). -request(post, json, Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url, HttpHeaders, "application/json", emqx_json:encode(bin_kw(Params))}, - reply(request_(post, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)). - -request_(Method, Req, HTTPOpts, Opts, RetryOpts = #{times := Times, - interval := Interval, - backoff := BackOff}) -> - case httpc:request(Method, Req, HTTPOpts, Opts) of - {error, _Reason} when Times > 0 -> - timer:sleep(trunc(Interval)), - RetryOpts1 = RetryOpts#{times := Times - 1, - interval := Interval * BackOff}, - request_(Method, Req, HTTPOpts, Opts, RetryOpts1); - Other -> Other - end. - -reply({ok, {{_, Code, _}, _Headers, Body}}) -> - {ok, Code, Body}; -reply({ok, Code, Body}) -> - {ok, Code, Body}; -reply({error, Error}) -> - {error, Error}. +reply({ok, StatusCode, _Headers}) -> + {ok, StatusCode, <<>>}; +reply({ok, StatusCode, _Headers, Body}) -> + {ok, StatusCode, Body}; +reply({error, Reason}) -> + {error, Reason}. %% TODO: move this conversion to cuttlefish config and schema bin_kw(KeywordList) when is_list(KeywordList) -> diff --git a/apps/emqx_auth_http/src/emqx_http_client.erl b/apps/emqx_auth_http/src/emqx_http_client.erl new file mode 100644 index 000000000..e29d798de --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_http_client.erl @@ -0,0 +1,256 @@ +-module(emqx_http_client). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). + +%% APIs +-export([ start_link/3 + , request/3 + , request/4 + ]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, { + pool :: ecpool:poo_name(), + id :: pos_integer(), + client :: pid() | undefined, + mref :: reference() | undefined, + host :: inet:hostname() | inet:ip_address(), + port :: inet:port_number(), + gun_opts :: proplists:proplist(), + gun_state :: down | up, + requests :: map() + }). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start_link(Pool, Id, Opts) -> + gen_server:start_link(?MODULE, [Pool, Id, Opts], []). + +request(Method, Pool, Req) -> + request(Method, Pool, Req, 5000). + +request(get, Pool, {Path, Headers}, Timeout) -> + call(pick(Pool), {get, {Path, Headers}, Timeout}, Timeout + 1000); +request(Method, Pool, {Path, Headers, Body}, Timeout) -> + call(pick(Pool), {Method, {Path, Headers, Body}, Timeout}, Timeout + 1000). + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Pool, Id, Opts]) -> + State = #state{pool = Pool, + id = Id, + client = undefined, + mref = undefined, + host = proplists:get_value(host, Opts), + port = proplists:get_value(port, Opts), + gun_opts = gun_opts(Opts), + gun_state = down, + requests = #{}}, + true = gproc_pool:connect_worker(Pool, {Pool, Id}), + {ok, State}. + +handle_call(Req = {_, _, _}, From, State = #state{client = undefined, gun_state = down}) -> + case open(State) of + {ok, NewState} -> + handle_call(Req, From, NewState); + {error, Reason} -> + {reply, {error, Reason}, State} + end; + +handle_call(Req = {_, _, Timeout}, From, State = #state{client = Client, mref = MRef, gun_state = down}) when is_pid(Client) -> + case gun:await_up(Client, Timeout, MRef) of + {ok, _} -> + handle_call(Req, From, State#state{gun_state = up}); + {error, timeout} -> + {reply, {error, timeout}, State}; + {error, Reason} -> + true = erlang:demonitor(MRef, [flush]), + {reply, {error, Reason}, State#state{client = undefined, mref = undefined}} + end; + +handle_call({Method, Request, Timeout}, From, State = #state{client = Client, requests = Requests, gun_state = up}) when is_pid(Client) -> + StreamRef = do_request(Client, Method, Request), + ExpirationTime = erlang:system_time(millisecond) + Timeout, + {noreply, State#state{requests = maps:put(StreamRef, {From, ExpirationTime, undefined}, Requests)}}; + +handle_call(Req, _From, State) -> + ?LOG(error, "Unexpected call: ~p", [Req]), + {reply, ignored, State}. + +handle_cast(Msg, State) -> + ?LOG(error, "Unexpected cast: ~p", [Msg]), + {noreply, State}. + +handle_info({gun_response, Client, StreamRef, IsFin, StatusCode, Headers}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_response' message from unknown stream ref: ~p", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + gun:cancel(Client, StreamRef), + flush_stream(Client, StreamRef), + {noreply, State#state{requests = NRequests}}; + {{From, ExpirationTime, undefined}, NRequests} -> + case IsFin of + fin -> + gen_server:reply(From, {ok, StatusCode, Headers}), + {noreply, State#state{requests = NRequests}}; + nofin -> + {noreply, State#state{requests = NRequests#{StreamRef => {From, ExpirationTime, {StatusCode, Headers, <<>>}}}}} + end; + _ -> + ?LOG(error, "Received 'gun_response' message does not match the state"), + {noreply, State} + end; + +handle_info({gun_data, Client, StreamRef, IsFin, Data}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_data' message from unknown stream ref: ~p", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + gun:cancel(Client, StreamRef), + flush_stream(Client, StreamRef), + {noreply, State#state{requests = NRequests}}; + {{From, ExpirationTime, {StatusCode, Headers, Acc}}, NRequests} -> + case IsFin of + fin -> + gen_server:reply(From, {ok, StatusCode, Headers, <>}), + {noreply, State#state{requests = NRequests}}; + nofin -> + {noreply, State#state{requests = NRequests#{StreamRef => {From, ExpirationTime, {StatusCode, Headers, <>}}}}} + end; + _ -> + ?LOG(error, "Received 'gun_data' message does not match the state"), + {noreply, State} + end; + +handle_info({gun_error, Client, StreamRef, Reason}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_error' message from unknown stream ref: ~p~n", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + {noreply, State#state{requests = NRequests}}; + {{From, _, _}, NRequests} -> + gen_server:reply(From, {error, Reason}), + {noreply, State#state{requests = NRequests}} + end; + +handle_info({gun_up, Client, _}, State = #state{client = Client}) -> + {noreply, State#state{gun_state = up}}; + +handle_info({gun_down, Client, _, Reason, KilledStreams, _}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + NRequests = lists:foldl(fun(StreamRef, Acc) -> + case maps:take(StreamRef, Acc) of + error -> Acc; + {{_, ExpirationTime, _}, NAcc} when Now > ExpirationTime -> + NAcc; + {{From, _, _}, NAcc} -> + gen_server:reply(From, {error, Reason}), + NAcc + end + end, Requests, KilledStreams), + {noreply, State#state{gun_state = down, requests = NRequests}}; + +handle_info({'DOWN', MRef, process, Client, Reason}, State = #state{mref = MRef, client = Client, requests = Requests}) -> + true = erlang:demonitor(MRef, [flush]), + Now = erlang:system_time(millisecond), + lists:foreach(fun({_, {_, ExpirationTime, _}}) when Now > ExpirationTime -> + ok; + ({_, {From, _, _}}) -> + gen_server:reply(From, {error, Reason}) + end, maps:to_list(Requests)), + case open(State#state{requests = #{}}) of + {ok, NewState} -> + {noreply, NewState}; + {error, Reason} -> + {noreply, State#state{mref = undefined, client = undefined}} + end; + +handle_info(Info, State) -> + ?LOG(error, "Unexpected info: ~p", [Info]), + {noreply, State}. + +terminate(_Reason, #state{pool = Pool, id = Id}) -> + gropc:disconnect_worker(Pool, {Pool, Id}), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +open(State = #state{host = Host, port = Port, gun_opts = GunOpts}) -> + case gun:open(Host, Port, GunOpts) of + {ok, ConnPid} when is_pid(ConnPid) -> + MRef = monitor(process, ConnPid), + {ok, State#state{mref = MRef, client = ConnPid}}; + {error, Reason} -> + {error, Reason} + end. + +gun_opts(Opts) -> + gun_opts(Opts, #{retry => 5, + retry_timeout => 1000, + connect_timeout => 5000, + protocols => [http], + http_opts => #{keepalive => infinity}}). + +gun_opts([], Acc) -> + Acc; +gun_opts([{retry, Retry} | Opts], Acc) -> + gun_opts(Opts, Acc#{retry => Retry}); +gun_opts([{retry_timeout, RetryTimeout} | Opts], Acc) -> + gun_opts(Opts, Acc#{retry_timeout => RetryTimeout}); +gun_opts([{connect_timeout, ConnectTimeout} | Opts], Acc) -> + gun_opts(Opts, Acc#{connect_timeout => ConnectTimeout}); +gun_opts([{transport, Transport} | Opts], Acc) -> + gun_opts(Opts, Acc#{transport => Transport}); +gun_opts([{transport_opts, TransportOpts} | Opts], Acc) -> + gun_opts(Opts, Acc#{transport_opts => TransportOpts}); +gun_opts([_ | Opts], Acc) -> + gun_opts(Opts, Acc). + +call(ChannPid, Msg, Timeout) -> + gen_server:call(ChannPid, Msg, Timeout). + +pick(Pool) -> + gproc_pool:pick_worker(Pool). + +do_request(Client, get, {Path, Headers}) -> + gun:get(Client, Path, Headers); +do_request(Client, post, {Path, Headers, Body}) -> + gun:post(Client, Path, Headers, Body). + +flush_stream(Client, StreamRef) -> + receive + {gun_response, Client, StreamRef, _, _, _} -> + flush_stream(Client, StreamRef); + {gun_data, Client, StreamRef, _, _} -> + flush_stream(Client, StreamRef); + {gun_error, Client, StreamRef, _} -> + flush_stream(Client, StreamRef) + after 0 -> + ok + end. \ No newline at end of file diff --git a/apps/emqx_auth_http/src/emqx_http_client_sup.erl b/apps/emqx_auth_http/src/emqx_http_client_sup.erl new file mode 100644 index 000000000..dcdd2e4c4 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_http_client_sup.erl @@ -0,0 +1,48 @@ +-module(emqx_http_client_sup). + +-behaviour(supervisor). + +-export([ start_link/2 + , init/1 + , stop_pool/1 + ]). + +start_link(Pool, Opts) -> + supervisor:start_link(?MODULE, [Pool, Opts]). + +init([Pool, Opts]) -> + PoolSize = pool_size(Opts), + ok = ensure_pool(Pool, random, [{size, PoolSize}]), + {ok, {{one_for_one, 10, 100}, [ + begin + ensure_pool_worker(Pool, {Pool, I}, I), + #{id => {Pool, I}, + start => {emqx_http_client, start_link, [Pool, I, Opts]}, + restart => transient, + shutdown => 5000, + type => worker, + modules => [emqx_http_client]} + end || I <- lists:seq(1, PoolSize)]}}. + + +ensure_pool(Pool, Type, Opts) -> + try gproc_pool:new(Pool, Type, Opts) + catch + error:exists -> ok + end. + +ensure_pool_worker(Pool, Name, Slot) -> + try gproc_pool:add_worker(Pool, Name, Slot) + catch + error:exists -> ok + end. + +pool_size(Opts) -> + Schedulers = erlang:system_info(schedulers), + proplists:get_value(pool_size, Opts, Schedulers). + +stop_pool(Name) -> + Workers = gproc_pool:defined_workers(Name), + [gproc_pool:remove_worker(Name, WokerName) || {WokerName, _, _} <- Workers], + gproc_pool:delete(Name), + ok. \ No newline at end of file diff --git a/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl index 25ff942c5..79c02c00c 100644 --- a/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl +++ b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl @@ -64,32 +64,38 @@ set_special_configs(emqx, _Schmea, _Inet) -> emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); set_special_configs(emqx_auth_http, Schema, Inet) -> - AuthReq = maps:from_list(application:get_env(emqx_auth_http, auth_req, [])), - SuprReq = maps:from_list(application:get_env(emqx_auth_http, super_req, [])), - AclReq = maps:from_list(application:get_env(emqx_auth_http, acl_req, [])), - SvrAddr = http_server_host(Schema, Inet), + ServerAddr = http_server(Schema, Inet), - AuthReq1 = AuthReq#{method := get, url := SvrAddr ++ "/mqtt/auth"}, - SuprReq1 = SuprReq#{method := post, content_type := 'x-www-form-urlencoded', url := SvrAddr ++ "/mqtt/superuser"}, - AclReq1 = AclReq #{method := post, content_type := json, url := SvrAddr ++ "/mqtt/acl"}, + AuthReq = #{method => get, + url => ServerAddr ++ "/mqtt/auth", + content_type => <<"application/x-www-form-urlencoded">>, + params => [{"clientid", "%c"}, {"username", "%u"}, {"password", "%P"}]}, + SuperReq = #{method => post, + url => ServerAddr ++ "/mqtt/superuser", + content_type => <<"application/x-www-form-urlencoded">>, + params => [{"clientid", "%c"}, {"username", "%u"}]}, + AclReq = #{method => post, + url => ServerAddr ++ "/mqtt/acl", + content_type => <<"application/json">>, + params => [{"access", "%A"}, {"username", "%u"}, {"clientid", "%c"}, {"ipaddr", "%a"}, {"topic", "%t"}, {"mountpoint", "%m"}]}, Schema =:= https andalso set_https_client_opts(), - application:set_env(emqx_auth_http, auth_req, maps:to_list(AuthReq1)), - application:set_env(emqx_auth_http, super_req, maps:to_list(SuprReq1)), - application:set_env(emqx_auth_http, acl_req, maps:to_list(AclReq1)). + application:set_env(emqx_auth_http, auth_req, maps:to_list(AuthReq)), + application:set_env(emqx_auth_http, super_req, maps:to_list(SuperReq)), + application:set_env(emqx_auth_http, acl_req, maps:to_list(AclReq)). %% @private set_https_client_opts() -> - HttpOpts = maps:from_list(application:get_env(emqx_auth_http, http_opts, [])), - HttpOpts1 = HttpOpts#{ssl => emqx_ct_helpers:client_ssl_twoway()}, - application:set_env(emqx_auth_http, http_opts, maps:to_list(HttpOpts1)). + TransportOpts = emqx_ct_helpers:client_ssl_twoway(), + {ok, PoolOpts} = application:get_env(emqx_auth_http, pool_opts), + application:set_env(emqx_auth_http, pool_opts, [{transport_opts, TransportOpts}, {transport, ssl} | PoolOpts]). %% @private -http_server_host(http, inet) -> "http://127.0.0.1:8991"; -http_server_host(http, inet6) -> "http://[::1]:8991"; -http_server_host(https, inet) -> "https://127.0.0.1:8991"; -http_server_host(https, inet6) -> "https://[::1]:8991". +http_server(http, inet) -> "http://127.0.0.1:8991"; +http_server(http, inet6) -> "http://[::1]:8991"; +http_server(https, inet) -> "https://127.0.0.1:8991"; +http_server(https, inet6) -> "https://[::1]:8991". %%------------------------------------------------------------------------------ %% Testcases diff --git a/apps/emqx_auth_jwt/.gitignore b/apps/emqx_auth_jwt/.gitignore index d038bde4d..62e4fbb25 100644 --- a/apps/emqx_auth_jwt/.gitignore +++ b/apps/emqx_auth_jwt/.gitignore @@ -25,3 +25,4 @@ rebar3.crashdump etc/emqx_auth_jwt.conf.rendered .rebar3/ *.swp +Mnesia.nonode@nohost/ diff --git a/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf index b9a1caa04..5a599ca23 100644 --- a/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf +++ b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf @@ -7,17 +7,28 @@ ## Value: String auth.jwt.secret = emqxsecret +## RSA or ECDSA public key file. +## +## Value: File +#auth.jwt.pubkey = etc/certs/jwt_public_key.pem + +## The JWKs server address +## +## see: http://self-issued.info/docs/draft-ietf-jose-json-web-key.html +## +#auth.jwt.jwks = https://127.0.0.1:8080/jwks + +## The JWKs refresh interval +## +## Value: Duration +#auth.jwt.jwks.refresh_interval = 5m + ## From where the JWT string can be got ## ## Value: username | password ## Default: password auth.jwt.from = password -## RSA or ECDSA public key file. -## -## Value: File -## auth.jwt.pubkey = etc/certs/jwt_public_key.pem - ## Enable to verify claims fields ## ## Value: on | off @@ -31,9 +42,4 @@ auth.jwt.verify_claims = off ## Variables: ## - %u: username ## - %c: clientid -# auth.jwt.verify_claims.username = %u - -## The Signature format -## - `der`: The erlang default format -## - `raw`: Compatible with others platform maybe -#auth.jwt.signature_format = der +#auth.jwt.verify_claims.username = %u diff --git a/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema index e8210a8cd..3d8de3678 100644 --- a/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema +++ b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema @@ -4,6 +4,14 @@ {datatype, string} ]}. +{mapping, "auth.jwt.jwks", "emqx_auth_jwt.jwks", [ + {datatype, string} +]}. + +{mapping, "auth.jwt.jwks.refresh_interval", "emqx_auth_jwt.refresh_interval", [ + {datatype, {duration, ms}} +]}. + {mapping, "auth.jwt.from", "emqx_auth_jwt.from", [ {default, password}, {datatype, atom} @@ -13,6 +21,11 @@ {datatype, string} ]}. +{mapping, "auth.jwt.signature_format", "emqx_auth_jwt.jwerl_opts", [ + {default, "der"}, + {datatype, {enum, [raw, der]}} +]}. + {mapping, "auth.jwt.verify_claims", "emqx_auth_jwt.verify_claims", [ {default, off}, {datatype, flag} @@ -34,15 +47,3 @@ end, [], cuttlefish_variable:filter_by_prefix("auth.jwt.verify_claims", Conf)) end end}. - -{mapping, "auth.jwt.signature_format", "emqx_auth_jwt.jwerl_opts", [ - {default, "der"}, - {datatype, {enum, [raw, der]}} -]}. - -{translation, "emqx_auth_jwt.jwerl_opts", fun(Conf) -> - Filter = fun(L) -> [I || I <- L, I /= undefined] end, - maps:from_list(Filter( - [{raw, cuttlefish:conf_get("auth.jwt.signature_format", Conf) == raw}] - )) -end}. diff --git a/apps/emqx_auth_jwt/rebar.config b/apps/emqx_auth_jwt/rebar.config index f711075ba..4164d1fed 100644 --- a/apps/emqx_auth_jwt/rebar.config +++ b/apps/emqx_auth_jwt/rebar.config @@ -1,5 +1,6 @@ {deps, - [{jwerl, {git, "https://github.com/emqx/jwerl.git", {branch, "1.1.1"}}} + [ + {jose, {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.10.1"}}} ]}. {edoc_opts, [{preprocess, true}]}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src index 5c76d9114..e5d25e11b 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src @@ -3,7 +3,7 @@ {vsn, "git"}, {modules, []}, {registered, [emqx_auth_jwt_sup]}, - {applications, [kernel,stdlib,jwerl]}, + {applications, [kernel,stdlib,jose]}, {mod, {emqx_auth_jwt_app, []}}, {env, []}, {licenses, ["Apache-2.0"]}, diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src new file mode 100644 index 000000000..0c7b8ebf3 --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src @@ -0,0 +1,10 @@ +%% -*-: erlang -*- + +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl index a00bc2577..6be726dc9 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl @@ -46,77 +46,31 @@ register_metrics() -> %% Authentication callbacks %%-------------------------------------------------------------------- -check(ClientInfo, AuthResult, Env = #{from := From, checklists := Checklists}) -> +check(ClientInfo, AuthResult, #{pid := Pid, + from := From, + checklists := Checklists}) -> case maps:find(From, ClientInfo) of error -> - ok = emqx_metrics:inc(?AUTH_METRICS(ignore)), - {ok, AuthResult#{auth_result => token_undefined, anonymous => false}}; + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {ok, undefined} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); {ok, Token} -> - try jwerl:header(Token) of - Headers -> - case verify_token(Headers, Token, Env) of - {ok, Claims} -> - {stop, maps:merge(AuthResult, verify_claims(Checklists, Claims, ClientInfo))}; - {error, Reason} -> - ok = emqx_metrics:inc(?AUTH_METRICS(failure)), - {stop, AuthResult#{auth_result => Reason, anonymous => false}} - end - catch - _Error:Reason -> - ?LOG(error, "Check token error: ~p", [Reason]), - emqx_metrics:inc(?AUTH_METRICS(ignore)) + case emqx_auth_jwt_svr:verify(Pid, Token) of + {error, not_found} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, not_token} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, Reason} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => Reason, anonymous => false}}; + {ok, Claims} -> + {stop, maps:merge(AuthResult, verify_claims(Checklists, Claims, ClientInfo))} end end. description() -> "Authentication with JWT". -%%-------------------------------------------------------------------- -%% Verify Token -%%-------------------------------------------------------------------- - -verify_token(#{alg := <<"HS", _/binary>>}, _Token, #{secret := undefined}) -> - {error, hmac_secret_undefined}; -verify_token(#{alg := Alg = <<"HS", _/binary>>}, Token, #{secret := Secret, opts := Opts}) -> - verify_token2(Alg, Token, Secret, Opts); - -verify_token(#{alg := <<"RS", _/binary>>}, _Token, #{pubkey := undefined}) -> - {error, rsa_pubkey_undefined}; -verify_token(#{alg := Alg = <<"RS", _/binary>>}, Token, #{pubkey := PubKey, opts := Opts}) -> - verify_token2(Alg, Token, PubKey, Opts); - -verify_token(#{alg := <<"ES", _/binary>>}, _Token, #{pubkey := undefined}) -> - {error, ecdsa_pubkey_undefined}; -verify_token(#{alg := Alg = <<"ES", _/binary>>}, Token, #{pubkey := PubKey, opts := Opts}) -> - verify_token2(Alg, Token, PubKey, Opts); - -verify_token(Header, _Token, _Env) -> - ?LOG(error, "Unsupported token algorithm: ~p", [Header]), - {error, token_unsupported}. - -verify_token2(Alg, Token, SecretOrKey, Opts) -> - try jwerl:verify(Token, decode_algo(Alg), SecretOrKey, #{}, Opts) of - {ok, Claims} -> - {ok, Claims}; - {error, Reason} -> - {error, Reason} - catch - _Error:Reason -> - {error, Reason} - end. - -decode_algo(<<"HS256">>) -> hs256; -decode_algo(<<"HS384">>) -> hs384; -decode_algo(<<"HS512">>) -> hs512; -decode_algo(<<"RS256">>) -> rs256; -decode_algo(<<"RS384">>) -> rs384; -decode_algo(<<"RS512">>) -> rs512; -decode_algo(<<"ES256">>) -> es256; -decode_algo(<<"ES384">>) -> es384; -decode_algo(<<"ES512">>) -> es512; -decode_algo(<<"none">>) -> none; -decode_algo(Alg) -> throw({error, {unsupported_algorithm, Alg}}). - -%%-------------------------------------------------------------------- +%%------------------------------------------------------------------------------ %% Verify Claims %%-------------------------------------------------------------------- @@ -143,4 +97,3 @@ feedvar(Checklists, #{username := Username, clientid := ClientId}) -> ({K, <<"%c">>}) -> {K, ClientId}; ({K, Expected}) -> {K, Expected} end, Checklists). - diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl index 511f6e826..736fb28b9 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl @@ -28,42 +28,55 @@ -define(APP, emqx_auth_jwt). --define(JWT_ACTION, {emqx_auth_jwt, check, [auth_env()]}). - start(_Type, _Args) -> - ok = emqx_auth_jwt:register_metrics(), - emqx:hook('client.authenticate', ?JWT_ACTION), - supervisor:start_link({local, ?MODULE}, ?MODULE, []). + {ok, Sup} = supervisor:start_link({local, ?MODULE}, ?MODULE, []), -stop(_State) -> - emqx:unhook('client.authenticate', ?JWT_ACTION). + {ok, Pid} = start_auth_server(jwks_svr_options()), + ok = emqx_auth_jwt:register_metrics(), + + AuthEnv0 = auth_env(), + AuthEnv1 = AuthEnv0#{pid => Pid}, + + emqx:hook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv1]}), + {ok, Sup, AuthEnv1}. + +stop(AuthEnv) -> + emqx:unhook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv]}). %%-------------------------------------------------------------------- %% Dummy supervisor %%-------------------------------------------------------------------- init([]) -> - {ok, { {one_for_all, 1, 10}, []} }. + {ok, {{one_for_all, 1, 10}, []}}. + +start_auth_server(Options) -> + Spec = #{id => jwt_svr, + start => {emqx_auth_jwt_svr, start_link, [Options]}, + restart => permanent, + shutdown => brutal_kill, + type => worker, + modules => [emqx_auth_jwt_svr]}, + supervisor:start_child(?MODULE, Spec). %%-------------------------------------------------------------------- %% Internal functions %%-------------------------------------------------------------------- auth_env() -> - #{secret => env(secret, undefined), - from => env(from, password), - pubkey => read_pubkey(), - checklists => env(verify_claims, []), - opts => env(jwerl_opts, #{}) + Checklists = [{atom_to_binary(K, utf8), V} + || {K, V} <- env(verify_claims, [])], + #{ from => env(from, password) + , checklists => Checklists }. -read_pubkey() -> - case env(pubkey, undefined) of - undefined -> undefined; - Path -> - {ok, PubKey} = file:read_file(Path), PubKey - end. +jwks_svr_options() -> + [{K, V} || {K, V} + <- [{secret, env(secret, undefined)}, + {pubkey, env(pubkey, undefined)}, + {jwks_addr, env(jwks, undefined)}, + {interval, env(refresh_interval, undefined)}], + V /= undefined]. env(Key, Default) -> application:get_env(?APP, Key, Default). - diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl new file mode 100644 index 000000000..b347d0e0b --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl @@ -0,0 +1,222 @@ +%%-------------------------------------------------------------------- +%% 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_auth_jwt_svr). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("jose/include/jose_jwk.hrl"). + +-logger_header("[JWT-SVR]"). + +%% APIs +-export([start_link/1]). + +-export([verify/2]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-type options() :: [option()]. +-type option() :: {secret, list()} + | {pubkey, list()} + | {jwks_addr, list()} + | {interval, pos_integer()}. + +-define(INTERVAL, 300000). + +-record(state, {static, remote, addr, tref, intv}). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +-spec start_link(options()) -> gen_server:start_ret(). +start_link(Options) -> + gen_server:start_link(?MODULE, [Options], []). + +-spec verify(pid(), binary()) + -> {error, term()} + | {ok, Payload :: map()}. +verify(S, JwsCompacted) when is_binary(JwsCompacted) -> + case catch jose_jws:peek(JwsCompacted) of + {'EXIT', _} -> {error, not_token}; + _ -> gen_server:call(S, {verify, JwsCompacted}) + end. + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Options]) -> + ok = jose:json_module(jiffy), + {Static, Remote} = do_init_jwks(Options), + Intv = proplists:get_value(interval, Options, ?INTERVAL), + {ok, reset_timer( + #state{ + static = Static, + remote = Remote, + addr = proplists:get_value(jwks_addr, Options), + intv = Intv})}. + +%% @private +do_init_jwks(Options) -> + K2J = fun(K, F) -> + case proplists:get_value(K, Options) of + undefined -> undefined; + V -> + try F(V) of + {error, Reason} -> + ?LOG(warning, "Build ~p JWK ~p failed: {error, ~p}~n", + [K, V, Reason]), + undefined; + J -> J + catch T:R:_ -> + ?LOG(warning, "Build ~p JWK ~p failed: {~p, ~p}~n", + [K, V, T, R]), + undefined + end + end + end, + OctJwk = K2J(secret, fun(V) -> + jose_jwk:from_oct(list_to_binary(V)) + end), + PemJwk = K2J(pubkey, fun jose_jwk:from_pem_file/1), + Remote = K2J(jwks_addr, fun request_jwks/1), + {[J ||J <- [OctJwk, PemJwk], J /= undefined], Remote}. + +handle_call({verify, JwsCompacted}, _From, State) -> + handle_verify(JwsCompacted, State); + +handle_call(_Req, _From, State) -> + {reply, ok, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({timeout, _TRef, refresh}, State = #state{addr = Addr}) -> + NState = try + State#state{remote = request_jwks(Addr)} + catch _:_ -> + State + end, + {noreply, reset_timer(NState)}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, State) -> + _ = cancel_timer(State), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +handle_verify(JwsCompacted, + State = #state{static = Static, remote = Remote}) -> + try + Jwks = case emqx_json:decode(jose_jws:peek_protected(JwsCompacted), [return_maps]) of + #{<<"kid">> := Kid} -> + [J || J <- Remote, maps:get(<<"kid">>, J#jose_jwk.fields, undefined) =:= Kid]; + _ -> Static + end, + case Jwks of + [] -> {reply, {error, not_found}, State}; + _ -> + {reply, do_verify(JwsCompacted, Jwks), State} + end + catch + _:_ -> + {reply, {error, invalid_signature}, State} + end. + +request_jwks(Addr) -> + case httpc:request(get, {Addr, []}, [], [{body_format, binary}]) of + {error, Reason} -> + error(Reason); + {ok, {_Code, _Headers, Body}} -> + try + JwkSet = jose_jwk:from(emqx_json:decode(Body, [return_maps])), + {_, Jwks} = JwkSet#jose_jwk.keys, Jwks + catch _:_ -> + ?LOG(error, "Invalid jwks server response: ~p~n", [Body]), + error(badarg) + end + end. + +reset_timer(State = #state{addr = undefined}) -> + State; +reset_timer(State = #state{intv = Intv}) -> + State#state{tref = erlang:start_timer(Intv, self(), refresh)}. + +cancel_timer(State = #state{tref = undefined}) -> + State; +cancel_timer(State = #state{tref = TRef}) -> + erlang:cancel_timer(TRef), + State#state{tref = undefined}. + +do_verify(_JwsCompated, []) -> + {error, invalid_signature}; +do_verify(JwsCompacted, [Jwk|More]) -> + case jose_jws:verify(Jwk, JwsCompacted) of + {true, Payload, _Jws} -> + Claims = emqx_json:decode(Payload, [return_maps]), + case check_claims(Claims) of + false -> + {error, invalid_signature}; + NClaims -> + {ok, NClaims} + end; + {false, _, _} -> + do_verify(JwsCompacted, More) + end. + +check_claims(Claims) -> + Now = os:system_time(seconds), + Checker = [{<<"exp">>, fun(ExpireTime) -> + Now < ExpireTime + end}, + {<<"iat">>, fun(IssueAt) -> + IssueAt =< Now + end}, + {<<"nbf">>, fun(NotBefore) -> + NotBefore =< Now + end} + ], + do_check_claim(Checker, Claims). + +do_check_claim([], Claims) -> + Claims; +do_check_claim([{K, F}|More], Claims) -> + case maps:take(K, Claims) of + error -> do_check_claim(More, Claims); + {V, NClaims} -> + case F(V) of + true -> do_check_claim(More, NClaims); + _ -> false + end + end. diff --git a/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl index 190c3db14..12f307b2a 100644 --- a/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl +++ b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl @@ -16,8 +16,8 @@ -module(emqx_auth_jwt_SUITE). --compile(nowarn_export_all). -compile(export_all). +-compile(nowarn_export_all). -include_lib("emqx/include/emqx.hrl"). -include_lib("eunit/include/eunit.hrl"). @@ -61,28 +61,34 @@ set_special_configs(emqx_auth_jwt) -> set_special_configs(_) -> ok. +sign(Payload, Alg, Key) -> + Jwk = jose_jwk:from_oct(Key), + Jwt = emqx_json:encode(Payload), + {_, Token} = jose_jws:compact(jose_jwt:sign(Jwk, #{<<"alg">> => Alg}, Jwt)), + Token. + %%------------------------------------------------------------------------------ %% Testcases %%------------------------------------------------------------------------------ t_check_auth(_) -> Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), ct:pal("Jwt: ~p~n", [Jwt]), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), - ?assertMatch({ok, #{auth_result := success, jwt_claims := #{clientid := <<"client1">>}}}, Result0), + ?assertMatch({ok, #{auth_result := success, jwt_claims := #{<<"clientid">> := <<"client1">>}}}, Result0), ct:sleep(3100), Result1 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result after 1000ms: ~p~n", [Result1]), ?assertMatch({error, _}, Result1), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), ct:pal("invalid jwt: ~p~n", [Jwt_Error]), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), @@ -92,15 +98,15 @@ t_check_auth(_) -> t_check_claims(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{sub, <<"value">>}]), Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}, - {sub, value}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}, + {sub, value}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), ?assertEqual({error, invalid_signature}, Result2). @@ -108,14 +114,14 @@ t_check_claims(_) -> t_check_claims_clientid(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{clientid, <<"%c">>}]), Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client23">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), ?assertEqual({error, invalid_signature}, Result2). @@ -123,15 +129,14 @@ t_check_claims_clientid(_) -> t_check_claims_username(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{username, <<"%u">>}]), Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client23">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result3 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result3]), ?assertEqual({error, invalid_signature}, Result3). - diff --git a/apps/emqx_auth_ldap/rebar.config b/apps/emqx_auth_ldap/rebar.config index 983d6b88c..c155efaed 100644 --- a/apps/emqx_auth_ldap/rebar.config +++ b/apps/emqx_auth_ldap/rebar.config @@ -1,6 +1,6 @@ {deps, [{eldap2, {git, "https://github.com/emqx/eldap2", {tag, "v0.2.2"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. diff --git a/apps/emqx_auth_mongo/rebar.config b/apps/emqx_auth_mongo/rebar.config index cebbf68f9..b946b5a95 100644 --- a/apps/emqx_auth_mongo/rebar.config +++ b/apps/emqx_auth_mongo/rebar.config @@ -1,6 +1,6 @@ {deps, [{mongodb, {git,"https://github.com/emqx/mongodb-erlang", {tag, "v3.0.7"}}}, - {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -28,7 +28,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_mysql/rebar.config b/apps/emqx_auth_mysql/rebar.config index c77aef44c..2a0aa4138 100644 --- a/apps/emqx_auth_mysql/rebar.config +++ b/apps/emqx_auth_mysql/rebar.config @@ -28,7 +28,7 @@ [{test, [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src b/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src index e4c16f92b..a4c8410af 100644 --- a/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src @@ -1,25 +1,9 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ] }. diff --git a/apps/emqx_auth_pgsql/rebar.config b/apps/emqx_auth_pgsql/rebar.config index 469412195..98b95fce7 100644 --- a/apps/emqx_auth_pgsql/rebar.config +++ b/apps/emqx_auth_pgsql/rebar.config @@ -1,6 +1,6 @@ {deps, [{epgsql, {git, "https://github.com/epgsql/epgsql", {tag, "4.4.0"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -26,7 +26,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {branch, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src index 657b119a1..a4c8410af 100644 --- a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src @@ -1,25 +1,9 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ] }. diff --git a/apps/emqx_auth_redis/.gitignore b/apps/emqx_auth_redis/.gitignore index d7472fa8f..0cfec36f4 100644 --- a/apps/emqx_auth_redis/.gitignore +++ b/apps/emqx_auth_redis/.gitignore @@ -22,3 +22,5 @@ erlang.mk .rebar3/ *.swp rebar.lock +/.idea/ +.DS_Store diff --git a/apps/emqx_auth_redis/rebar.config b/apps/emqx_auth_redis/rebar.config index 5fac9befb..367b74acd 100644 --- a/apps/emqx_auth_redis/rebar.config +++ b/apps/emqx_auth_redis/rebar.config @@ -1,6 +1,6 @@ {deps, - [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.2"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.3"}}}, + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -26,7 +26,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src b/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src index 671b9274f..d05d8148f 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src +++ b/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src @@ -1,36 +1,10 @@ -{"4.2.3", +%% -*-: erlang -*- + +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.1", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.0", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - } + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.1", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.0", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - } + {<<".*">>, []} ] -}. \ No newline at end of file +}. diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl index 6066a306a..83112976d 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl @@ -35,7 +35,7 @@ pool_spec(Server) -> Options = application:get_env(?APP, options, []), case proplists:get_value(type, Server) of cluster -> - eredis_cluster:start_pool(?APP, Server ++ Options), + {ok, _} = eredis_cluster:start_pool(?APP, Server ++ Options), []; _ -> [ecpool:pool_spec(?APP, ?APP, emqx_auth_redis_cli, Server ++ Options)] diff --git a/apps/emqx_bridge_mqtt/rebar.config b/apps/emqx_bridge_mqtt/rebar.config index 1c9f5cbdd..8440b2e33 100644 --- a/apps/emqx_bridge_mqtt/rebar.config +++ b/apps/emqx_bridge_mqtt/rebar.config @@ -1,7 +1,7 @@ {deps, [{replayq, {git, "https://github.com/emqx/replayq", {tag, "v0.2.0"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}. {edoc_opts, [{preprocess, true}]}. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src index 8264abc25..f6d128b08 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src @@ -1,32 +1,10 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} + {<<"*.">>, []} ] }. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl index c189a2ea2..19c0c5711 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl @@ -31,6 +31,12 @@ , ensure_unsubscribed/2 ]). +%% callbacks for emqtt +-export([ handle_puback/2 + , handle_publish/2 + , handle_disconnected/2 + ]). + -include_lib("emqx/include/logger.hrl"). -include_lib("emqx/include/emqx_mqtt.hrl"). @@ -134,23 +140,23 @@ send(#{client_pid := ClientPid} = Conn, [Msg | Rest], _PktId) -> end. -handle_puback(Parent, #{packet_id := PktId, reason_code := RC}) +handle_puback(#{packet_id := PktId, reason_code := RC}, Parent) when RC =:= ?RC_SUCCESS; RC =:= ?RC_NO_MATCHING_SUBSCRIBERS -> Parent ! {batch_ack, PktId}, ok; -handle_puback(_Parent, #{packet_id := PktId, reason_code := RC}) -> +handle_puback(#{packet_id := PktId, reason_code := RC}, _Parent) -> ?LOG(warning, "Publish ~p to remote node falied, reason_code: ~p", [PktId, RC]). handle_publish(Msg, Mountpoint) -> emqx_broker:publish(emqx_bridge_msg:to_broker_msg(Msg, Mountpoint)). -handle_disconnected(Parent, Reason) -> +handle_disconnected(Reason, Parent) -> Parent ! {disconnected, self(), Reason}. make_hdlr(Parent, Mountpoint) -> - #{puback => fun(Ack) -> handle_puback(Parent, Ack) end, - publish => fun(Msg) -> handle_publish(Msg, Mountpoint) end, - disconnected => fun(Reason) -> handle_disconnected(Parent, Reason) end + #{puback => {fun ?MODULE:handle_puback/2, [Parent]}, + publish => {fun ?MODULE:handle_publish/2, [Mountpoint]}, + disconnected => {fun ?MODULE:handle_disconnected/2, [Parent]} }. subscribe_remote_topics(ClientPid, Subscriptions) -> diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl index e46c6d05b..7708521e8 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl @@ -20,6 +20,7 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_rule_engine/include/rule_actions.hrl"). -import(emqx_rule_utils, [str/1]). @@ -33,7 +34,9 @@ -export([subscriptions/1]). --export([on_action_create_data_to_mqtt_broker/2]). +-export([ on_action_create_data_to_mqtt_broker/2 + , on_action_data_to_mqtt_broker/2 + ]). -define(RESOURCE_TYPE_MQTT, 'bridge_mqtt'). -define(RESOURCE_TYPE_MQTT_SUB, 'bridge_mqtt_sub'). @@ -625,32 +628,42 @@ on_resource_destroy(ResId, #{<<"pool">> := PoolName}) -> error({{?RESOURCE_TYPE_MQTT, ResId}, destroy_failed}) end. -on_action_create_data_to_mqtt_broker(_Id, #{<<"pool">> := PoolName, - <<"forward_topic">> := ForwardTopic, - <<"payload_tmpl">> := PayloadTmpl}) -> +on_action_create_data_to_mqtt_broker(ActId, Opts = #{<<"pool">> := PoolName, + <<"forward_topic">> := ForwardTopic, + <<"payload_tmpl">> := PayloadTmpl}) -> ?LOG(info, "Initiating Action ~p.", [?FUNCTION_NAME]), PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), TopicTks = case ForwardTopic == <<"">> of true -> undefined; false -> emqx_rule_utils:preproc_tmpl(ForwardTopic) end, - fun(Msg, _Env = #{id := Id, clientid := From, flags := Flags, - topic := Topic, timestamp := TimeStamp, qos := QoS}) -> - Topic1 = case TopicTks =:= undefined of - true -> Topic; - false -> emqx_rule_utils:proc_tmpl(TopicTks, Msg) - end, - BrokerMsg = #message{id = Id, - qos = QoS, - from = From, - flags = Flags, - topic = Topic1, - payload = format_data(PayloadTks, Msg), - timestamp = TimeStamp}, - ecpool:with_client(PoolName, fun(BridgePid) -> - BridgePid ! {deliver, rule_engine, BrokerMsg} - end) - end. + Opts. + +on_action_data_to_mqtt_broker(Msg, _Env = + #{id := Id, clientid := From, flags := Flags, + topic := Topic, timestamp := TimeStamp, qos := QoS, + ?BINDING_KEYS := #{ + 'ActId' := ActId, + 'PoolName' := PoolName, + 'TopicTks' := TopicTks, + 'PayloadTks' := PayloadTks + }}) -> + Topic1 = case TopicTks =:= undefined of + true -> Topic; + false -> emqx_rule_utils:proc_tmpl(TopicTks, Msg) + end, + BrokerMsg = #message{id = Id, + qos = QoS, + from = From, + flags = Flags, + topic = Topic1, + payload = format_data(PayloadTks, Msg), + timestamp = TimeStamp}, + ecpool:with_client(PoolName, + fun(BridgePid) -> + BridgePid ! {deliver, rule_engine, BrokerMsg} + end), + emqx_rule_metrics:inc_actions_success(ActId). format_data([], Msg) -> emqx_json:encode(Msg); diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl index 2078fe41a..768cd3258 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl @@ -16,7 +16,7 @@ %% @doc Bridge works in two layers (1) batching layer (2) transport layer %% The `bridge' batching layer collects local messages in batches and sends over -%% to remote MQTT node/cluster via `connetion' transport layer. +%% to remote MQTT node/cluster via `connection' transport layer. %% In case `REMOTE' is also an EMQX node, `connection' is recommended to be %% the `gen_rpc' based implementation `emqx_bridge_rpc'. Otherwise `connection' %% has to be `emqx_bridge_mqtt'. @@ -98,6 +98,9 @@ , ensure_subscription_absent/2 ]). +%% Internal +-export([msg_marshaller/1]). + -export_type([ config/0 , batch/0 , ack_ref/0 @@ -232,13 +235,10 @@ init(Config) -> State = init_opts(Config), Topics = [iolist_to_binary(T) || T <- Forwards], Subs = check_subscriptions(Subscriptions), - ConnectConfig = get_conn_cfg(Config), - ConnectFun = fun(SubsX) -> - emqx_bridge_connect:start(ConnectModule, ConnectConfig#{subscriptions => SubsX}) - end, + ConnectCfg = get_conn_cfg(Config), self() ! idle, {ok, idle, State#{connect_module => ConnectModule, - connect_fun => ConnectFun, + connect_cfg => ConnectCfg, forwards => Topics, subscriptions => Subs, replayq => Queue @@ -276,7 +276,7 @@ open_replayq(Config) -> false -> #{dir => Dir, seg_bytes => SegBytes, max_total_size => MaxTotalSize} end, replayq:open(QueueConfig#{sizer => fun emqx_bridge_msg:estimate_size/1, - marshaller => fun msg_marshaller/1}). + marshaller => fun ?MODULE:msg_marshaller/1}). check_subscriptions(Subscriptions) -> lists:map(fun({Topic, QoS}) -> @@ -433,10 +433,11 @@ is_topic_present(Topic, Topics) -> do_connect(#{forwards := Forwards, subscriptions := Subs, - connect_fun := ConnectFun, + connect_module := ConnectModule, + connect_cfg := ConnectCfg, name := Name} = State) -> ok = subscribe_local_topics(Forwards, Name), - case ConnectFun(Subs) of + case emqx_bridge_connect:start(ConnectModule, ConnectCfg#{subscriptions => Subs}) of {ok, Conn} -> ?LOG(info, "Bridge ~p is connecting......", [Name]), {ok, eval_bridge_handler(State#{connection => Conn}, connected)}; diff --git a/apps/emqx_coap/src/emqx_coap_server.erl b/apps/emqx_coap/src/emqx_coap_server.erl index d3ed1a96e..0d571fac3 100644 --- a/apps/emqx_coap/src/emqx_coap_server.erl +++ b/apps/emqx_coap/src/emqx_coap_server.erl @@ -22,6 +22,12 @@ , stop/1 ]). +-export([ start_listener/1 + , start_listener/3 + , stop_listener/1 + , stop_listener/2 + ]). + %%-------------------------------------------------------------------- %% APIs %%-------------------------------------------------------------------- diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl index 292f2c453..cbf0d81d5 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard.erl @@ -25,6 +25,11 @@ , stop_listeners/0 ]). +%% for minirest +-export([ filter/1 + , is_authorized/1 + ]). + -define(APP, ?MODULE). %%-------------------------------------------------------------------- @@ -81,7 +86,9 @@ listener_name(Proto) -> http_handlers() -> Plugins = lists:map(fun(Plugin) -> Plugin#plugin.name end, emqx_plugins:list()), - [{"/api/v4/", minirest:handler(#{apps => Plugins, filter => fun filter/1}),[{authorization, fun is_authorized/1}]}]. + [{"/api/v4/", + minirest:handler(#{apps => Plugins, filter => fun ?MODULE:filter/1}), + [{authorization, fun ?MODULE:is_authorized/1}]}]. %%-------------------------------------------------------------------- %% Basic Authorization diff --git a/apps/emqx_exhook/.github/workflows/run_test_cases.yaml b/apps/emqx_exhook/.github/workflows/run_test_cases.yaml index fed706984..9970ed321 100644 --- a/apps/emqx_exhook/.github/workflows/run_test_cases.yaml +++ b/apps/emqx_exhook/.github/workflows/run_test_cases.yaml @@ -2,7 +2,7 @@ name: Run test cases on: [push, pull_request] -jobs: +jobs: run_test_cases: runs-on: ubuntu-latest @@ -11,21 +11,18 @@ jobs: steps: - uses: actions/checkout@v1 - - uses: actions/setup-java@v1 - with: - java-version: '8.0.x' - java-package: jdk - - name: run test cases + - name: Code dialyzer run: | - make eunit + make xref + make dialyzer + - name: Run test cases + run: | + make eunit + make proper make ct make cover - uses: actions/upload-artifact@v1 - if: always() + if: failure() with: name: logs path: _build/test/logs - - uses: actions/upload-artifact@v1 - with: - name: cover - path: _build/test/cover diff --git a/apps/emqx_exhook/.gitignore b/apps/emqx_exhook/.gitignore index 9ecba8017..da1f0db23 100644 --- a/apps/emqx_exhook/.gitignore +++ b/apps/emqx_exhook/.gitignore @@ -23,3 +23,7 @@ data/ *.pyc .DS_Store *.class +Mnesia.nonode@nohost/ +src/emqx_exhook_pb.erl +src/emqx_exhook_v_1_hook_provider_client.erl +src/emqx_exhook_v_1_hook_provider_bhvr.erl diff --git a/apps/emqx_exhook/README.md b/apps/emqx_exhook/README.md index 9d4ccd81f..216c39275 100644 --- a/apps/emqx_exhook/README.md +++ b/apps/emqx_exhook/README.md @@ -1,75 +1,39 @@ -# emqx_extension_hook +# emqx_exhook -The `emqx_extension_hook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. +The `emqx_exhook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. ## Feature -- [x] Support `python` and `java`. -- [x] Support all hooks of emqx. +- [x] Based on gRPC, it brings a very wide range of applicability - [x] Allows you to use the return value to extend emqx behavior. -We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. - ## Architecture ``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ +EMQ X Third-party Runtime ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gPRC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ ``` -## Drivers +## Usage -### Python +### gRPC service -***Requirements:*** +See: `priv/protos/exhook.proto` -- It requires the emqx hosted machine has Python3 Runtimes (not support python2) -- The `python3` executable commands in your shell +### CLI -***Examples:*** +## Example -See `test/scripts/main.py` +## Recommended gRPC Framework -### Java +See: https://github.com/grpc-ecosystem/awesome-grpc -***Requirements:*** +## Thanks -- It requires the emqx hosted machine has Java 8+ Runtimes -- An executable commands in your shell, i,g: `java` - -***Examples:*** - -See `test/scripts/Main.java` - -## Configurations - -| Name | Data Type | Options | Default | Description | -| ------------------- | --------- | ------------------------------------- | ---------------- | -------------------------------- | -| drivers | Enum | `python3`
`java` | `python3` | Drivers type | -| .path | String | - | `data/extension` | The codes/library search path | -| .call_timeout | Duration | - | `5s` | Function call timeout | -| .pool_size | Integer | - | `8` | The pool size for the driver | -| .init_module | String | - | main | The module name for initial call | - -## SDK - -See `sdk/README.md` - -## Known Issues or TODOs - -**Configurable Log System** - -- use stderr to print logs to the emqx console instead of stdout. An alternative is to print the logs to a file. -- The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform. - -## Reference - -- [erlport](https://github.com/hdima/erlport) -- [Eexternal Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) -- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) +- [grpcbox](https://github.com/tsloughter/grpcbox) diff --git a/apps/emqx_exhook/docs/design.md b/apps/emqx_exhook/docs/design.md index 1bf74723c..671e240cc 100644 --- a/apps/emqx_exhook/docs/design.md +++ b/apps/emqx_exhook/docs/design.md @@ -2,254 +2,115 @@ ## 动机 -增强系统的扩展性。包含的目的有: +在 EMQ X Broker v4.1-v4.2 中,我们发布了 2 个插件来扩展 emqx 的编程能力: -- 完全支持各种钩子,能够根据其返回值修改 EMQ X 或者 Client 的行为。 - - 例如 `auth/acl`:可以查询数据库或者执行某种算法校验操作权限。然后返回 `false` 表示 `认证/ACL` 失败。 - - 例如 `message.publish`:可以解析 `消息/主题` 并将其存储至数据库中。 +1. `emqx-extension-hook` 提供了使用 Java, Python 向 Broker 挂载钩子的功能 +2. `emqx-exproto` 提供了使用 Java,Python 编写用户自定义协议接入插件的功能 -- 支持多种语言的扩展;并包含该语言的示例程序。 - - python - - webhook - - Java - - Lua - - c,go,..... -- 热操作 - - 允许在插件运行过程中,添加和移除 `Driver`。 +但在后续的支持中发现许多难以处理的问题: -- 需要 CLI ,甚至 API 来管理 `Driver` +1. 有大量的编程语言需要支持,需要编写和维护如 Go, JavaScript, Lua.. 等语言的驱动。 +2. `erlport` 使用的操作系统的管道进行通信,这让用户代码只能部署在和 emqx 同一个操作系统上。部署方式受到了极大的限制。 +3. 用户程序的启动参数直接打包到 Broker 中,导致用户开发无法实时的进行调试,单步跟踪等。 +4. `erlport` 会占用 `stdin` `stdout`。 -注:`message` 类钩子仅包括在企业版中。 +因此,我们计划重构这部分的实现,其中主要的内容是: +1. 使用 `gRPC` 替换 `erlport`。 +2. 将 `emqx-extension-hook` 重命名为 `emqx-exhook` + + +旧版本的设计参考:[emqx-extension-hook design in v4.2.0](https://github.com/emqx/emqx-exhook/blob/v4.2.0/docs/design.md) ## 设计 架构如下: ``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ + EMQ X ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gRPC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ +``` + +`emqx-exhook` 通过 gRPC 的方式向用户部署的 gRPC 服务发送钩子的请求,并处理其返回的值。 + + +和 emqx 原生的钩子一致,emqx-exhook 也支持链式的方式计算和返回: + + + +### gRPC 服务示例 + +用户需要实现的方法,和数据类型的定义在 `priv/protos/exhook.proto` 文件中。例如,其支持的接口有: + +```protobuff +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} ``` ### 配置文件示例 -#### 驱动 配置 - -```properties -## Driver type +``` +## 配置 gRPC 服务地址 (HTTP) ## -## Exmaples: -## - python3 --- 仅配置 python3 -## - python3, java, webhook --- 配置多个 Driver -exhook.dirvers = python3, java, webhook +## s1 为服务器的名称 +exhook.server.s1.url = http://127.0.0.1:9001 -## --- 具体 driver 的配置详情 - -## Python -exhook.dirvers.python3.path = data/extension/python -exhook.dirvers.python3.call_timeout = 5s -exhook.dirvers.python3.pool_size = 8 - -## java -exhook.drivers.java.path = data/extension/java -... -``` - -#### 钩子配置 - -钩子支持配置在配置文件中,例如: - -```properties -exhook.rule.python3.client.connected = {"module": "client", "callback": "on_client_connected"} -exhook.rule.python3.message.publish = {"module": "client", "callback": "on_client_connected", "topics": ["#", "t/#"]} -``` - -***已废弃!!(冗余)*** - - -### 驱动抽象 - -#### APIs - -| 方法名 | 说明 | 入参 | 返回 | -| ------------------------ | -------- | ------ | ------ | -| `init` | 初始化 | - | 见下表 | -| `deinit` | 销毁 | - | - | -| `xxx `*(由init函数定义)* | 钩子回调 | 见下表 | 见下表 | - - - -##### init 函数规格 - -```erlang -%% init 函数 -%% HookSpec : 为用户在脚本中的 初始化函数指定的;他会与配置文件中的内容作为默认值,进行合并 -%% 该参数的目的,用于 EMQ X 判断需要执行哪些 Hook 和 如何执行 Hook -%% State : 为用户自己管理的数据内容,EMQ X 不关心它,只来回透传 -init() -> {HookSpec, State}. - -%% 例如: -{[{client_connect, callback_m(), callback_f(),#{}, {}}]} - -%%-------------------------------------------------------------- -%% Type Defines - --tpye hook_spec() :: [{hookname(), callback_m(), callback_f(), hook_opts()}]. - --tpye state :: any(). - --type hookname() :: client_connect - | client_connack - | client_connected - | client_disconnected - | client_authenticate - | client_check_acl - | client_subscribe - | client_unsubscribe - | session_created - | session_subscribed - | session_unsubscribed - | session_resumed - | session_discarded %% TODO: Should squash to `terminated` ? - | session_takeovered %% TODO: Should squash to `terminated` ? - | session_terminated - | message_publish - | message_delivered - | message_acked - | message_dropped. - --type callback_m() :: atom(). -- 回调的模块名称;python 为脚本文件名称;java 为类名;webhook 为 URI 地址 - --type callback_f() :: atom(). -- 回调的方法名称;python,java 等为方法名;webhook 为资源地址 - --tpye hook_opts() :: [{hook_key(), any()}]. -- 配置项;配置该项钩子的行为 - --type hook_key() :: topics | ... -``` - - - -##### deinit 函数规格 - -``` erlang -%% deinit 函数;不关心返回的任何内容 -deinit() -> any(). -``` - - - -##### 回调函数规格 - -| 钩子 | 入参 | 返回 | -| -------------------- | ----------------------------------------------------- | --------- | -| client_connect | `connifno`
`props` | - | -| client_connack | `connifno`
`rc`
`props` | - | -| client_connected | `clientinfo`
| - | -| client_disconnected | `clientinfo`
`reason` | - | -| client_authenticate | `clientinfo`
`result` | `result` | -| client_check_acl | `clientinfo`
`pubsub`
`topic`
`result` | `result` | -| client_subscribe | `clientinfo`
`props`
`topicfilters` | - | -| client_unsubscribe | `clientinfo`
`props`
`topicfilters` | - | -| session_created | `clientinfo` | - | -| session_subscribed | `clientinfo`
`topic`
`subopts` | - | -| session_unsubscribed | `clientinfo`
`topic` | - | -| session_resumed | `clientinfo` | - | -| session_discared | `clientinfo` | - | -| session_takeovered | `clientinfo` | - | -| session_terminated | `clientinfo`
`reason` | - | -| message_publish | `messsage` | `message` | -| message_delivered | `clientinfo`
`message` | - | -| message_dropped | `message` | - | -| message_acked | `clientinfo`
`message` | - | - - - -上表中包含数据格式为: - -```erlang --type conninfo :: [ {node, atom()} - , {clientid, binary()} - , {username, binary()} - , {peerhost, binary()} - , {sockport, integer()} - , {proto_name, binary()} - , {proto_ver, integer()} - , {keepalive, integer()} - ]. - --type clientinfo :: [ {node, atom()} - , {clientid, binary()} - , {username, binary()} - , {password, binary()} - , {peerhost, binary()} - , {sockport, integer()} - , {protocol, binary()} - , {mountpoint, binary()} - , {is_superuser, boolean()} - , {anonymous, boolean()} - ]. - --type message :: [ {node, atom()} - , {id, binary()} - , {qos, integer()} - , {from, binary()} - , {topic, binary()} - , {payload, binary()} - , {timestamp, integer()} - ]. - --type rc :: binary(). --type props :: [{key(), value()}] - --type topics :: [topic()]. --type topic :: binary(). --type pubsub :: publish | subscribe. --type result :: true | false. -``` - - - -### 统计 - -在驱动运行过程中,应有对每种钩子调用计数,例如: - -``` -exhook.python3.check_acl 10 -``` - - - -### 管理 - -**CLI 示例:** - - - -**列出所有的驱动** - -``` -./bin/emqx_ctl exhook dirvers list -Drivers(xxx=yyy) -Drivers(aaa=bbb) -``` - - - -**开关驱动** - -``` -./bin/emqx_ctl exhook drivers enable python3 -ok - -./bin/emqx_ctl exhook drivers disable python3 -ok - -./bin/emqx_ctl exhook drivers stats -python3.client_connect 123 -webhook.check_acl 20 +## 配置 gRPC 服务地址 (HTTPS) +## +## s2 为服务器名称 +exhook.server.s2.url = https://127.0.0.1:9002 +exhook.server.s2.cacertfile = ca.pem +exhook.server.s2.certfile = cert.pem +exhook.server.s2.keyfile = key.pem ``` diff --git a/apps/emqx_exhook/docs/introduction.md b/apps/emqx_exhook/docs/introduction.md deleted file mode 100644 index f4cdbd877..000000000 --- a/apps/emqx_exhook/docs/introduction.md +++ /dev/null @@ -1,84 +0,0 @@ -## 简介 - -`emqx-extension-hook` 插件用于提供钩子(Hook)的多语言支持。它能够允许其他的语言,例如:Python,Java 等,能够直接表达如何挂载钩子,和处理相应的钩子事件。 - -该插件给 EMQ X 带来的扩展性十分的强大,甚至于所有基于钩子的插件都可以通过其他编程语言实现。唯一不同的是在性能上肯定有一定的降低。 - -目前,一些常见的场景有: - -- 通过 `client.authenticate` 钩子,使用其他编程语言查询数据库,判断该客户端是否具有接入的权限。 -- 通过 `client.check_acl` 钩子,使用其他编程语言查询数据库,实现发布/订阅的权限控制逻辑。 -- 通过 `message` 类的钩子,实现消息收发的控制和数据格式转换。 -- 获取客户端所有的事件,将其存储进三方的日志、或数据平台中。 - -**声明:当前仅实现了 Python、Java 的支持** - -**声明:message 类钩子功能仅包含在企业版当中** - -### 要求 - -EMQ X 发行包中不包含其他语言的运行环境。它要求: - -- 宿主机需包含其他编程语言对应的执行环境。 -- 必须将源码(或编译后的代码)、资源文件等,放到 `emqx-extension-hook` 指示的路径。 -- 代码的实现,若包含三方依赖、库等,它应该包含在 `emqx-extension-hook` 对其的搜索路径中。 - - -### 架构 - -`emqx-extension-hook` 是 EMQ X 的一个插件,它主要包括: - -1. 驱动的管理。例如:如何启动/停止某个驱动。 -2. 事件的分发。例如:根据各个驱动所注册的钩子列表的不同,向各个驱动分发事件,传递返回值等。 -3. 预置了驱动的实现。包括 Python 和 Java 驱动的实现,和方便用户集成开发的 SDK 代码包。 - -其架构图如下: - -``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ -``` - -图中表明,由 Client 产生的所有的事件,例如:连接、发布、订阅等,都会由 `emqx-extension-hook`插件分发给下面的各个 `驱动(Driver)`;而,驱动则负责如何与三方运行时的进行通信。 - -广义上的驱动(Driver)可以分为两类: - -1. 编程语言类。 -2. 服务类。例如:HTTP 就属于此类。 - -`emqx-extension-hook` 并不关心驱动实际的类型和实现,只要其实现了对应的接口即可。 - - -#### 驱动 - -本文中,只有未经限定说明的驱动,都是指编程语言类的驱动。 - -编程语言类驱动是基于 [Erlang - Port](http://erlang.org/doc/tutorial/c_port.html) 进行实现。它本质上是由 `emqx-extension-hook` 是启动一个其他语言的程序,并使用管道(Pipe)实现两个进程间的通信。 - - -此类驱动的实现包括两部分的内容: - -1. Erlang 侧的实现,它包含如何启动其他语言的运行时系统、和分发请求、处理结果等。 -2. 其他语言侧的实现。它包含如何和 Erlang 虚拟机通信,如何执行函数调用等。 - -如: - -``` - Erlang VM Third Runtimes (e.g: Java VM) - +===========+=========+ +=========+================+ - | Extension | Driver | <=====> | Driver | User's Codes | - +===========+=========+ +=========+================+ -``` - -而,对于基于服务的驱动,原理就很简单了。以 HTTP 为例,它的实现仅需要一个 HTTP 客户端、和指定服务端返回的数据格式即可。 - -### 集成与调试 - -参见 SDK 规范、和对应语言的开发手册 - diff --git a/apps/emqx_exhook/docs/sdk-specification.md b/apps/emqx_exhook/docs/sdk-specification.md deleted file mode 100644 index 7593bc9b6..000000000 --- a/apps/emqx_exhook/docs/sdk-specification.md +++ /dev/null @@ -1,79 +0,0 @@ -## SDK 规范 - -### 动机 - -SDK 的目的在于方便用户使用 IDE 集成开发、和模拟调试。 - -### 位置 - -``` - +------------------+ - | User's Codes | - +------------------+ - | SDK | <==== The SDK Located - +------------------+ - | Raw APIs | - +------------------+ - | Driver | - +==================+ - || - +==================+ - | EMQ X Plugin | - +------------------+ -``` - -因此,SDK 的作用在于封装底层的比较晦涩的数据格式和方法,屏蔽底层细节。直接提供优化 API 供用户使用。 - - -### 实现要求 - -**声明:** stdin, stdout 已用于和 EMQ X 通信,请不要使用。stderr 用于日志输出。 - -#### 基础项 - -1. 必须将原始的 `init` `deinit`函数进行封装,方便用户: - - 配置需要挂载的钩子列表 - - 定义用户自己的初始化和销毁的内容 -2. 必须将回调函数的各个松散的数据类型,封装成类或某种结构化类型。 -3. 必须要有对应的开发、部署文档说明 - -#### 高级项 - -1. 应能方便用户能在 IDE 中进行,集成和开发 -2. 应提供集成测试用的模拟代码。 - - 例如,生成模拟的数据,发送至用户的程序,方便直接断点调试 - - -### 部署结构 - -#### 代码依赖结构 - -从部署的角度看,代码的依赖关系为: - -1. 用户代码: - * 一定会依赖 SDK - * 可能会依赖 某个位置的三方/系统库 -2. SDK 代码: - * 只能依赖 erlport -3. 基础通信库 - * 无依赖 - -#### 部署 - -从文件存放的位置来看,一个标准的部署结构为: - -``` -emqx -| -|--- data -|------- extension -|---------- -|--------------- -|---------- -| -|---------- -|--------------- -|---------- -``` - -它表达了:在 `data/extension` 目录下安装了两个 SDK,并且用户都基于 SDK 编写了其回调的代码模块。 diff --git a/apps/emqx_exhook/etc/emqx_exhook.conf b/apps/emqx_exhook/etc/emqx_exhook.conf new file mode 100644 index 000000000..f6f5213f7 --- /dev/null +++ b/apps/emqx_exhook/etc/emqx_exhook.conf @@ -0,0 +1,15 @@ +##==================================================================== +## EMQ X Hooks +##==================================================================== + +##-------------------------------------------------------------------- +## Server Address + +## The gRPC server url +## +## exhook.server.$name.url = url() +exhook.server.default.url = http://127.0.0.1:9000 + +#exhook.server.default.ssl.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem +#exhook.server.default.ssl.certfile = {{ platform_etc_dir }}/certs/cert.pem +#exhook.server.default.ssl.keyfile = {{ platform_etc_dir }}/certs/key.pem diff --git a/apps/emqx_exhook/etc/emqx_extension_hook.conf b/apps/emqx_exhook/etc/emqx_extension_hook.conf deleted file mode 100644 index a4f531108..000000000 --- a/apps/emqx_exhook/etc/emqx_extension_hook.conf +++ /dev/null @@ -1,24 +0,0 @@ -##==================================================================== -## EMQ X Hooks -##==================================================================== - -##-------------------------------------------------------------------- -## Driver confs - -## Setup the supported drivers -## -## Value: python3 | java -exhook.drivers = python3 - -## Search path for scripts/library -## -exhook.drivers.python3.path = {{ platform_data_dir }}/extension/ - -## Call timeout -## -## Value: Duration -##exhook.drivers.python3.call_timeout = 5s - -## Initial module name -## -##exhook.drivers.python3.init_module = main diff --git a/apps/emqx_exhook/include/emqx_extension_hook.hrl b/apps/emqx_exhook/include/emqx_exhook.hrl similarity index 87% rename from apps/emqx_exhook/include/emqx_extension_hook.hrl rename to apps/emqx_exhook/include/emqx_exhook.hrl index 18260754e..8a404ca39 100644 --- a/apps/emqx_exhook/include/emqx_extension_hook.hrl +++ b/apps/emqx_exhook/include/emqx_exhook.hrl @@ -14,9 +14,9 @@ %% limitations under the License. %%-------------------------------------------------------------------- --ifndef(EMQX_EXTENSION_HOOK_HRL). --define(EMQX_EXTENSION_HOOK_HRL, true). +-ifndef(EMQX_EXHOOK_HRL). +-define(EMQX_EXHOOK_HRL, true). --define(APP, emqx_extension_hook). +-define(APP, emqx_exhook). -endif. diff --git a/apps/emqx_exhook/priv/emqx_exhook.schema b/apps/emqx_exhook/priv/emqx_exhook.schema new file mode 100644 index 000000000..2a926b968 --- /dev/null +++ b/apps/emqx_exhook/priv/emqx_exhook.schema @@ -0,0 +1,38 @@ +%%-*- mode: erlang -*- + +{mapping, "exhook.server.$name.url", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.cacertfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.certfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.keyfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{translation, "emqx_exhook.servers", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + ServerOptions = fun(Prefix) -> + case http_uri:parse(cuttlefish:conf_get(Prefix ++ ".url", Conf)) of + {ok, {http, _, Host, Port, _, _}} -> + [{scheme, http}, {host, Host}, {port, Port}]; + {ok, {https, _, Host, Port, _, _}} -> + [{scheme, https}, {host, Host}, {port, Port}, + {ssl_options, + Filter([{ssl, true}, + {certfile, cuttlefish:conf_get(Prefix ++ ".ssl.certfile", Conf)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".ssl.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".ssl.cacertfile", Conf)} + ])}]; + _ -> error(invalid_server_options) + end + end, + [{list_to_atom(Name), ServerOptions("exhook.server." ++ Name)} + || {["exhook", "server", Name, "url"], _} <- cuttlefish_variable:filter_by_prefix("exhook.server", Conf)] +end}. diff --git a/apps/emqx_exhook/priv/emqx_extension_hook.schema b/apps/emqx_exhook/priv/emqx_extension_hook.schema deleted file mode 100644 index 677b57726..000000000 --- a/apps/emqx_exhook/priv/emqx_extension_hook.schema +++ /dev/null @@ -1,43 +0,0 @@ -%%-*- mode: erlang -*- - -{mapping, "exhook.drivers", "emqx_extension_hook.drivers", [ - {datatype, string} -]}. - -{mapping, "exhook.drivers.$name.$key", "emqx_extension_hook.drivers", [ - {datatype, string} -]}. - -{translation, "emqx_extension_hook.drivers", fun(Conf) -> - - Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, - - Duration = fun(S) -> - case cuttlefish_duration:parse(S, ms) of - Ms when is_integer(Ms) -> Ms; - {error, R} -> error(R) - end - end, - Integer = fun(S) -> list_to_integer(S) end, - - Atom = fun(S) -> list_to_atom(S) end, - - Python = fun(Prefix) -> - [{init_module, Atom(cuttlefish:conf_get(Prefix ++ ".init_module", Conf, "main"))}, %% dirver - {python_path, cuttlefish:conf_get(Prefix ++ ".path", Conf, undefined)}, - {call_timeout, Duration(cuttlefish:conf_get(Prefix ++ ".call_timeout", Conf, "5s"))}] - end, - - Java = fun(Prefix) -> - [{init_module, Atom(cuttlefish:conf_get(Prefix ++ ".init_module", Conf, "Main"))}, %% dirver - {java_path, cuttlefish:conf_get(Prefix ++ ".path", Conf, undefined)}, - {call_timeout, Duration(cuttlefish:conf_get(Prefix ++ ".call_timeout", Conf, "5s"))}] - end, - - Options = fun(python) -> Filter(Python("exhook.drivers.python")); - (python3) -> Filter(Python("exhook.drivers.python3")); - (java) -> Filter(Java("exhook.drivers.java")); - (_) -> error(not_supported_drivers_type) - end, - [{Atom(Name), Options(Atom(Name))} || Name <- string:tokens(cuttlefish:conf_get("exhook.drivers", Conf), ",")] -end}. diff --git a/apps/emqx_exhook/priv/protos/exhook.proto b/apps/emqx_exhook/priv/protos/exhook.proto new file mode 100644 index 000000000..8dc9641b9 --- /dev/null +++ b/apps/emqx_exhook/priv/protos/exhook.proto @@ -0,0 +1,395 @@ +//------------------------------------------------------------------------------ +// 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. +//------------------------------------------------------------------------------ + +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} + +//------------------------------------------------------------------------------ +// Request & Response +//------------------------------------------------------------------------------ + +message ProviderLoadedRequest { + + BrokerInfo broker = 1; +} + +message LoadedResponse { + + repeated HookSpec hooks = 1; +} + +message ProviderUnloadedRequest { } + +message ClientConnectRequest { + + ConnInfo conninfo = 1; + + // MQTT CONNECT packet's properties (MQTT v5.0) + // + // It should be empty on MQTT v3.1.1/v3.1 or others protocol + repeated Property props = 2; +} + +message ClientConnackRequest { + + ConnInfo conninfo = 1; + + string result_code = 2; + + repeated Property props = 3; +} + +message ClientConnectedRequest { + + ClientInfo clientinfo = 1; +} + +message ClientDisconnectedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message ClientAuthenticateRequest { + + ClientInfo clientinfo = 1; + + bool result = 2; +} + +message ClientCheckAclRequest { + + ClientInfo clientinfo = 1; + + enum AclReqType { + + PUBLISH = 0; + + SUBSCRIBE = 1; + } + + AclReqType type = 2; + + string topic = 3; + + bool result = 4; +} + +message ClientSubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message ClientUnsubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message SessionCreatedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionSubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; + + SubOpts subopts = 3; +} + +message SessionUnsubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; +} + +message SessionResumedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionDiscardedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTakeoveredRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTerminatedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message MessagePublishRequest { + + Message message = 1; +} + +message MessageDeliveredRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +message MessageDroppedRequest { + + Message message = 1; + + string reason = 2; +} + +message MessageAckedRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +//------------------------------------------------------------------------------ +// Basic data types +//------------------------------------------------------------------------------ + +message EmptySuccess { } + +message ValuedResponse { + + // The responsed value type + // - ignore: Ignore the responsed value + // - contiune: Use the responsed value and execute the next hook + // - stop_and_return: Use the responsed value and stop the chain executing + enum ResponsedType { + + IGNORE = 0; + + CONTINUE = 1; + + STOP_AND_RETURN = 2; + } + + ResponsedType type = 1; + + oneof value { + + // Boolean result, used on the 'client.authenticate', 'client.check_acl' hooks + bool bool_result = 3; + + // Message result, used on the 'message.*' hooks + Message message = 4; + } +} + +message BrokerInfo { + + string version = 1; + + string sysdescr = 2; + + string uptime = 3; + + string datetime = 4; +} + +message HookSpec { + + // The registered hooks name + // + // Available value: + // "client.connect", "client.connack" + // "client.connected", "client.disconnected" + // "client.authenticate", "client.check_acl" + // "client.subscribe", "client.unsubscribe" + // + // "session.created", "session.subscribed" + // "session.unsubscribed", "session.resumed" + // "session.discarded", "session.takeovered" + // "session.terminated" + // + // "message.publish", "message.delivered" + // "message.acked", "message.dropped" + string name = 1; + + // The topic filters for message hooks + repeated string topics = 2; +} + +message ConnInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string peerhost = 4; + + uint32 sockport = 5; + + string proto_name = 6; + + string proto_ver = 7; + + uint32 keepalive = 8; +} + +message ClientInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string password = 4; + + string peerhost = 5; + + uint32 sockport = 6; + + string protocol = 7; + + string mountpoint = 8; + + bool is_superuser = 9; + + bool anonymous = 10; +} + +message Message { + + string node = 1; + + string id = 2; + + uint32 qos = 3; + + string from = 4; + + string topic = 5; + + bytes payload = 6; + + uint64 timestamp = 7; +} + +message Property { + + string name = 1; + + string value = 2; +} + +message TopicFilter { + + string name = 1; + + uint32 qos = 2; +} + +message SubOpts { + + // The QoS level + uint32 qos = 1; + + // The group name for shared subscription + string share = 2; + + // The Retain Handling option (MQTT v5.0) + // + // 0 = Send retained messages at the time of the subscribe + // 1 = Send retained messages at subscribe only if the subscription does + // not currently exist + // 2 = Do not send retained messages at the time of the subscribe + uint32 rh = 3; + + // The Retain as Published option (MQTT v5.0) + // + // If 1, Application Messages forwarded using this subscription keep the + // RETAIN flag they were published with. + // If 0, Application Messages forwarded using this subscription have the + // RETAIN flag set to 0. + // Retained messages sent when the subscription is established have the RETAIN flag set to 1. + uint32 rap = 4; + + // The No Local option (MQTT v5.0) + // + // If the value is 1, Application Messages MUST NOT be forwarded to a + // connection with a ClientID equal to the ClientID of the publishing + uint32 nl = 5; +} diff --git a/apps/emqx_exhook/rebar.config b/apps/emqx_exhook/rebar.config index 756c24c68..ebeaddeab 100644 --- a/apps/emqx_exhook/rebar.config +++ b/apps/emqx_exhook/rebar.config @@ -1,7 +1,21 @@ %%-*- mode: erlang -*- +{plugins, + [rebar3_proper, + {grpc_plugin, {git, "https://github.com/HJianBo/grpcbox_plugin", {tag, "v0.9.1"}}} +]}. -{deps, [{ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, - {erlport, {git, "https://github.com/emqx/erlport", {tag, "v1.2.2"}}}]}. +{deps, + [{grpc, {git, "https://github.com/emqx/grpc", {tag, "0.5.0"}}} +]}. + +{grpc, + [{protos, ["priv/protos"]}, + {gpb_opts, [{module_name_prefix, "emqx_"}, + {module_name_suffix, "_pb"}]} +]}. + +{provider_hooks, + [{pre, [{compile, {grpc, gen}}]}]}. {edoc_opts, [{preprocess, true}]}. @@ -15,14 +29,19 @@ {xref_checks, [undefined_function_calls, undefined_functions, locals_not_used, deprecated_function_calls, warnings_as_errors, deprecated_functions]}. +{xref_ignores, [emqx_exhook_pb]}. + {cover_enabled, true}. {cover_opts, [verbose]}. {cover_export_enabled, true}. +{cover_excl_mods, [emqx_exhook_pb, + emqx_exhook_v_1_hook_provider_bhvr, + emqx_exhook_v_1_hook_provider_client]}. {profiles, - [{test, [ - {deps, [ {emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.2.2"}}} - , {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} - ]} + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.1"}}} + ]} ]} ]}. diff --git a/apps/emqx_exhook/sdk/README.md b/apps/emqx_exhook/sdk/README.md deleted file mode 100644 index 435ca4d92..000000000 --- a/apps/emqx_exhook/sdk/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# SDKs - -A specific language SDK is a suite of codes for user-oriented friendly. - -Even it does not need it for you to develop the Multiple language support plugins, but it provides more friendly APIs and Abstract for you - - -Now, we provide the following SDKs: - -- Java: https://github.com/emqx/emqx-extension-java-sdk -- Python: https://github.com/emqx/emqx-extension-python-sdk - diff --git a/apps/emqx_exhook/src/emqx_exhook.app.src b/apps/emqx_exhook/src/emqx_exhook.app.src new file mode 100644 index 000000000..fd8bc98ae --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.app.src @@ -0,0 +1,12 @@ +{application, emqx_exhook, + [{description, "EMQ X Extension for Hook"}, + {vsn, "git"}, + {modules, []}, + {registered, []}, + {mod, {emqx_exhook_app, []}}, + {applications, [kernel,stdlib,grpc]}, + {env,[]}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}]} + ]}. diff --git a/apps/emqx_exhook/src/emqx_exhook.app.src.script b/apps/emqx_exhook/src/emqx_exhook.app.src.script new file mode 100644 index 000000000..b549f9e39 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.app.src.script @@ -0,0 +1,24 @@ +%%-*- mode: erlang -*- +%% .app.src.script + +RemoveLeadingV = + fun(Tag) -> + case re:run(Tag, "^[v]?[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]", "", [{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_exhook/src/emqx_exhook.appup.src b/apps/emqx_exhook/src/emqx_exhook.appup.src new file mode 100644 index 000000000..dcf0d8cdd --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.appup.src @@ -0,0 +1,9 @@ +%% -*-: erlang -*- +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_exhook/src/emqx_extension_hook.erl b/apps/emqx_exhook/src/emqx_exhook.erl similarity index 55% rename from apps/emqx_exhook/src/emqx_extension_hook.erl rename to apps/emqx_exhook/src/emqx_exhook.erl index 295393c3b..c464f31b5 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook.erl +++ b/apps/emqx_exhook/src/emqx_exhook.erl @@ -14,9 +14,9 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook). +-module(emqx_exhook). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). -include_lib("emqx/include/logger.hrl"). -logger_header("[ExHook]"). @@ -29,42 +29,42 @@ ]). -export([ cast/2 - , call_fold/4 + , call_fold/3 ]). %%-------------------------------------------------------------------- %% Mgmt APIs %%-------------------------------------------------------------------- --spec list() -> [emqx_extension_hook_driver:driver()]. +-spec list() -> [emqx_exhook_server:server()]. list() -> - [state(Name) || Name <- running()]. + [server(Name) || Name <- running()]. --spec enable(atom(), list()) -> ok | {error, term()}. +-spec enable(atom()|string(), list()) -> ok | {error, term()}. enable(Name, Opts) -> case lists:member(Name, running()) of true -> {error, already_started}; _ -> - case emqx_extension_hook_driver:load(Name, Opts) of - {ok, DriverState} -> - save(Name, DriverState); + case emqx_exhook_server:load(Name, Opts) of + {ok, ServiceState} -> + save(Name, ServiceState); {error, Reason} -> - ?LOG(error, "Load driver ~p failed: ~p", [Name, Reason]), + ?LOG(error, "Load server ~p failed: ~p", [Name, Reason]), {error, Reason} end end. --spec disable(atom()) -> ok | {error, term()}. +-spec disable(atom()|string()) -> ok | {error, term()}. disable(Name) -> - case state(Name) of + case server(Name) of undefined -> {error, not_running}; - Driver -> - ok = emqx_extension_hook_driver:unload(Driver), + Service -> + ok = emqx_exhook_server:unload(Service), unsave(Name) end. --spec disable_all() -> [atom()]. +-spec disable_all() -> [term()]. disable_all() -> [begin disable(Name), Name end || Name <- running()]. @@ -72,46 +72,44 @@ disable_all() -> %% Dispatch APIs %%---------------------------------------------------------- --spec cast(atom(), list()) -> ok. -cast(Name, Args) -> - cast(Name, Args, running()). +-spec cast(atom(), map()) -> ok. +cast(Hookpoint, Req) -> + cast(Hookpoint, Req, running()). cast(_, _, []) -> ok; -cast(Name, Args, [DriverName|More]) -> - emqx_extension_hook_driver:run_hook(Name, Args, state(DriverName)), - cast(Name, Args, More). +cast(Hookpoint, Req, [ServiceName|More]) -> + %% XXX: Need a real asynchronous running + _ = emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)), + cast(Hookpoint, Req, More). --spec call_fold(atom(), list(), term(), function()) -> ok | {stop, term()}. -call_fold(Name, InfoArgs, AccArg, Validator) -> - call_fold(Name, InfoArgs, AccArg, Validator, running()). +-spec call_fold(atom(), term(), function()) + -> {ok, term()} + | {stop, term()}. +call_fold(Hookpoint, Req, AccFun) -> + call_fold(Hookpoint, Req, AccFun, running()). -call_fold(_, _, _, _, []) -> - ok; -call_fold(Name, InfoArgs, AccArg, Validator, [NameDriver|More]) -> - Driver = state(NameDriver), - case emqx_extension_hook_driver:run_hook_fold(Name, InfoArgs, AccArg, Driver) of - ok -> call_fold(Name, InfoArgs, AccArg, Validator, More); - {error, _} -> call_fold(Name, InfoArgs, AccArg, Validator, More); - {ok, NAcc} -> - case Validator(NAcc) of - true -> - {stop, NAcc}; - _ -> - ?LOG(error, "Got invalid return type for calling ~p on ~p", - [Name, emqx_extension_hook_driver:name(Driver)]), - call_fold(Name, InfoArgs, AccArg, Validator, More) - end +call_fold(_, Req, _, []) -> + {ok, Req}; +call_fold(Hookpoint, Req, AccFun, [ServiceName|More]) -> + case emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)) of + {ok, Resp} -> + case AccFun(Req, Resp) of + {stop, NReq} -> {stop, NReq}; + {ok, NReq} -> call_fold(Hookpoint, NReq, AccFun, More) + end; + _ -> + call_fold(Hookpoint, Req, AccFun, More) end. %%---------------------------------------------------------- %% Storage -compile({inline, [save/2]}). -save(Name, DriverState) -> +save(Name, ServiceState) -> Saved = persistent_term:get(?APP, []), persistent_term:put(?APP, lists:reverse([Name | Saved])), - persistent_term:put({?APP, Name}, DriverState). + persistent_term:put({?APP, Name}, ServiceState). -compile({inline, [unsave/1]}). unsave(Name) -> @@ -128,9 +126,9 @@ unsave(Name) -> running() -> persistent_term:get(?APP, []). --compile({inline, [state/1]}). -state(Name) -> +-compile({inline, [server/1]}). +server(Name) -> case catch persistent_term:get({?APP, Name}) of {'EXIT', {badarg,_}} -> undefined; - State -> State + Service -> Service end. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_app.erl b/apps/emqx_exhook/src/emqx_exhook_app.erl similarity index 80% rename from apps/emqx_exhook/src/emqx_extension_hook_app.erl rename to apps/emqx_exhook/src/emqx_exhook_app.erl index 990907913..1411b618d 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_app.erl +++ b/apps/emqx_exhook/src/emqx_exhook_app.erl @@ -14,40 +14,47 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_app). +-module(emqx_exhook_app). -behaviour(application). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). --emqx_plugin(?MODULE). +-emqx_plugin(extension). -export([ start/2 , stop/1 , prep_stop/1 ]). +%% Internal export +-export([ load_server/2 + , unload_server/1 + , load_exhooks/0 + , unload_exhooks/0 + ]). + %%-------------------------------------------------------------------- %% Application callbacks %%-------------------------------------------------------------------- start(_StartType, _StartArgs) -> - {ok, Sup} = emqx_extension_hook_sup:start_link(), + {ok, Sup} = emqx_exhook_sup:start_link(), %% Load all dirvers - load_all_drivers(), + load_all_servers(), %% Register all hooks load_exhooks(), %% Register CLI - emqx_ctl:register_command(exhook, {emqx_extension_hook_cli, cli}, []), + emqx_ctl:register_command(exhook, {emqx_exhook_cli, cli}, []), {ok, Sup}. prep_stop(State) -> emqx_ctl:unregister_command(exhook), unload_exhooks(), - unload_all_drivers(), + unload_all_servers(), State. stop(_State) -> @@ -57,17 +64,19 @@ stop(_State) -> %% Internal funcs %%-------------------------------------------------------------------- -load_all_drivers() -> - load_all_drivers(application:get_env(?APP, drivers, [])). +load_all_servers() -> + lists:foreach(fun({Name, Options}) -> + load_server(Name, Options) + end, application:get_env(?APP, servers, [])). -load_all_drivers([]) -> - ok; -load_all_drivers([{Name, Opts}|Drivers]) -> - ok = emqx_extension_hook:enable(Name, Opts), - load_all_drivers(Drivers). +unload_all_servers() -> + emqx_exhook:disable_all(). -unload_all_drivers() -> - emqx_extension_hook:disable_all(). +load_server(Name, Options) -> + emqx_exhook:enable(Name, Options). + +unload_server(Name) -> + emqx_exhook:disable(Name). %%-------------------------------------------------------------------- %% Exhooks diff --git a/apps/emqx_exhook/src/emqx_extension_hook_cli.erl b/apps/emqx_exhook/src/emqx_exhook_cli.erl similarity index 64% rename from apps/emqx_exhook/src/emqx_extension_hook_cli.erl rename to apps/emqx_exhook/src/emqx_exhook_cli.erl index daca3412f..8bab9ced5 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_cli.erl +++ b/apps/emqx_exhook/src/emqx_exhook_cli.erl @@ -14,44 +14,44 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_cli). +-module(emqx_exhook_cli). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). -export([cli/1]). -cli(["drivers", "list"]) -> +cli(["server", "list"]) -> if_enabled(fun() -> - Drivers = emqx_extension_hook:list(), - [emqx_ctl:print("Driver(~s)~n", [emqx_extension_hook_driver:format(Driver)]) || Driver <- Drivers] + Services = emqx_exhook:list(), + [emqx_ctl:print("HookServer(~s)~n", [emqx_exhook_server:format(Service)]) || Service <- Services] end); -cli(["drivers", "enable", Name0]) -> +cli(["server", "enable", Name0]) -> if_enabled(fun() -> Name = list_to_atom(Name0), - case proplists:get_value(Name, application:get_env(?APP, drivers, [])) of + case proplists:get_value(Name, application:get_env(?APP, servers, [])) of undefined -> emqx_ctl:print("not_found~n"); Opts -> - print(emqx_extension_hook:enable(Name, Opts)) + print(emqx_exhook:enable(Name, Opts)) end end); -cli(["drivers", "disable", Name]) -> +cli(["server", "disable", Name]) -> if_enabled(fun() -> - print(emqx_extension_hook:disable(list_to_atom(Name))) + print(emqx_exhook:disable(list_to_atom(Name))) end); -cli(["drivers", "stats"]) -> +cli(["server", "stats"]) -> if_enabled(fun() -> [emqx_ctl:print("~-35s:~w~n", [Name, N]) || {Name, N} <- stats()] end); cli(_) -> - emqx_ctl:usage([{"exhook drivers list", "List all running drivers"}, - {"exhook drivers enable ", "Enable a driver with configurations"}, - {"exhook drivers disable ", "Disable a driver"}, - {"exhook drivers stats", "Print drivers statistic"}]). + emqx_ctl:usage([{"exhook server list", "List all running exhook server"}, + {"exhook server enable ", "Enable a exhook server in the configuration"}, + {"exhook server disable ", "Disable a exhook server"}, + {"exhook server stats", "Print exhook server statistic"}]). print(ok) -> emqx_ctl:print("ok~n"); @@ -69,12 +69,12 @@ if_enabled(Fun) -> end. hint() -> - emqx_ctl:print("Please './bin/emqx_ctl plugins load emqx_extension_hook' first.~n"). + emqx_ctl:print("Please './bin/emqx_ctl plugins load emqx_exhook' first.~n"). stats() -> - lists:foldr(fun({K, N}, Acc) -> + lists:usort(lists:foldr(fun({K, N}, Acc) -> case atom_to_list(K) of "exhook." ++ Key -> [{Key, N}|Acc]; _ -> Acc end - end, [], emqx_metrics:all()). + end, [], emqx_metrics:all())). diff --git a/apps/emqx_exhook/src/emqx_exhook_handler.erl b/apps/emqx_exhook/src/emqx_exhook_handler.erl new file mode 100644 index 000000000..3a35073ca --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_handler.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_exhook_handler). + +-include("emqx_exhook.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook]"). + +-export([ on_client_connect/2 + , on_client_connack/3 + , on_client_connected/2 + , on_client_disconnected/3 + , on_client_authenticate/2 + , on_client_check_acl/4 + , on_client_subscribe/3 + , on_client_unsubscribe/3 + ]). + +%% Session Lifecircle Hooks +-export([ on_session_created/2 + , on_session_subscribed/3 + , on_session_unsubscribed/3 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/3 + ]). + +%% Utils +-export([ message/1 + , stringfy/1 + , merge_responsed_bool/2 + , merge_responsed_message/2 + , assign_to_message/2 + , clientinfo/1 + ]). + +-import(emqx_exhook, + [ cast/2 + , call_fold/3 + ]). + +-exhooks([ {'client.connect', {?MODULE, on_client_connect, []}} + , {'client.connack', {?MODULE, on_client_connack, []}} + , {'client.connected', {?MODULE, on_client_connected, []}} + , {'client.disconnected', {?MODULE, on_client_disconnected, []}} + , {'client.authenticate', {?MODULE, on_client_authenticate, []}} + , {'client.check_acl', {?MODULE, on_client_check_acl, []}} + , {'client.subscribe', {?MODULE, on_client_subscribe, []}} + , {'client.unsubscribe', {?MODULE, on_client_unsubscribe, []}} + , {'session.created', {?MODULE, on_session_created, []}} + , {'session.subscribed', {?MODULE, on_session_subscribed, []}} + , {'session.unsubscribed',{?MODULE, on_session_unsubscribed, []}} + , {'session.resumed', {?MODULE, on_session_resumed, []}} + , {'session.discarded', {?MODULE, on_session_discarded, []}} + , {'session.takeovered', {?MODULE, on_session_takeovered, []}} + , {'session.terminated', {?MODULE, on_session_terminated, []}} + ]). + +%%-------------------------------------------------------------------- +%% Clients +%%-------------------------------------------------------------------- + +on_client_connect(ConnInfo, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + props => properties(Props) + }, + cast('client.connect', Req). + +on_client_connack(ConnInfo, Rc, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + result_code => stringfy(Rc), + props => properties(Props)}, + cast('client.connack', Req). + +on_client_connected(ClientInfo, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('client.connected', Req). + +on_client_disconnected(ClientInfo, Reason, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason) + }, + cast('client.disconnected', Req). + +on_client_authenticate(ClientInfo, AuthResult) -> + Bool = maps:get(auth_result, AuthResult, undefined) == success, + Req = #{clientinfo => clientinfo(ClientInfo), + result => Bool + }, + + case call_fold('client.authenticate', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + Result = case Bool of true -> success; _ -> not_authorized end, + {StopOrOk, AuthResult#{auth_result => Result, anonymous => false}}; + _ -> + {ok, AuthResult} + end. + +on_client_check_acl(ClientInfo, PubSub, Topic, Result) -> + Bool = Result == allow, + Type = case PubSub of + publish -> 'PUBLISH'; + subscribe -> 'SUBSCRIBE' + end, + Req = #{clientinfo => clientinfo(ClientInfo), + type => Type, + topic => Topic, + result => Bool + }, + case call_fold('client.check_acl', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + NResult = case Bool of true -> allow; _ -> deny end, + {StopOrOk, NResult}; + _ -> {ok, Result} + end. + +on_client_subscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.subscribe', Req). + +on_client_unsubscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.unsubscribe', Req). + +%%-------------------------------------------------------------------- +%% Session +%%-------------------------------------------------------------------- + +on_session_created(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.created', Req). + +on_session_subscribed(ClientInfo, Topic, SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic, + subopts => maps:with([qos, share, rh, rap, nl], SubOpts) + }, + cast('session.subscribed', Req). + +on_session_unsubscribed(ClientInfo, Topic, _SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic + }, + cast('session.unsubscribed', Req). + +on_session_resumed(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.resumed', Req). + +on_session_discarded(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.discarded', Req). + +on_session_takeovered(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.takeovered', Req). + +on_session_terminated(ClientInfo, Reason, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason)}, + cast('session.terminated', Req). + +%%-------------------------------------------------------------------- +%% Types + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +conninfo(_ConnInfo = + #{clientid := ClientId, username := Username, peername := {Peerhost, _}, + sockname := {_, SockPort}, proto_name := ProtoName, proto_ver := ProtoVer, + keepalive := Keepalive}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + peerhost => ntoa(Peerhost), + sockport => SockPort, + proto_name => ProtoName, + proto_ver => stringfy(ProtoVer), + keepalive => Keepalive}. + +clientinfo(ClientInfo = + #{clientid := ClientId, username := Username, peerhost := PeerHost, + sockport := SockPort, protocol := Protocol, mountpoint := Mountpoiont}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + password => maybe(maps:get(password, ClientInfo, undefined)), + peerhost => ntoa(PeerHost), + sockport => SockPort, + protocol => stringfy(Protocol), + mountpoint => maybe(Mountpoiont), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true)}. + +message(#message{id = Id, qos = Qos, from = From, topic = Topic, payload = Payload, timestamp = Ts}) -> + #{node => stringfy(node()), + id => hexstr(Id), + qos => Qos, + from => stringfy(From), + topic => Topic, + payload => Payload, + timestamp => Ts}. + +assign_to_message(#{qos := Qos, topic := Topic, payload := Payload}, Message) -> + Message#message{qos = Qos, topic = Topic, payload = Payload}. + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +hexstr(B) -> + iolist_to_binary([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(B)]). + +%%-------------------------------------------------------------------- +%% Acc funcs + +%% see exhook.proto +merge_responsed_bool(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_bool(Req, #{type := Type, value := {bool_result, NewBool}}) + when is_boolean(NewBool) -> + NReq = Req#{result => NewBool}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_bool(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. + +merge_responsed_message(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_message(Req, #{type := Type, value := {message, NMessage}}) -> + NReq = Req#{message => NMessage}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_message(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. diff --git a/apps/emqx_exhook/src/emqx_exhook_server.erl b/apps/emqx_exhook/src/emqx_exhook_server.erl new file mode 100644 index 000000000..451983437 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_server.erl @@ -0,0 +1,286 @@ +%%-------------------------------------------------------------------- +%% 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_exhook_server). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook Svr]"). + +-define(PB_CLIENT_MOD, emqx_exhook_v_1_hook_provider_client). + +%% Load/Unload +-export([ load/2 + , unload/1 + ]). + +%% APIs +-export([call/3]). + +%% Infos +-export([ name/1 + , format/1 + ]). + +-record(server, { + %% Server name (equal to grpc client channel name) + name :: server_name(), + %% The server started options + options :: list(), + %% gRPC channel pid + channel :: pid(), + %% Registered hook names and options + hookspec :: #{hookpoint() => map()}, + %% Metrcis name prefix + prefix :: list() + }). + +-type server_name() :: string(). +-type server() :: #server{}. + +-type hookpoint() :: 'client.connect' + | 'client.connack' + | 'client.connected' + | 'client.disconnected' + | 'client.authenticate' + | 'client.check_acl' + | 'client.subscribe' + | 'client.unsubscribe' + | 'session.created' + | 'session.subscribed' + | 'session.unsubscribed' + | 'session.resumed' + | 'session.discarded' + | 'session.takeovered' + | 'session.terminated' + | 'message.publish' + | 'message.delivered' + | 'message.acked' + | 'message.dropped'. + +-export_type([server/0]). + +-dialyzer({nowarn_function, [inc_metrics/2]}). + +%%-------------------------------------------------------------------- +%% Load/Unload APIs +%%-------------------------------------------------------------------- + +-spec load(atom(), list()) -> {ok, server()} | {error, term()} . +load(Name0, Opts0) -> + Name = prefix(Name0), + {SvrAddr, ClientOpts} = channel_opts(Opts0), + case emqx_exhook_sup:start_grpc_client_channel(Name, SvrAddr, ClientOpts) of + {ok, _ChannPoolPid} -> + case do_init(Name) of + {ok, HookSpecs} -> + %% Reigster metrics + Prefix = lists:flatten(io_lib:format("exhook.~s.", [Name])), + ensure_metrics(Prefix, HookSpecs), + {ok, #server{name = Name, + options = Opts0, + channel = _ChannPoolPid, + hookspec = HookSpecs, + prefix = Prefix }}; + {error, _} = E -> + emqx_exhook_sup:stop_grpc_client_channel(Name), E + end; + {error, _} = E -> E + end. + +%% @private +prefix(Name) when is_atom(Name) -> + "exhook:" ++ atom_to_list(Name); +prefix(Name) when is_binary(Name) -> + "exhook:" ++ binary_to_list(Name); +prefix(Name) when is_list(Name) -> + "exhook:" ++ Name. + +%% @private +channel_opts(Opts) -> + Scheme = proplists:get_value(scheme, Opts), + Host = proplists:get_value(host, Opts), + Port = proplists:get_value(port, Opts), + SvrAddr = lists:flatten(io_lib:format("~s://~s:~w", [Scheme, Host, Port])), + ClientOpts = case Scheme of + https -> + SslOpts = lists:keydelete(ssl, 1, proplists:get_value(ssl_options, Opts, [])), + #{gun_opts => + #{transport => ssl, + transport_opts => SslOpts}}; + _ -> #{} + end, + {SvrAddr, ClientOpts}. + +-spec unload(server()) -> ok. +unload(#server{name = Name}) -> + _ = do_deinit(Name), + _ = emqx_exhook_sup:stop_grpc_client_channel(Name), + ok. + +do_deinit(Name) -> + _ = do_call(Name, 'on_provider_unloaded', #{}), + ok. + +do_init(ChannName) -> + Req = #{broker => maps:from_list(emqx_sys:info())}, + case do_call(ChannName, 'on_provider_loaded', Req) of + {ok, InitialResp} -> + try + {ok, resovle_hookspec(maps:get(hooks, InitialResp, []))} + catch _:Reason:Stk -> + ?LOG(error, "try to init ~p failed, reason: ~p, stacktrace: ~0p", + [ChannName, Reason, Stk]), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +%% @private +resovle_hookspec(HookSpecs) when is_list(HookSpecs) -> + MessageHooks = message_hooks(), + AvailableHooks = available_hooks(), + lists:foldr(fun(HookSpec, Acc) -> + case maps:get(name, HookSpec, undefined) of + undefined -> Acc; + Name0 -> + Name = try binary_to_existing_atom(Name0, utf8) catch T:R:_ -> {T,R} end, + case lists:member(Name, AvailableHooks) of + true -> + case lists:member(Name, MessageHooks) of + true -> + Acc#{Name => #{topics => maps:get(topics, HookSpec, [])}}; + _ -> + Acc#{Name => #{}} + end; + _ -> error({unknown_hookpoint, Name}) + end + end + end, #{}, HookSpecs). + +ensure_metrics(Prefix, HookSpecs) -> + Keys = [list_to_atom(Prefix ++ atom_to_list(Hookpoint)) + || Hookpoint <- maps:keys(HookSpecs)], + lists:foreach(fun emqx_metrics:ensure/1, Keys). + +format(#server{name = Name, hookspec = Hooks}) -> + io_lib:format("name=~p, hooks=~0p", [Name, Hooks]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +name(#server{name = Name}) -> + Name. + +-spec call(hookpoint(), map(), server()) + -> ignore + | {ok, Resp :: term()} + | {error, term()}. +call(Hookpoint, Req, #server{name = ChannName, hookspec = Hooks, prefix = Prefix}) -> + GrpcFunc = hk2func(Hookpoint), + case maps:get(Hookpoint, Hooks, undefined) of + undefined -> ignore; + Opts -> + NeedCall = case lists:member(Hookpoint, message_hooks()) of + false -> true; + _ -> + #{message := #{topic := Topic}} = Req, + match_topic_filter(Topic, maps:get(topics, Opts, [])) + end, + case NeedCall of + false -> ignore; + _ -> + inc_metrics(Prefix, Hookpoint), + do_call(ChannName, GrpcFunc, Req) + end + end. + +%% @private +inc_metrics(IncFun, Name) when is_function(IncFun) -> + %% BACKW: e4.2.0-e4.2.2 + {env, [Prefix|_]} = erlang:fun_info(IncFun, env), + inc_metrics(Prefix, Name); +inc_metrics(Prefix, Name) when is_list(Prefix) -> + emqx_metrics:inc(list_to_atom(Prefix ++ atom_to_list(Name))). + +-compile({inline, [match_topic_filter/2]}). +match_topic_filter(_, []) -> + true; +match_topic_filter(TopicName, TopicFilter) -> + lists:any(fun(F) -> emqx_topic:match(TopicName, F) end, TopicFilter). + +-spec do_call(string(), atom(), map()) -> {ok, map()} | {error, term()}. +do_call(ChannName, Fun, Req) -> + Options = #{channel => ChannName}, + ?LOG(debug, "Call ~0p:~0p(~0p, ~0p)", [?PB_CLIENT_MOD, Fun, Req, Options]), + case catch apply(?PB_CLIENT_MOD, Fun, [Req, Options]) of + {ok, Resp, _Metadata} -> + ?LOG(debug, "Response {ok, ~0p, ~0p}", [Resp, _Metadata]), + {ok, Resp}; + {error, {Code, Msg}, _Metadata} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) response errcode: ~0p, errmsg: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Code, Msg]), + {error, {Code, Msg}}; + {error, Reason} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) error: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason]), + {error, Reason}; + {'EXIT', {Reason, Stk}} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) throw an exception: ~0p, stacktrace: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason, Stk]), + {error, Reason} + end. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +-compile({inline, [hk2func/1]}). +hk2func('client.connect') -> 'on_client_connect'; +hk2func('client.connack') -> 'on_client_connack'; +hk2func('client.connected') -> 'on_client_connected'; +hk2func('client.disconnected') -> 'on_client_disconnected'; +hk2func('client.authenticate') -> 'on_client_authenticate'; +hk2func('client.check_acl') -> 'on_client_check_acl'; +hk2func('client.subscribe') -> 'on_client_subscribe'; +hk2func('client.unsubscribe') -> 'on_client_unsubscribe'; +hk2func('session.created') -> 'on_session_created'; +hk2func('session.subscribed') -> 'on_session_subscribed'; +hk2func('session.unsubscribed') -> 'on_session_unsubscribed'; +hk2func('session.resumed') -> 'on_session_resumed'; +hk2func('session.discarded') -> 'on_session_discarded'; +hk2func('session.takeovered') -> 'on_session_takeovered'; +hk2func('session.terminated') -> 'on_session_terminated'; +hk2func('message.publish') -> 'on_message_publish'; +hk2func('message.delivered') ->'on_message_delivered'; +hk2func('message.acked') -> 'on_message_acked'; +hk2func('message.dropped') ->'on_message_dropped'. + +-compile({inline, [message_hooks/0]}). +message_hooks() -> + ['message.publish', 'message.delivered', + 'message.acked', 'message.dropped']. + +-compile({inline, [available_hooks/0]}). +available_hooks() -> + ['client.connect', 'client.connack', 'client.connected', + 'client.disconnected', 'client.authenticate', 'client.check_acl', + 'client.subscribe', 'client.unsubscribe', + 'session.created', 'session.subscribed', 'session.unsubscribed', + 'session.resumed', 'session.discarded', 'session.takeovered', + 'session.terminated' | message_hooks()]. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_sup.erl b/apps/emqx_exhook/src/emqx_exhook_sup.erl similarity index 67% rename from apps/emqx_exhook/src/emqx_extension_hook_sup.erl rename to apps/emqx_exhook/src/emqx_exhook_sup.erl index 64b94070f..c8d2ecf35 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_sup.erl +++ b/apps/emqx_exhook/src/emqx_exhook_sup.erl @@ -14,7 +14,7 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_sup). +-module(emqx_exhook_sup). -behaviour(supervisor). @@ -22,8 +22,8 @@ , init/1 ]). --export([ start_driver_pool/1 - , stop_driver_pool/1 +-export([ start_grpc_client_channel/3 + , stop_grpc_client_channel/1 ]). %%-------------------------------------------------------------------- @@ -40,11 +40,20 @@ init([]) -> %% APIs %%-------------------------------------------------------------------- --spec start_driver_pool(map()) -> {ok, pid()} | {error, term()}. -start_driver_pool(Spec) -> - supervisor:start_child(?MODULE, Spec). +-spec start_grpc_client_channel( + string(), + uri_string:uri_string(), + grpc_client:options()) -> {ok, pid()} | {error, term()}. +start_grpc_client_channel(Name, SvrAddr, Options) -> + grpc_client_sup:create_channel_pool(Name, SvrAddr, Options). --spec stop_driver_pool(atom()) -> ok. -stop_driver_pool(Name) -> - ok = supervisor:terminate_child(?MODULE, Name), - ok = supervisor:delete_child(?MODULE, Name). +-spec stop_grpc_client_channel(string()) -> ok. +stop_grpc_client_channel(Name) -> + %% Avoid crash due to hot-upgrade had unloaded + %% grpc application + try + grpc_client_sup:stop_channel_pool(Name) + catch + _:_:_ -> + ok + end. diff --git a/apps/emqx_exhook/src/emqx_extension_hook.app.src b/apps/emqx_exhook/src/emqx_extension_hook.app.src deleted file mode 100644 index 0575ec95f..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook.app.src +++ /dev/null @@ -1,14 +0,0 @@ -{application, emqx_extension_hook, - [{description, "EMQ X Extension for Hook"}, - {vsn, "git"}, - {modules, []}, - {registered, []}, - {mod, {emqx_extension_hook_app, []}}, - {applications, [kernel, stdlib, ecpool, erlport]}, - {env,[]}, - {licenses, ["Apache-2.0"]}, - {maintainers, ["EMQ X Team "]}, - {links, [{"Homepage", "https://emqx.io/"}, - {"Github", "https://github.com/emqx/emqx-extension-hook"} - ]} - ]}. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_driver.erl b/apps/emqx_exhook/src/emqx_extension_hook_driver.erl deleted file mode 100644 index 2600c6e6b..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook_driver.erl +++ /dev/null @@ -1,305 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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_extension_hook_driver). - --include_lib("emqx/include/logger.hrl"). - --logger_header("[ExHook Driver]"). - -%% Load/Unload --export([ load/2 - , unload/1 - , connect/1 - ]). - -%% APIs --export([ run_hook/3 - , run_hook_fold/4]). - -%% Infos --export([ name/1 - , format/1 - ]). - --record(driver, { - %% Driver name (equal to ecpool name) - name :: driver_name(), - %% Driver type - type :: driver_type(), - %% Initial Module name - init :: atom(), - %% Hook Spec - hookspec :: hook_spec(), - %% Metric fun - incfun :: function(), - %% low layer state - state - }). - --type driver_name() :: python | python3 | java | webhook | lua | atom(). --type driver_type() :: python | webhok | java | atom(). --type driver() :: #driver{}. - --type hook_spec() :: #{hookname() => [{callback_m(), callback_f(), spec()}]}. --type hookname() :: client_connect - | client_connack - | client_connected - | client_disconnected - | client_authenticate - | client_check_acl - | client_subscribe - | client_unsubscribe - | session_created - | session_subscribed - | session_unsubscribed - | session_resumed - | session_discarded - | session_takeovered - | session_terminated - | message_publish - | message_delivered - | message_acked - | message_dropped. - --type callback_m() :: atom(). - --type callback_f() :: atom(). - --type spec() :: #{ - topic => binary() %% for `message` hook only - }. - --export_type([driver/0]). - -%%-------------------------------------------------------------------- -%% Load/Unload APIs -%%-------------------------------------------------------------------- - --spec load(atom(), list()) -> {ok, driver()} | {error, term()} . -load(Name, Opts0) -> - case lists:keytake(init_module, 1, Opts0) of - false -> {error, not_found_initial_module}; - {value, {_,InitM}, Opts} -> - Spec = pool_spec(Name, Opts), - {ok, _} = emqx_extension_hook_sup:start_driver_pool(Spec), - do_init(Name, InitM) - end. - --spec unload(driver()) -> ok. -unload(#driver{name = Name, init = InitM}) -> - do_deinit(Name, InitM), - emqx_extension_hook_sup:stop_driver_pool(Name). - -do_deinit(Name, InitM) -> - _ = raw_call(type(Name), Name, InitM, 'deinit', []), - ok. - -do_init(Name, InitM) -> - Type = type(Name), - case raw_call(Type, Name, InitM, 'init', []) of - {ok, {HookSpec, State}} -> - NHookSpec = resovle_hook_spec(HookSpec), - %% Reigster metrics - Prefix = "exhook." ++ atom_to_list(Name) ++ ".", - ensure_metrics(Prefix, NHookSpec), - {ok, #driver{type = Type, - name = Name, - init = InitM, - state = State, - hookspec = NHookSpec, - incfun = incfun(Prefix) }}; - {error, Reason} -> - emqx_extension_hook_sup:stop_driver_pool(Name), - {error, Reason} - end. - -%% @private -pool_spec(Name, Opts) -> - NOpts = lists:keystore(pool_size, 1, Opts, {pool_size, 1}), - ecpool:pool_spec(Name, Name, ?MODULE, [{name, Name} | NOpts]). - -resovle_hook_spec(HookSpec) -> - Atom = fun(B) -> list_to_atom(B) end, - HookSpec1 = lists:map(fun({Name, Module, Func}) -> - {Name, Module, Func, []}; - (Other) -> Other - end, HookSpec), - lists:foldr( - fun({Name, Module, Func, Spec}, Acc) -> - NameAtom = Atom(Name), - Acc#{NameAtom => [{Atom(Module), Atom(Func), maps:from_list(Spec)} | maps:get(NameAtom, Acc, [])]} - end, #{}, HookSpec1). - -ensure_metrics(Prefix, HookSpec) -> - Keys = [ list_to_atom(Prefix ++ atom_to_list(K)) || K <- maps:keys(HookSpec)], - lists:foreach(fun emqx_metrics:ensure/1, Keys). - -incfun(Prefix) -> - fun(Name) -> - emqx_metrics:inc(list_to_atom(Prefix ++ atom_to_list(Name))) - end. - -format(#driver{name = Name, init = InitM, hookspec = Hooks}) -> - io_lib:format("name=~p, init_module=~p, hooks=~0p", [Name, InitM, maps:keys(Hooks)]). - -%%-------------------------------------------------------------------- -%% ecpool callback -%%-------------------------------------------------------------------- - --spec connect(list()) -> {ok, pid()} | {error, any()}. -connect(Opts0) -> - case lists:keytake(name, 1, lists:keydelete(ecpool_worker_id, 1, Opts0)) of - {_,{_, Name}, Opts} - when Name =:= python; - Name =:= python3 -> - NOpts = resovle_search_path(python, Opts), - python:start_link([{python, atom_to_list(Name)} | NOpts]); - {_,{_, Name}, Opts} - when Name =:= java -> - NOpts = resovle_search_path(java, Opts), - java:start_link([{java, atom_to_list(Name)} | NOpts]) - end. - -%% @private -resovle_search_path(java, Opts) -> - case proplists:get_value(java_path, Opts) of - undefined -> Opts; - Path -> - Solved = lists:flatten( - lists:join(pathsep(), - [expand_jar_packages(filename:absname(P)) - || P <- re:split(Path, pathsep(), [{return, list}]), P /= ""])), - lists:keystore(java_path, 1, Opts, {java_path, Solved}) - end; - -resovle_search_path(_, Opts) -> - Opts. - -expand_jar_packages(Path) -> - IsJarPkgs = fun(Name) -> - Ext = filename:extension(Name), - Ext == ".jar" orelse Ext == ".zip" - end, - case file:list_dir(Path) of - {ok, []} -> [Path]; - {error, _} -> [Path]; - {ok, Names} -> - lists:join(pathsep(), - [Path] ++ [filename:join([Path, Name]) || Name <- Names, IsJarPkgs(Name)]) - end. - -pathsep() -> - case os:type() of - {win32, _} -> - ";"; - _ -> - ":" - end. - -%%-------------------------------------------------------------------- -%% APIs -%%-------------------------------------------------------------------- - -name(#driver{name = Name}) -> - Name. - --spec run_hook(atom(), list(), driver()) - -> ok - | {ok, term()} - | {error, term()}. -run_hook(Name, Args, Driver = #driver{hookspec = HookSpec, incfun = IncFun}) -> - case maps:get(Name, HookSpec, []) of - [] -> ok; - Cbs -> - lists:foldl(fun({M, F, Opts}, _) -> - case match_topic_filter(Name, proplists:get_value(topic, Args, null), maps:get(topics, Opts, [])) of - true -> - IncFun(Name), - call(M, F, Args, Driver); - _ -> ok - end - end, ok, Cbs) - end. - --spec run_hook_fold(atom(), list(), any(), driver()) - -> ok - | {ok, term()} - | {error, term()}. -run_hook_fold(Name, Args, Acc0, Driver = #driver{hookspec = HookSpec, incfun = IncFun}) -> - case maps:get(Name, HookSpec, []) of - [] -> ok; - Cbs -> - lists:foldl(fun({M, F, Opts}, Acc) -> - case match_topic_filter(Name, proplists:get_value(topic, Args, null), maps:get(topics, Opts, [])) of - true -> - IncFun(Name), - call(M, F, Args ++ [Acc], Driver); - _ -> ok - end - end, Acc0, Cbs) - end. - --compile({inline, [match_topic_filter/3]}). -match_topic_filter(_Name, null, _TopicFilter) -> - true; -match_topic_filter(Name, TopicName, TopicFilter) - when Name =:= message_publish; - Name =:= message_delivered; - Name =:= message_dropped; - Name =:= message_acked -> - lists:any(fun(F) -> emqx_topic:match(TopicName, F) end, TopicFilter); -match_topic_filter(_, _, _) -> - true. - --spec call(atom(), atom(), list(), driver()) -> ok | {ok, term()} | {error, term()}. -call(Mod, Fun, Args, #driver{name = Name, type = Type, state = State}) -> - with_pool(Name, fun(C) -> - do_call(Type, C, Mod, Fun, Args ++ [State]) - end). - -raw_call(Type, Name, Mod, Fun, Args) when is_list(Args) -> - with_pool(Name, fun(C) -> - do_call(Type, C, Mod, Fun, Args) - end). - -do_call(Type, C, M, F, A) -> - case catch apply(Type, call, [C, M, F, A]) of - ok -> ok; - undefined -> ok; - {_Ok = 0, Return} -> {ok, Return}; - {_Err = 1, Reason} -> {error, Reason}; - {'EXIT', Reason, Stk} -> - ?LOG(error, "CALL ~p ~p:~p(~p), exception: ~p, stacktrace ~0p", - [Type, M, F, A, Reason, Stk]), - {error, Reason}; - _X -> - ?LOG(error, "CALL ~p ~p:~p(~p), unknown return: ~0p", - [Type, M, F, A, _X]), - {error, unknown_return_format} - end. - -%%-------------------------------------------------------------------- -%% Internal funcs -%%-------------------------------------------------------------------- - -with_pool(Name, Fun) -> - ecpool:with_client(Name, Fun). - -type(python3) -> python; -type(python) -> python; -type(Name) -> Name. - diff --git a/apps/emqx_exhook/src/emqx_extension_hook_handler.erl b/apps/emqx_exhook/src/emqx_extension_hook_handler.erl deleted file mode 100644 index 3ce9e4e90..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook_handler.erl +++ /dev/null @@ -1,249 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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_extension_hook_handler). - --include("emqx_extension_hook.hrl"). --include_lib("emqx/include/emqx.hrl"). --include_lib("emqx/include/logger.hrl"). - --logger_header("[ExHook]"). - --export([ on_client_connect/2 - , on_client_connack/3 - , on_client_connected/2 - , on_client_disconnected/3 - , on_client_authenticate/2 - , on_client_check_acl/4 - , on_client_subscribe/3 - , on_client_unsubscribe/3 - ]). - -%% Session Lifecircle Hooks --export([ on_session_created/2 - , on_session_subscribed/3 - , on_session_unsubscribed/3 - , on_session_resumed/2 - , on_session_discarded/2 - , on_session_takeovered/2 - , on_session_terminated/3 - ]). - -%% Utils --export([ message/1 - , validator/1 - , assign_to_message/2 - , clientinfo/1 - , stringfy/1 - ]). - --import(emqx_extension_hook, - [ cast/2 - , call_fold/4 - ]). - --exhooks([ {'client.connect', {?MODULE, on_client_connect, []}} - , {'client.connack', {?MODULE, on_client_connack, []}} - , {'client.connected', {?MODULE, on_client_connected, []}} - , {'client.disconnected', {?MODULE, on_client_disconnected, []}} - , {'client.authenticate', {?MODULE, on_client_authenticate, []}} - , {'client.check_acl', {?MODULE, on_client_check_acl, []}} - , {'client.subscribe', {?MODULE, on_client_subscribe, []}} - , {'client.unsubscribe', {?MODULE, on_client_unsubscribe, []}} - , {'session.created', {?MODULE, on_session_created, []}} - , {'session.subscribed', {?MODULE, on_session_subscribed, []}} - , {'session.unsubscribed',{?MODULE, on_session_unsubscribed, []}} - , {'session.resumed', {?MODULE, on_session_resumed, []}} - , {'session.discarded', {?MODULE, on_session_discarded, []}} - , {'session.takeovered', {?MODULE, on_session_takeovered, []}} - , {'session.terminated', {?MODULE, on_session_terminated, []}} - ]). - -%%-------------------------------------------------------------------- -%% Clients -%%-------------------------------------------------------------------- - -on_client_connect(ConnInfo, _Props) -> - cast('client_connect', [conninfo(ConnInfo), props(_Props)]). - -on_client_connack(ConnInfo, Rc, _Props) -> - cast('client_connack', [conninfo(ConnInfo), Rc, props(_Props)]). - -on_client_connected(ClientInfo, _ConnInfo) -> - cast('client_connected', [clientinfo(ClientInfo)]). - -on_client_disconnected(ClientInfo, {shutdown, Reason}, ConnInfo) when is_atom(Reason) -> - on_client_disconnected(ClientInfo, Reason, ConnInfo); -on_client_disconnected(ClientInfo, Reason, _ConnInfo) -> - cast('client_disconnected', [clientinfo(ClientInfo), stringfy(Reason)]). - -on_client_authenticate(ClientInfo, AuthResult) -> - AccArg = maps:get(auth_result, AuthResult, undefined) == success, - Name = 'client_authenticate', - case call_fold(Name, [clientinfo(ClientInfo)], AccArg, validator(Name)) of - {stop, Bool} when is_boolean(Bool) -> - Result = case Bool of true -> success; _ -> not_authorized end, - {stop, AuthResult#{auth_result => Result, anonymous => false}}; - _ -> - {ok, AuthResult} - end. - -on_client_check_acl(ClientInfo, PubSub, Topic, Result) -> - AccArg = Result == allow, - Name = 'client_check_acl', - case call_fold(Name, [clientinfo(ClientInfo), PubSub, Topic], AccArg, validator(Name)) of - {stop, Bool} when is_boolean(Bool) -> - NResult = case Bool of true -> allow; _ -> deny end, - {stop, NResult}; - _ -> {ok, Result} - end. - -on_client_subscribe(ClientInfo, Props, TopicFilters) -> - cast('client_subscribe', [clientinfo(ClientInfo), props(Props), topicfilters(TopicFilters)]). - -on_client_unsubscribe(Clientinfo, Props, TopicFilters) -> - cast('client_unsubscribe', [clientinfo(Clientinfo), props(Props), topicfilters(TopicFilters)]). - -%%-------------------------------------------------------------------- -%% Session -%%-------------------------------------------------------------------- - -on_session_created(ClientInfo, _SessInfo) -> - cast('session_created', [clientinfo(ClientInfo)]). - -on_session_subscribed(Clientinfo, Topic, SubOpts) -> - cast('session_subscribed', [clientinfo(Clientinfo), Topic, props(SubOpts)]). - -on_session_unsubscribed(ClientInfo, Topic, _SubOpts) -> - cast('session_unsubscribed', [clientinfo(ClientInfo), Topic]). - -on_session_resumed(ClientInfo, _SessInfo) -> - cast('session_resumed', [clientinfo(ClientInfo)]). - -on_session_discarded(ClientInfo, _SessInfo) -> - cast('session_discarded', [clientinfo(ClientInfo)]). - -on_session_takeovered(ClientInfo, _SessInfo) -> - cast('session_takeovered', [clientinfo(ClientInfo)]). - -on_session_terminated(ClientInfo, Reason, _SessInfo) -> - cast('session_terminated', [clientinfo(ClientInfo), stringfy(Reason)]). - -%%-------------------------------------------------------------------- -%% Types - -props(undefined) -> []; -props(M) when is_map(M) -> maps:to_list(M). - -conninfo(_ConnInfo = - #{clientid := ClientId, username := Username, peername := {Peerhost, _}, - sockname := {_, SockPort}, proto_name := ProtoName, proto_ver := ProtoVer, - keepalive := Keepalive}) -> - [{node, node()}, - {clientid, ClientId}, - {username, maybe(Username)}, - {peerhost, ntoa(Peerhost)}, - {sockport, SockPort}, - {proto_name, ProtoName}, - {proto_ver, ProtoVer}, - {keepalive, Keepalive}]. - -clientinfo(ClientInfo = - #{clientid := ClientId, username := Username, peerhost := PeerHost, - sockport := SockPort, protocol := Protocol, mountpoint := Mountpoiont}) -> - [{node, node()}, - {clientid, ClientId}, - {username, maybe(Username)}, - {password, maybe(maps:get(password, ClientInfo, undefined))}, - {peerhost, ntoa(PeerHost)}, - {sockport, SockPort}, - {protocol, Protocol}, - {mountpoint, maybe(Mountpoiont)}, - {is_superuser, maps:get(is_superuser, ClientInfo, false)}, - {anonymous, maps:get(anonymous, ClientInfo, true)}]. - -message(#message{id = Id, qos = Qos, from = From, topic = Topic, payload = Payload, timestamp = Ts}) -> - [{node, node()}, - {id, hexstr(Id)}, - {qos, Qos}, - {from, From}, - {topic, Topic}, - {payload, Payload}, - {timestamp, Ts}]. - -topicfilters(Tfs = [{_, _}|_]) -> - [{Topic, Qos} || {Topic, #{qos := Qos}} <- Tfs]; -topicfilters(Tfs) -> - Tfs. - -ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> - list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); -ntoa(IP) -> - list_to_binary(inet_parse:ntoa(IP)). - -maybe(undefined) -> <<"">>; -maybe(B) -> B. - -%% @private -stringfy(Term) when is_binary(Term) -> - Term; -stringfy(Term) when is_atom(Term) -> - atom_to_binary(Term, utf8); -stringfy(Term) when is_tuple(Term) -> - iolist_to_binary(io_lib:format("~p", [Term])). - -hexstr(B) -> - iolist_to_binary([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(B)]). - -%%-------------------------------------------------------------------- -%% Validator funcs - -validator(Name) -> - fun(V) -> validate_acc_arg(Name, V) end. - -validate_acc_arg('client_authenticate', V) when is_boolean(V) -> true; -validate_acc_arg('client_check_acl', V) when is_boolean(V) -> true; -validate_acc_arg('message_publish', V) when is_list(V) -> validate_msg(V, true); -validate_acc_arg(_, _) -> false. - -validate_msg([], Bool) -> - Bool; -validate_msg(_, false) -> - false; -validate_msg([{topic, T} | More], _) -> - validate_msg(More, is_binary(T)); -validate_msg([{payload, P} | More], _) -> - validate_msg(More, is_binary(P)); -validate_msg([{qos, Q} | More], _) -> - validate_msg(More, Q =< 2 andalso Q >= 0); -validate_msg([{timestamp, T} | More], _) -> - validate_msg(More, is_integer(T)); -validate_msg([_ | More], _) -> - validate_msg(More, true). - -%%-------------------------------------------------------------------- -%% Misc - -assign_to_message([], Message) -> - Message; -assign_to_message([{topic, Topic}|More], Message) -> - assign_to_message(More, Message#message{topic = Topic}); -assign_to_message([{qos, Qos}|More], Message) -> - assign_to_message(More, Message#message{qos = Qos}); -assign_to_message([{payload, Payload}|More], Message) -> - assign_to_message(More, Message#message{payload = Payload}); -assign_to_message([_|More], Message) -> - assign_to_message(More, Message). diff --git a/apps/emqx_exhook/test/emqx_exhook_SUITE.erl b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl new file mode 100644 index 000000000..b66950215 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl @@ -0,0 +1,53 @@ +%%-------------------------------------------------------------------- +%% 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_exhook_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + Cfg. + +end_per_suite(_Cfg) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + emqx_exhook_demo_svr:stop(). + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Test cases +%%-------------------------------------------------------------------- + +t_hooks(_Cfg) -> + ok. diff --git a/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl new file mode 100644 index 000000000..05fa07465 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl @@ -0,0 +1,297 @@ +%%-------------------------------------------------------------------- +%% 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_exhook_demo_svr). + +-behavior(emqx_exhook_v_1_hook_provider_bhvr). + +%% +-export([ start/0 + , stop/0 + , take/0 + , in/1 + ]). + +%% gRPC server HookProvider callbacks +-export([ on_provider_loaded/2 + , on_provider_unloaded/2 + , on_client_connect/2 + , on_client_connack/2 + , on_client_connected/2 + , on_client_disconnected/2 + , on_client_authenticate/2 + , on_client_check_acl/2 + , on_client_subscribe/2 + , on_client_unsubscribe/2 + , on_session_created/2 + , on_session_subscribed/2 + , on_session_unsubscribed/2 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/2 + , on_message_publish/2 + , on_message_delivered/2 + , on_message_dropped/2 + , on_message_acked/2 + ]). + +-define(PORT, 9000). +-define(NAME, ?MODULE). + +%%-------------------------------------------------------------------- +%% Server APIs +%%-------------------------------------------------------------------- + +start() -> + Pid = spawn(fun mngr_main/0), + register(?MODULE, Pid), + {ok, Pid}. + +stop() -> + grpc:stop_server(?NAME), + ?MODULE ! stop. + +take() -> + ?MODULE ! {take, self()}, + receive {value, V} -> V + after 5000 -> error(timeout) end. + +in({FunName, Req}) -> + ?MODULE ! {in, FunName, Req}. + +mngr_main() -> + application:ensure_all_started(grpc), + Services = #{protos => [emqx_exhook_pb], + services => #{'emqx.exhook.v1.HookProvider' => emqx_exhook_demo_svr} + }, + Options = [], + Svr = grpc:start_server(?NAME, ?PORT, Services, Options), + mngr_loop([Svr, queue:new(), queue:new()]). + +mngr_loop([Svr, Q, Takes]) -> + receive + {in, FunName, Req} -> + {NQ1, NQ2} = reply(queue:in({FunName, Req}, Q), Takes), + mngr_loop([Svr, NQ1, NQ2]); + {take, From} -> + {NQ1, NQ2} = reply(Q, queue:in(From, Takes)), + mngr_loop([Svr, NQ1, NQ2]); + stop -> + exit(normal) + end. + +reply(Q1, Q2) -> + case queue:len(Q1) =:= 0 orelse + queue:len(Q2) =:= 0 of + true -> {Q1, Q2}; + _ -> + {{value, {Name, V}}, NQ1} = queue:out(Q1), + {{value, From}, NQ2} = queue:out(Q2), + From ! {value, {Name, V}}, + {NQ1, NQ2} + end. + +%%-------------------------------------------------------------------- +%% callbacks +%%-------------------------------------------------------------------- + +-spec on_provider_loaded(emqx_exhook_pb:provider_loaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:loaded_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. + +on_provider_loaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{hooks => [ + #{name => <<"client.connect">>}, + #{name => <<"client.connack">>}, + #{name => <<"client.connected">>}, + #{name => <<"client.disconnected">>}, + #{name => <<"client.authenticate">>}, + #{name => <<"client.check_acl">>}, + #{name => <<"client.subscribe">>}, + #{name => <<"client.unsubscribe">>}, + #{name => <<"session.created">>}, + #{name => <<"session.subscribed">>}, + #{name => <<"session.unsubscribed">>}, + #{name => <<"session.resumed">>}, + #{name => <<"session.discarded">>}, + #{name => <<"session.takeovered">>}, + #{name => <<"session.terminated">>}, + #{name => <<"message.publish">>}, + #{name => <<"message.delivered">>}, + #{name => <<"message.acked">>}, + #{name => <<"message.dropped">>}]}, Md}. +-spec on_provider_unloaded(emqx_exhook_pb:provider_unloaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_provider_unloaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connect(emqx_exhook_pb:client_connect_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connect(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connack(emqx_exhook_pb:client_connack_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connack(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connected(emqx_exhook_pb:client_connected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_disconnected(emqx_exhook_pb:client_disconnected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_disconnected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_authenticate(emqx_exhook_pb:client_authenticate_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_authenticate(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'IGNORE'}, Md}. + +-spec on_client_check_acl(emqx_exhook_pb:client_check_acl_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_check_acl(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'STOP_AND_RETURN', value => {bool_result, true}}, Md}. + +-spec on_client_subscribe(emqx_exhook_pb:client_subscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_subscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_unsubscribe(emqx_exhook_pb:client_unsubscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_unsubscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_created(emqx_exhook_pb:session_created_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_created(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_subscribed(emqx_exhook_pb:session_subscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_subscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_unsubscribed(emqx_exhook_pb:session_unsubscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_unsubscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_resumed(emqx_exhook_pb:session_resumed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_resumed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_discarded(emqx_exhook_pb:session_discarded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_discarded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_takeovered(emqx_exhook_pb:session_takeovered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_takeovered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_terminated(emqx_exhook_pb:session_terminated_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_terminated(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_publish(emqx_exhook_pb:message_publish_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_publish(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_delivered(emqx_exhook_pb:message_delivered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_delivered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_dropped(emqx_exhook_pb:message_dropped_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_dropped(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_acked(emqx_exhook_pb:message_acked_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_acked(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. diff --git a/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl b/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl deleted file mode 100644 index 11237b7fd..000000000 --- a/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl +++ /dev/null @@ -1,139 +0,0 @@ -%%-------------------------------------------------------------------- -%% 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_extension_hook_SUITE). - --compile(export_all). --compile(nowarn_export_all). - --include_lib("eunit/include/eunit.hrl"). --include_lib("common_test/include/ct.hrl"). - -%%-------------------------------------------------------------------- -%% Setups -%%-------------------------------------------------------------------- - -all() -> emqx_ct:all(?MODULE). - -init_per_suite(Cfg) -> - emqx_ct_helpers:start_apps([emqx_extension_hook], fun set_special_cfgs/1), - emqx_logger:set_log_level(warning), - Cfg. - -end_per_suite(_) -> - emqx_ct_helpers:stop_apps([emqx_extension_hook]). - -set_special_cfgs(emqx) -> - application:set_env(emqx, allow_anonymous, false), - application:set_env(emqx, enable_acl_cache, false), - application:set_env(emqx, plugins_loaded_file, - emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); -set_special_cfgs(emqx_extension_hook) -> - application:set_env(emqx_extension_hook, drivers, []), - ok. - -reload_plugin_with(_DriverName = python3) -> - application:stop(emqx_extension_hook), - Path = emqx_ct_helpers:deps_path(emqx_extension_hook, "test/scripts"), - Drivers = [{python3, [{init_module, main}, - {python_path, Path}, - {call_timeout, 5000}]}], - application:set_env(emqx_extension_hook, drivers, Drivers), - application:ensure_all_started(emqx_extension_hook); - -reload_plugin_with(_DriverName = java) -> - application:stop(emqx_extension_hook), - - ErlPortJar = emqx_ct_helpers:deps_path(erlport, "priv/java/_pkgs/erlport.jar"), - Path = emqx_ct_helpers:deps_path(emqx_extension_hook, "test/scripts"), - Drivers = [{java, [{init_module, 'Main'}, - {java_path, Path}, - {call_timeout, 5000}]}], - - %% Compile it - ct:pal(os:cmd(lists:concat(["cd ", Path, " && ", - "rm -rf Main.class State.class && ", - "javac -cp ", ErlPortJar, " Main.java"]))), - - application:set_env(emqx_extension_hook, drivers, Drivers), - application:ensure_all_started(emqx_extension_hook). - -%%-------------------------------------------------------------------- -%% Test cases -%%-------------------------------------------------------------------- - -t_python3(_) -> - reload_plugin_with(python3), - schedule_all_hooks(). - -t_java(_) -> - reload_plugin_with(java), - schedule_all_hooks(). - -schedule_all_hooks() -> - ok = emqx_extension_hook_handler:on_client_connect(conninfo(), #{}), - ok = emqx_extension_hook_handler:on_client_connack(conninfo(), success,#{}), - ok = emqx_extension_hook_handler:on_client_connected(clientinfo(), conninfo()), - ok = emqx_extension_hook_handler:on_client_disconnected(clientinfo(), takeovered, conninfo()), - {stop, #{auth_result := success, - anonymous := false}} = emqx_extension_hook_handler:on_client_authenticate(clientinfo(), #{auth_result => not_authorised, anonymous => true}), - {stop, allow} = emqx_extension_hook_handler:on_client_check_acl(clientinfo(), publish, <<"t/a">>, deny), - ok = emqx_extension_hook_handler:on_client_subscribe(clientinfo(), #{}, sub_topicfilters()), - ok = emqx_extension_hook_handler:on_client_unsubscribe(clientinfo(), #{}, unsub_topicfilters()), - - ok = emqx_extension_hook_handler:on_session_created(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_subscribed(clientinfo(), <<"t/a">>, subopts()), - ok = emqx_extension_hook_handler:on_session_unsubscribed(clientinfo(), <<"t/a">>, subopts()), - ok = emqx_extension_hook_handler:on_session_resumed(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_discarded(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_takeovered(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_terminated(clientinfo(), sockerr, sessinfo()). - -%%-------------------------------------------------------------------- -%% Generator -%%-------------------------------------------------------------------- - -conninfo() -> - #{clientid => <<"123">>, - username => <<"abc">>, - peername => {{127,0,0,1}, 2341}, - sockname => {{0,0,0,0}, 1883}, - proto_name => <<"MQTT">>, - proto_ver => 4, - keepalive => 60 - }. - -clientinfo() -> - #{clientid => <<"123">>, - username => <<"abc">>, - peerhost => {127,0,0,1}, - sockport => 1883, - protocol => 'mqtt', - mountpoint => undefined - }. - -sub_topicfilters() -> - [{<<"t/a">>, #{qos => 1}}]. - -unsub_topicfilters() -> - [<<"t/a">>]. - -sessinfo() -> - {session,xxx,yyy}. - -subopts() -> - #{qos => 1, rh => 0, rap => 0, nl => 0}. - diff --git a/apps/emqx_exhook/test/props/prop_exhook_hooks.erl b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl new file mode 100644 index 000000000..e4c11dd3d --- /dev/null +++ b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl @@ -0,0 +1,537 @@ +%%-------------------------------------------------------------------- +%% 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(prop_exhook_hooks). + +-include_lib("proper/include/proper.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-import(emqx_ct_proper_types, + [ conninfo/0 + , clientinfo/0 + , sessioninfo/0 + , message/0 + , connack_return_code/0 + , topictab/0 + , topic/0 + , subopts/0 + ]). + +-define(ALL(Vars, Types, Exprs), + ?SETUP(fun() -> + State = do_setup(), + fun() -> do_teardown(State) end + end, ?FORALL(Vars, Types, Exprs))). + +%%-------------------------------------------------------------------- +%% Properties +%%-------------------------------------------------------------------- + +prop_client_connect() -> + ?ALL({ConnInfo, ConnProps}, + {conninfo(), conn_properties()}, + begin + _OutConnProps = emqx_hooks:run_fold('client.connect', [ConnInfo], ConnProps), + {'on_client_connect', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(ConnProps), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_connack() -> + ?ALL({ConnInfo, Rc, AckProps}, + {conninfo(), connack_return_code(), ack_properties()}, + begin + _OutAckProps = emqx_hooks:run_fold('client.connack', [ConnInfo, Rc], AckProps), + {'on_client_connack', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(AckProps), + result_code => atom_to_binary(Rc, utf8), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_authenticate() -> + ?ALL({ClientInfo, AuthResult}, {clientinfo(), authresult()}, + begin + _OutAuthResult = emqx_hooks:run_fold('client.authenticate', [ClientInfo], AuthResult), + {'on_client_authenticate', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => authresult_to_bool(AuthResult), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_check_acl() -> + ?ALL({ClientInfo, PubSub, Topic, Result}, + {clientinfo(), oneof([publish, subscribe]), topic(), oneof([allow, deny])}, + begin + _OutResult = emqx_hooks:run_fold('client.check_acl', [ClientInfo, PubSub, Topic], Result), + {'on_client_check_acl', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => aclresult_to_bool(Result), + type => pubsub_to_enum(PubSub), + topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + + +prop_client_connected() -> + ?ALL({ClientInfo, ConnInfo}, + {clientinfo(), conninfo()}, + begin + ok = emqx_hooks:run('client.connected', [ClientInfo, ConnInfo]), + {'on_client_connected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_disconnected() -> + ?ALL({ClientInfo, Reason, ConnInfo}, + {clientinfo(), shutdown_reason(), conninfo()}, + begin + ok = emqx_hooks:run('client.disconnected', [ClientInfo, Reason, ConnInfo]), + {'on_client_disconnected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_subscribe() -> + ?ALL({ClientInfo, SubProps, TopicTab}, + {clientinfo(), sub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.subscribe', [ClientInfo, SubProps], TopicTab), + {'on_client_subscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(SubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_unsubscribe() -> + ?ALL({ClientInfo, UnSubProps, TopicTab}, + {clientinfo(), unsub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.unsubscribe', [ClientInfo, UnSubProps], TopicTab), + {'on_client_unsubscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(UnSubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_created() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.created', [ClientInfo, SessInfo]), + {'on_session_created', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_subscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.subscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_subscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + subopts => subopts(SubOpts), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_unsubscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.unsubscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_unsubscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_resumed() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.resumed', [ClientInfo, SessInfo]), + {'on_session_resumed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_discared() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.discarded', [ClientInfo, SessInfo]), + {'on_session_discarded', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_takeovered() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.takeovered', [ClientInfo, SessInfo]), + {'on_session_takeovered', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_terminated() -> + ?ALL({ClientInfo, Reason, SessInfo}, + {clientinfo(), shutdown_reason(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.terminated', [ClientInfo, Reason, SessInfo]), + {'on_session_terminated', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + + true + end). + +nodestr() -> + stringfy(node()). + +peerhost(#{peername := {Host, _}}) -> + ntoa(Host). + +sockport(#{sockname := {_, Port}}) -> + Port. + +%% copied from emqx_exhook + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +subopts(SubOpts) -> + #{qos => maps:get(qos, SubOpts, 0), + rh => maps:get(rh, SubOpts, 0), + rap => maps:get(rap, SubOpts, 0), + nl => maps:get(nl, SubOpts, 0), + share => maps:get(share, SubOpts, <<>>) + }. + +authresult_to_bool(AuthResult) -> + maps:get(auth_result, AuthResult, undefined) == success. + +aclresult_to_bool(Result) -> + Result == allow. + +pubsub_to_enum(publish) -> 'PUBLISH'; +pubsub_to_enum(subscribe) -> 'SUBSCRIBE'. + +%prop_message_publish() -> +% ?ALL({Msg, Env, Encode}, {message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_delivered() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_acked() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message()}, +% begin +% true +% end). + +%%-------------------------------------------------------------------- +%% Helper +%%-------------------------------------------------------------------- + +do_setup() -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + emqx_logger:set_log_level(warning), + %% waiting first loaded event + {'on_provider_loaded', _} = emqx_exhook_demo_svr:take(), + ok. + +do_teardown(_) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + %% waiting last unloaded event + {'on_provider_unloaded', _} = emqx_exhook_demo_svr:take(), + _ = emqx_exhook_demo_svr:stop(), + timer:sleep(2000), + ok. + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Generators +%%-------------------------------------------------------------------- + +conn_properties() -> + #{}. + +ack_properties() -> + #{}. + +sub_properties() -> + #{}. + +unsub_properties() -> + #{}. + +shutdown_reason() -> + oneof([utf8(), {shutdown, atom()}]). + +authresult() -> + #{auth_result => connack_return_code()}. + +%topic_filter_env() -> +% oneof([{<<"#">>}, {undefined}, {topic()}]). diff --git a/apps/emqx_exhook/test/scripts/Main.java b/apps/emqx_exhook/test/scripts/Main.java deleted file mode 100644 index 996b729f6..000000000 --- a/apps/emqx_exhook/test/scripts/Main.java +++ /dev/null @@ -1,160 +0,0 @@ -import java.io.*; -import java.util.*; -import com.erlport.erlang.term.*; - -class State implements Serializable { - - Integer times; - - public State() { - times = 0; - } - - public Integer incr() { - times += 1; - return times; - } - - @Override - public String toString() { - return String.format("State(times: %d)", times); - } -} - -public class Main { - - public static Object init() { - System.err.printf("Initiate driver...\n"); - - // [{"topics", ["t/#", "t/a"]}] - List topics = new ArrayList(); - topics.add(new Binary("t/#")); - topics.add(new Binary("test/#")); - - List actionOpts = new ArrayList(); - actionOpts.add(Tuple.two(new Atom("topics"), topics)); - - Object[] actions0 = new Object[] { - Tuple.three("client_connect", "Main", "on_client_connect"), - Tuple.three("client_connack", "Main", "on_client_connack"), - Tuple.three("client_connected", "Main", "on_client_connected"), - Tuple.three("client_disconnected", "Main", "on_client_disconnected"), - Tuple.three("client_authenticate", "Main", "on_client_authenticate"), - Tuple.three("client_check_acl", "Main", "on_client_check_acl"), - Tuple.three("client_subscribe", "Main", "on_client_subscribe"), - Tuple.three("client_unsubscribe", "Main", "on_client_unsubscribe"), - - Tuple.three("session_created", "Main", "on_session_created"), - Tuple.three("session_subscribed", "Main", "on_session_subscribed"), - Tuple.three("session_unsubscribed", "Main", "on_session_unsubscribed"), - Tuple.three("session_resumed", "Main", "on_session_resumed"), - Tuple.three("session_discarded", "Main", "on_session_discarded"), - Tuple.three("session_takeovered", "Main", "on_session_takeovered"), - Tuple.three("session_terminated", "Main", "on_session_terminated"), - - Tuple.four("message_publish", "Main", "on_message_publish", actionOpts), - Tuple.four("message_delivered", "Main", "on_message_delivered", actionOpts), - Tuple.four("message_acked", "Main", "on_message_acked", actionOpts), - Tuple.four("message_dropped", "Main", "on_message_dropped", actionOpts) - }; - - List actions = new ArrayList(Arrays.asList(actions0)); - - State state = new State(); - //Tuple state = new Tuple(0); - - // {0 | 1, [{HookName, CallModule, CallFunction, Opts}]} - return Tuple.two(0, Tuple.two(actions, state)); - } - - public static void deinit() { - - } - - // Callbacks - - public static void on_client_connect(Object connInfo, Object props, Object state) { - System.err.printf("[Java] on_client_connect: connInfo: %s, props: %s, state: %s\n", connInfo, props, state); - } - - public static void on_client_connack(Object connInfo, Object rc, Object props, Object state) { - System.err.printf("[Java] on_client_connack: connInfo: %s, rc: %s, props: %s, state: %s\n", connInfo, rc, props, state); - } - - public static void on_client_connected(Object clientInfo, Object state) { - System.err.printf("[Java] on_client_connected: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_client_disconnected(Object clientInfo, Object reason, Object state) { - System.err.printf("[Java] on_client_disconnected: clientinfo: %s, reason: %s, state: %s\n", clientInfo, reason, state); - } - - public static Object on_client_authenticate(Object clientInfo, Object authresult, Object state) { - System.err.printf("[Java] on_client_authenticate: clientinfo: %s, authresult: %s, state: %s\n", clientInfo, authresult, state); - - return Tuple.two(0, true); - } - - public static Object on_client_check_acl(Object clientInfo, Object pubsub, Object topic, Object result, Object state) { - System.err.printf("[Java] on_client_check_acl: clientinfo: %s, pubsub: %s, topic: %s, result: %s, state: %s\n", clientInfo, pubsub, topic, result, state); - - return Tuple.two(0, true); - } - - public static void on_client_subscribe(Object clientInfo, Object props, Object topic, Object state) { - System.err.printf("[Java] on_client_subscribe: clientinfo: %s, props: %s, topic: %s, state: %s\n", clientInfo, props, topic, state); - } - - public static void on_client_unsubscribe(Object clientInfo, Object props, Object topic, Object state) { - System.err.printf("[Java] on_client_unsubscribe: clientinfo: %s, props: %s, topic: %s, state: %s\n", clientInfo, props, topic, state); - } - - // Sessions - - public static void on_session_created(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_created: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_subscribed(Object clientInfo, Object topic, Object opts, Object state) { - System.err.printf("[Java] on_session_subscribed: clientinfo: %s, topic: %s, subopts: %s, state: %s\n", clientInfo, topic, opts, state); - } - - public static void on_session_unsubscribed(Object clientInfo, Object topic, Object state) { - System.err.printf("[Java] on_session_unsubscribed: clientinfo: %s, topic: %s, state: %s\n", clientInfo, topic, state); - } - - public static void on_session_resumed(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_resumed: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_discarded(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_discarded: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_takeovered(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_takeovered: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_terminated(Object clientInfo, Object reason, Object state) { - System.err.printf("[Java] on_session_terminated: clientinfo: %s, reason: %s, state: %s\n", clientInfo, reason, state); - } - - // Messages - - public static Object on_message_publish(Object message, Object state) { - System.err.printf("[Java] on_message_publish: message: %s, state: %s\n", message, state); - return Tuple.two(0, message); - } - - public static void on_message_dropped(Object message, Object reason, Object state) { - System.err.printf("[Java] on_message_dropped: message: %s, reason: %s, state: %s\n", message, reason, state); - } - - public static void on_message_delivered(Object clientInfo, Object message, Object state) { - System.err.printf("[Java] on_message_delivered: clientinfo: %s, message: %s, state: %s\n", clientInfo, message, state); - } - - public static void on_message_acked(Object clientInfo, Object message, Object state) { - System.err.printf("[Java] on_message_acked: clientinfo: %s, message: %s, state: %s\n", clientInfo, message, state); - } -} diff --git a/apps/emqx_exhook/test/scripts/main.py b/apps/emqx_exhook/test/scripts/main.py deleted file mode 100644 index dcfa1d7e4..000000000 --- a/apps/emqx_exhook/test/scripts/main.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/python -# -*- coding: UTF-8 -*- - -OK = 0 -ERROR = 1 - -## Return :: (HookSpec, State) -## -## HookSpec :: [(HookName, CallbackModule, CallbackFunction, Opts)] -## State :: Any -## -## HookName :: "client_connect" | "client_connack" | "client_connected" | ... -## CallbackModule :: ... -## CallbackFunctiin :: ... -## Opts :: [(Key, Value)] -def init(): - ## Maybe a connection object? - state = () - hookspec = [("client_connect", "main", "on_client_connect", []), - ("client_connack", "main", "on_client_connack", []), - ("client_connected", "main", "on_client_connected", []), - ("client_disconnected", "main", "on_client_disconnected", []), - ("client_authenticate", "main", "on_client_authenticate", []), - ("client_check_acl", "main", "on_client_check_acl", []), - ("client_subscribe", "main", "on_client_subscribe", []), - ("client_unsubscribe", "main", "on_client_unsubscribe", []), - ("session_created", "main", "on_session_created", []), - ("session_subscribed", "main", "on_session_subscribed", []), - ("session_unsubscribed","main", "on_session_unsubscribed", []), - ("session_resumed", "main", "on_session_resumed", []), - ("session_discarded", "main", "on_session_discarded", []), - ("session_takeovered", "main", "on_session_takeovered", []), - ("session_terminated", "main", "on_session_terminated", []), - ("message_publish", "main", "on_message_publish", [("topics", ["t/#"])]), - ("message_delivered", "main", "on_message_delivered", [("topics", ["t/#"])]), - ("message_acked", "main", "on_message_acked", [("topics", ["t/#"])]), - ("message_dropped", "main", "on_message_dropped", [("topics", ["t/#"])]) - ] - return (OK, (hookspec, state)) - -def deinit(): - return - -##-------------------------------------------------------------------- -## Callback functions -##-------------------------------------------------------------------- - - -##-------------------------------------------------------------------- -## Clients - -def on_client_connect(conninfo, props, state): - print("on_client_connect: conninfo: {0}, props: {1}, state: {2}".format(conninfo, props, state)) - return - -def on_client_connack(conninfo, rc, props, state): - print("on_client_connack: conninfo: {0}, rc{1}, props: {2}, state: {3}".format(conninfo, rc, props, state)) - return - -def on_client_connected(clientinfo, state): - print("on_client_connected: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_client_disconnected(clientinfo, reason, state): - print("on_client_disconnected: clientinfo: {0}, reason: {1}, state: {2}".format(clientinfo, reason, state)) - return - -def on_client_authenticate(clientinfo, authresult, state): - print("on_client_authenticate: clientinfo: {0}, authresult: {1}, state: {2}".format(clientinfo, authresult, state)) - ## True / False - return (OK, True) - -def on_client_check_acl(clientinfo, pubsub, topic, result, state): - print("on_client_check_acl: clientinfo: {0}, pubsub: {1}, topic: {2}, result: {3}, state: {4}".format(clientinfo, pubsub, topic, result, state)) - ## True / False - return (OK, True) - -def on_client_subscribe(clientinfo, props, topics, state): - print("on_client_subscribe: clientinfo: {0}, props: {1}, topics: {2}, state: {3}".format(clientinfo, props, topics, state)) - return - -def on_client_unsubscribe(clientinfo, props, topics, state): - print("on_client_unsubscribe: clientinfo: {0}, props: {1}, topics: {2}, state: {3}".format(clientinfo, props, topics, state)) - return - -##-------------------------------------------------------------------- -## Sessions - -def on_session_created(clientinfo, state): - print("on_session_created: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_subscribed(clientinfo, topic, opts, state): - print("on_session_subscribed: clientinfo: {0}, topic: {1}, opts: {2}, state: {3}".format(clientinfo, topic, opts, state)) - return - -def on_session_unsubscribed(clientinfo, topic, state): - print("on_session_unsubscribed: clientinfo: {0}, topic: {1}, state: {2}".format(clientinfo, topic, state)) - return - -def on_session_resumed(clientinfo, state): - print("on_session_resumed: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_discarded(clientinfo, state): - print("on_session_discared: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_takeovered(clientinfo, state): - print("on_session_takeovered: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_terminated(clientinfo, reason, state): - print("on_session_terminated: clientinfo: {0}, reason: {1}, state: {2}".format(clientinfo, reason, state)) - return - -##-------------------------------------------------------------------- -## Messages - -def on_message_publish(message, state): - print("on_message_publish: message: {0}, state: {1}".format(message, state)) - return message - -def on_message_dropped(message, reason, state): - print("on_message_dropped: message: {0}, reason: {1}, state: {2}".format(message, reason, state)) - return - -def on_message_delivered(clientinfo, message, state): - print("on_message_delivered: clientinfo: {0}, message: {1}, state: {2}".format(clientinfo, message, state)) - return - -def on_message_acked(clientinfo, message, state): - print("on_message_acked: clientinfo: {0}, message: {1}, state: {2}".format(clientinfo, message, state)) - return diff --git a/apps/emqx_exproto/.gitignore b/apps/emqx_exproto/.gitignore index b193aa17d..384f2255a 100644 --- a/apps/emqx_exproto/.gitignore +++ b/apps/emqx_exproto/.gitignore @@ -41,5 +41,8 @@ erlang.mk *.coverdata etc/emqx_exproto.conf.rendered Mnesia.*/ -__pycache__ -example/*.class +src/emqx_exproto_pb.erl +src/emqx_exproto_v_1_connection_adapter_bhvr.erl +src/emqx_exproto_v_1_connection_adapter_client.erl +src/emqx_exproto_v_1_connection_handler_bhvr.erl +src/emqx_exproto_v_1_connection_handler_client.erl diff --git a/apps/emqx_exproto/README.md b/apps/emqx_exproto/README.md index 7fa88a9dc..4b59dcae3 100644 --- a/apps/emqx_exproto/README.md +++ b/apps/emqx_exproto/README.md @@ -4,53 +4,25 @@ The `emqx_exproto` extremly enhance the extensibility for EMQ X. It allow using ## Feature -- [x] Support Python, Java. -- [x] Support the `tcp`, `ssl`, `udp`, `dtls` socket. -- [x] Provide the `PUB/SUB` interface to others language. - -We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. +- [x] Based on gRPC, it brings a very wide range of applicability +- [x] Allows you to use the return value to extend emqx behavior. ## Architecture ![EMQ X ExProto Arch](./docs/images/exproto-arch.jpg) -## Drivers +## Usage -### Python +### gRPC service -***Requirements:*** +See: `priv/protos/exproto.proto` -- It requires the emqx hosted machine has Python3 Runtimes -- An executable commands in your shell, i,g: `python3` or `python` +## Example -***Examples:*** +## Recommended gRPC Framework -See [example/main.python](https://github.com/emqx/emqx-exproto/blob/master/example/main.py) +See: https://github.com/grpc-ecosystem/awesome-grpc -### Java +## Thanks -See [example/Main.java](https://github.com/emqx/emqx-exproto/blob/master/example/Main.java) - - -## SDK - -The SDK encloses the underlying obscure data types and function interfaces. It only provides a convenience for development, it is not required. - -See [sdk/README.md](https://github.com/emqx/emqx-exproto/blob/master/sdk/README.md) - - -## Benchmark - -***Work in progress...*** - - -## Known Issues or TODOs - -- Configurable Log System. - * The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform - -## Reference - -- [erlport](https://github.com/hdima/erlport) -- [External Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) -- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) +- [grpcbox](https://github.com/tsloughter/grpcbox) diff --git a/apps/emqx_exproto/docs/design.md b/apps/emqx_exproto/docs/design.md index b5cc4e49d..0a6a082e2 100644 --- a/apps/emqx_exproto/docs/design.md +++ b/apps/emqx_exproto/docs/design.md @@ -4,173 +4,124 @@ 该插件给 EMQ X 带来的扩展性十分的强大,它能以你熟悉语言处理任何的私有协议,并享受由 EMQ X 系统带来的高连接,和高并发的优点。 -**声明:当前仅实现了 Python、Java 的支持** - ## 特性 -- 多语言支持。快速将接入层的协议实现迁移到 EMQ X 中进行管理 +- 极强的扩展能力。使用 gRPC 作为 RPC 通信框架,支持各个主流编程语言 - 高吞吐。连接层以完全的异步非阻塞式 I/O 的方式实现 -- 完善的连接层。完全的支持 TCP\TLS UDP\DTLS 类型的连接 +- 连接层透明。完全的支持 TCP\TLS UDP\DTLS 类型的连接管理,并对上层提供统一个 API - 连接层的管理能力。例如,最大连接数,连接和吞吐的速率限制,IP 黑名单 等 -## 架构 +## 架构 ![Extension-Protocol Arch](images/exproto-arch.jpg) -该插件需要完成的工作包括三部分: +该插件主要需要处理的内容包括: -**初始化:** (TODO) -- loaded: -- unload: +1. **连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: + - 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 + - 调用 `OnSocketCreated` 回调。用于通知外部模块**已新建立了一个连接**。 + - 调用 `OnScoektClosed` 回调。用于通知外部模块连接**已关闭**。 + - 调用 `OnReceivedBytes` 回调。用于通知外部模块**该连接新收到的数据包**。 + - 提供 `Send` 接口。供外部模块调用,**用于发送数据包**。 + - 提供 `Close` 接口。供外部模块调用,**用于主动关闭连接**。 -**连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: +2. **协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: -- 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 -- 调用 `init` 回调。用于通知外部模块**已新建立了一个连接**。 -- 调用 `terminated` 回调。用于通知外部模块连接**已关闭**。 -- 调用 `received` 回调。用于通知外部模块**该连接新收到的数据包**。 -- 提供 `send` 接口。供外部模块调用,**用于发送数据包**。 -- 提供 `close` 接口。供外部模块调用,**用于主动关闭连接**。 - - -**协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: - -- 提供 `register` 接口。供外部模块调用,用于向集群注册客户端。 -- 提供 `publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 -- 提供 `subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 -- 提供 `unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 -- 调用 `deliver` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) - - -**管理&统计相关:** 该部分主要提供其他**管理&统计相关的接口**。包括: - -- 提供 `Hooks` 类的接口。用于与系统的钩子系统进行交互。 -- 提供 `Metrics` 类的接口。用于统计。 -- 提供 `HTTP or CLI` 管理类接口。 + - 提供 `Authenticate` 接口。供外部模块调用,用于向集群注册客户端。 + - 提供 `StartTimer` 接口。供外部模块调用,用于为该连接进程启动心跳等定时器。 + - 提供 `Publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 + - 提供 `Subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 + - 提供 `Unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 + - 调用 `OnTimerTimeout` 回调。用于处理定时器超时的事件。 + - 调用 `OnReceivedMessages` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) ## 接口设计 -### 连接层接口 +从 gRPC 上的逻辑来说,emqx-exproto 会作为客户端向用户的 `ProtocolHandler` 服务发送回调请求。同时,它也会作为服务端向用户提供 `ConnectionAdapter` 服务,以提供 emqx-exproto 各个接口的访问。如图: -多语言组件需要向 EMQ X 注册的回调函数: +![Extension Protocol gRPC Arch](images/exproto-grpc-arch.jpg) -```erlang -%% Got a new Connection -init(conn(), conninfo()) -> state(). -%% Incoming a data -recevied(conn(), data(), state()) -> state(). +详情参见:`priv/protos/exproto.proto`,例如接口的定义有: -%% Socket & Connection process terminated -terminated(conn(), reason(), state()) -> ok. +```protobuff +syntax = "proto3"; --opaue conn() :: pid(). +package emqx.exproto.v1; --type conninfo() :: [ {socktype, tcp | tls | udp | dtls}, - , {peername, {inet:ip_address(), inet:port_number()}}, - , {sockname, {inet:ip_address(), inet:port_number()}}, - , {peercert, nossl | [{cn, string()}, {dn, string()}]} - ]). +// The Broker side serivce. It provides a set of APIs to +// handle a protcol access +service ConnectionAdapter { --type reason() :: string(). + // -- socket layer --type state() :: any(). + rpc Send(SendBytesRequest) returns (CodeResponse) {}; + + rpc Close(CloseSocketRequest) returns (CodeResponse) {}; + + // -- protocol layer + + rpc Authenticate(AuthenticateRequest) returns (CodeResponse) {}; + + rpc StartTimer(TimerRequest) returns (CodeResponse) {}; + + // -- pub/sub layer + + rpc Publish(PublishRequest) returns (CodeResponse) {}; + + rpc Subscribe(SubscribeRequest) returns (CodeResponse) {}; + + rpc Unsubscribe(UnsubscribeRequest) returns (CodeResponse) {}; +} + +service ConnectionHandler { + + // -- socket layer + + rpc OnSocketCreated(SocketCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSocketClosed(SocketClosedRequest) returns (EmptySuccess) {}; + + rpc OnReceivedBytes(ReceivedBytesRequest) returns (EmptySuccess) {}; + + // -- pub/sub layer + + rpc OnTimerTimeout(TimerTimeoutRequest) returns (EmptySuccess) {}; + + rpc OnReceivedMessages(ReceivedMessagesRequest) returns (EmptySuccess) {}; +} ``` - -`emqx-exproto` 需要向多语言插件提供的接口: - -``` erlang -%% Send a data to socket -send(conn(), data()) -> ok. - -%% Close the socket -close(conn() ) -> ok. -``` - - -### 协议/会话层接口 - -多语言组件需要向 EMQ X 注册的回调函数: - -```erlang -%% Received a message from a Topic -deliver(conn(), [message()], state()) -> state(). - --type message() :: [ {id, binary()} - , {qos, integer()} - , {from, binary()} - , {topic, binary()} - , {payload, binary()} - , {timestamp, integer()} - ]. -``` - - -`emqx-exproto` 需要向多语言插件提供的接口: - -``` erlang -%% Reigster the client to Broker -register(conn(), clientinfo()) -> ok | {error, Reason}. - -%% Publish a message to Broker -publish(conn(), message()) -> ok. - -%% Subscribe a topic -subscribe(conn(), topic(), qos()) -> ok. - -%% Unsubscribe a topic -unsubscribe(conn(), topic()) -> ok. - --type clientinfo() :: [ {proto_name, binary()} - , {proto_ver, integer() | string()} - , {clientid, binary()} - , {username, binary()} - , {mountpoint, binary()}} - , {keepalive, non_neg_integer()} - ]. -``` - -### 管理&统计相关接口 - -*TODO..* - ## 配置项设计 1. 以 **监听器( Listener)** 为基础,提供 TCP/UDP 的监听。 - Listener 目前仅支持:TCP、TLS、UDP、DTLS。(ws、wss、quic 暂不支持) -2. 每个监听器,会指定一个多语言的驱动,用于调用外部模块的接口 - - Driver 目前仅支持:python,java +2. 每个监听器,会指定一个 `ProtocolHandler` 的服务地址,用于调用外部模块的接口。 +3. emqx-exproto 还会监听一个 gRPC 端口用于提供对 `ConnectionAdapter` 服务的访问。 例如: ``` properties -## A JT/T 808 TCP based example: -exproto.listener.jtt808 = 6799 -exproto.listener.jtt808.type = tcp -exproto.listener.jtt808.driver = python -# acceptors, max_connections, max_conn_rate, ... -# proxy_protocol, ... -# sndbuff, recbuff, ... -# ssl, cipher, certfile, psk, ... +## gRPC 服务监听地址 (HTTP) +## +exproto.server.http.url = http://127.0.0.1:9002 -exproto.listener.jtt808. = +## gRPC 服务监听地址 (HTTPS) +## +exproto.server.https.url = https://127.0.0.1:9002 +exproto.server.https.cacertfile = ca.pem +exproto.server.https.certfile = cert.pem +exproto.server.https.keyfile = key.pem + +## Listener 配置 +## 例如,名称为 protoname 协议的 TCP 监听器配置 +exproto.listener.protoname = tcp://0.0.0.0:7993 + +## ProtocolHandler 服务地址及 https 的证书配置 +exproto.listener.protoname.proto_handler_url = http://127.0.0.1:9001 +#exproto.listener.protoname.proto_handler_certfile = +#exproto.listener.protoname.proto_handler_cacertfile = +#exproto.listener.protoname.proto_handler_keyfile = -## A CoAP UDP based example -exproto.listener.coap = 6799 -exproto.listener.coap.type = udp -exproto.listener.coap.driver = java # ... ``` - -## 集成与调试 - -参见 SDK 规范、和对应语言的开发手册 - -## SDK 实现要求 - -参见 SDK 规范、和对应语言的开发手册 - -## TODOs: - -- 认证 和 发布 订阅鉴权等钩子接入 diff --git a/apps/emqx_exproto/docs/images/exproto-arch.jpg b/apps/emqx_exproto/docs/images/exproto-arch.jpg index 54cd63f613403e86409e6cc95fdea5855482d5e5..dddf7996b98c61bf55f911a35e0f51e76c8e8728 100644 GIT binary patch literal 72633 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