Quellcode durchsuchen

feat(ldap): add LDAP connector

firest vor 2 Jahren
Ursprung
Commit
fa6343cc80

+ 94 - 0
apps/emqx_ldap/BSL.txt

@@ -0,0 +1,94 @@
+Business Source License 1.1
+
+Licensor:             Hangzhou EMQ Technologies Co., Ltd.
+Licensed Work:        EMQX Enterprise Edition
+                      The Licensed Work is (c) 2023
+                      Hangzhou EMQ Technologies Co., Ltd.
+Additional Use Grant: Students and educators are granted right to copy,
+                      modify, and create derivative work for research
+                      or education.
+Change Date:          2027-02-01
+Change License:       Apache License, Version 2.0
+
+For information about alternative licensing arrangements for the Software,
+please contact Licensor: https://www.emqx.com/en/contact
+
+Notice
+
+The Business Source License (this document, or the “License”) is not an Open
+Source license. However, the Licensed Work will eventually be made available
+under an Open Source License, as stated in this License.
+
+License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
+“Business Source License” is a trademark of MariaDB Corporation Ab.
+
+-----------------------------------------------------------------------------
+
+Business Source License 1.1
+
+Terms
+
+The Licensor hereby grants you the right to copy, modify, create derivative
+works, redistribute, and make non-production use of the Licensed Work. The
+Licensor may make an Additional Use Grant, above, permitting limited
+production use.
+
+Effective on the Change Date, or the fourth anniversary of the first publicly
+available distribution of a specific version of the Licensed Work under this
+License, whichever comes first, the Licensor hereby grants you rights under
+the terms of the Change License, and the rights granted in the paragraph
+above terminate.
+
+If your use of the Licensed Work does not comply with the requirements
+currently in effect as described in this License, you must purchase a
+commercial license from the Licensor, its affiliated entities, or authorized
+resellers, or you must refrain from using the Licensed Work.
+
+All copies of the original and modified Licensed Work, and derivative works
+of the Licensed Work, are subject to this License. This License applies
+separately for each version of the Licensed Work and the Change Date may vary
+for each version of the Licensed Work released by Licensor.
+
+You must conspicuously display this License on each original or modified copy
+of the Licensed Work. If you receive the Licensed Work in original or
+modified form from a third party, the terms and conditions set forth in this
+License apply to your use of that work.
+
+Any use of the Licensed Work in violation of this License will automatically
+terminate your rights under this License for the current and all other
+versions of the Licensed Work.
+
+This License does not grant you any right in any trademark or logo of
+Licensor or its affiliates (provided that you may use a trademark or logo of
+Licensor as expressly required by this License).
+
+TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
+AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
+EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
+TITLE.
+
+MariaDB hereby grants you permission to use this License’s text to license
+your works, and to refer to it using the trademark “Business Source License”,
+as long as you comply with the Covenants of Licensor below.
+
+Covenants of Licensor
+
+In consideration of the right to use this License’s text and the “Business
+Source License” name and trademark, Licensor covenants to MariaDB, and to all
+other recipients of the licensed work to be provided by Licensor:
+
+1. To specify as the Change License the GPL Version 2.0 or any later version,
+   or a license that is compatible with GPL Version 2.0 or a later version,
+   where “compatible” means that software provided under the Change License can
+   be included in a program with software provided under GPL Version 2.0 or a
+   later version. Licensor may specify additional Change Licenses without
+   limitation.
+
+2. To either: (a) specify an additional grant of rights to use that does not
+   impose any additional restriction on the right granted in this License, as
+   the Additional Use Grant; or (b) insert the text “None”.
+
+3. To specify a Change Date.
+
+4. Not to modify this License in any other way.

+ 14 - 0
apps/emqx_ldap/README.md

@@ -0,0 +1,14 @@
+# LDAP Connector
+
+This application houses the LDAP connector.
+It provides the APIs to connect to the LDAP service.
+
+It is used by the emqx_authz and emqx_authn applications to check user permissions.
+
+## Contributing
+
+Please see our [contributing.md](../../CONTRIBUTING.md).
+
+## License
+
+See [APL](../../APL.txt).

+ 1 - 0
apps/emqx_ldap/docker-ct

@@ -0,0 +1 @@
+

+ 7 - 0
apps/emqx_ldap/rebar.config

@@ -0,0 +1,7 @@
+%% -*- mode: erlang; -*-
+
+{erl_opts, [debug_info]}.
+{deps, [
+        {emqx_connector, {path, "../../apps/emqx_connector"}},
+        {emqx_resource, {path, "../../apps/emqx_resource"}}
+]}.

+ 13 - 0
apps/emqx_ldap/src/emqx_ldap.app.src

@@ -0,0 +1,13 @@
+{application, emqx_ldap, [
+    {description, "EMQX LDAP Connector"},
+    {vsn, "0.1.0"},
+    {registered, []},
+    {applications, [
+        kernel,
+        stdlib
+    ]},
+    {env, []},
+    {modules, []},
+
+    {links, []}
+]}.

+ 230 - 0
apps/emqx_ldap/src/emqx_ldap.erl

@@ -0,0 +1,230 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+-module(emqx_ldap).
+
+-include_lib("emqx_connector/include/emqx_connector.hrl").
+-include_lib("typerefl/include/types.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
+-include_lib("emqx/include/logger.hrl").
+-include_lib("snabbkaffe/include/snabbkaffe.hrl").
+-include_lib("eldap/include/eldap.hrl").
+
+-behaviour(emqx_resource).
+
+%% callbacks of behaviour emqx_resource
+-export([
+    callback_mode/0,
+    on_start/2,
+    on_stop/2,
+    on_query/3,
+    on_get_status/2
+]).
+
+%% ecpool connect & reconnect
+-export([connect/1]).
+
+-export([roots/0, fields/1]).
+
+-export([do_get_status/1]).
+
+-define(LDAP_HOST_OPTIONS, #{
+    default_port => 389
+}).
+
+-type params_tokens() :: #{atom() => list()}.
+-type state() ::
+    #{
+        pool_name := binary(),
+        base_tokens := params_tokens(),
+        filter_tokens := params_tokens()
+    }.
+
+-define(ECS, emqx_connector_schema_lib).
+
+%%=====================================================================
+%% Hocon schema
+roots() ->
+    [{config, #{type => hoconsc:ref(?MODULE, config)}}].
+
+fields(config) ->
+    [
+        {server, server()},
+        {pool_size, fun ?ECS:pool_size/1},
+        {username, fun ensure_username/1},
+        {password, fun ?ECS:password/1},
+        {base_object,
+            ?HOCON(binary(), #{
+                desc => ?DESC(base_object),
+                required => true
+            })},
+        {filter, ?HOCON(binary(), #{desc => ?DESC(filter), default => ""})},
+        {auto_reconnect, fun ?ECS:auto_reconnect/1}
+    ] ++ emqx_connector_schema_lib:ssl_fields().
+
+server() ->
+    Meta = #{desc => ?DESC("server")},
+    emqx_schema:servers_sc(Meta, ?LDAP_HOST_OPTIONS).
+
+ensure_username(required) ->
+    true;
+ensure_username(Field) ->
+    ?ECS:username(Field).
+
+%% ===================================================================
+callback_mode() -> always_sync.
+
+-spec on_start(binary(), hoconsc:config()) -> {ok, state()} | {error, _}.
+on_start(
+    InstId,
+    #{
+        server := Server,
+        pool_size := PoolSize,
+        ssl := SSL
+    } = Config
+) ->
+    HostPort = emqx_schema:parse_server(Server, ?LDAP_HOST_OPTIONS),
+    ?SLOG(info, #{
+        msg => "starting_ldap_connector",
+        connector => InstId,
+        config => emqx_utils:redact(Config)
+    }),
+
+    Config2 = maps:merge(Config, HostPort),
+    Config3 =
+        case maps:get(enable, SSL) of
+            true ->
+                Config2#{sslopts => emqx_tls_lib:to_client_opts(SSL)};
+            false ->
+                Config2
+        end,
+    Options = [
+        {pool_size, PoolSize},
+        {auto_reconnect, ?AUTO_RECONNECT_INTERVAL},
+        {options, Config3}
+    ],
+
+    case emqx_resource_pool:start(InstId, ?MODULE, Options) of
+        ok ->
+            {ok, prepare_template(Config, #{pool_name => InstId})};
+        {error, Reason} ->
+            ?tp(
+                ldap_connector_start_failed,
+                #{error => Reason}
+            ),
+            {error, Reason}
+    end.
+
+on_stop(InstId, _State) ->
+    ?SLOG(info, #{
+        msg => "stopping_ldap_connector",
+        connector => InstId
+    }),
+    emqx_resource_pool:stop(InstId).
+
+on_query(InstId, {query, Data}, State) ->
+    on_query(InstId, {query, Data}, [], State);
+on_query(InstId, {query, Data, Attrs}, State) ->
+    on_query(InstId, {query, Data}, [{attributes, Attrs}], State);
+on_query(InstId, {query, Data, Attrs, Timeout}, State) ->
+    on_query(InstId, {query, Data}, [{attributes, Attrs}, {timeout, Timeout}], State).
+
+on_get_status(_InstId, #{pool_name := PoolName} = _State) ->
+    case emqx_resource_pool:health_check_workers(PoolName, fun ?MODULE:do_get_status/1) of
+        true ->
+            connected;
+        false ->
+            connecting
+    end.
+
+do_get_status(Conn) ->
+    erlang:is_process_alive(Conn).
+
+%% ===================================================================
+
+connect(Options) ->
+    #{host := Host, username := Username, password := Password} =
+        Conf = proplists:get_value(options, Options),
+    OpenOpts = maps:to_list(maps:with([port, sslopts], Conf)),
+    case eldap:open([Host], [{log, fun log/3}, OpenOpts]) of
+        {ok, Handle} = Ret ->
+            case eldap:simple_bind(Handle, Username, Password) of
+                ok -> Ret;
+                Error -> Error
+            end;
+        Error ->
+            Error
+    end.
+
+on_query(
+    InstId,
+    {query, Data},
+    SearchOptions,
+    #{base_tokens := BaseTks, filter_tokens := FilterTks} = State
+) ->
+    Base = emqx_placeholder:proc_tmpl(BaseTks, Data),
+    case FilterTks of
+        [] ->
+            do_ldap_query(InstId, [{base, Base} | SearchOptions], State);
+        _ ->
+            FilterBin = emqx_placeholder:proc_tmpl(FilterTks, Data),
+            %% TODO
+            Filter = FilterBin,
+            do_ldap_query(
+                InstId,
+                [{base, Base}, {filter, Filter} | SearchOptions],
+                State
+            )
+    end.
+
+do_ldap_query(
+    InstId,
+    SearchOptions,
+    #{pool_name := PoolName} = State
+) ->
+    LogMeta = #{connector => InstId, search => SearchOptions, state => State},
+    ?TRACE("QUERY", "ldap_connector_received", LogMeta),
+    case
+        ecpool:pick_and_do(
+            PoolName,
+            {eldap, search, SearchOptions},
+            handover
+        )
+    of
+        {ok, Result} ->
+            ?tp(
+                ldap_connector_query_return,
+                #{result => Result}
+            ),
+            {ok,
+                case Result#eldap_search_result.entries of
+                    [First | _] ->
+                        %% TODO Support multi entries?
+                        First;
+                    _ ->
+                        undefined
+                end};
+        {error, Reason} ->
+            ?SLOG(
+                error,
+                LogMeta#{msg => "ldap_connector_do_sql_query_failed", reason => Reason}
+            ),
+            {error, {unrecoverable_error, Reason}}
+    end.
+
+log(Level, Format, Args) ->
+    ?SLOG(
+        Level,
+        #{
+            msg => "ldap_log",
+            log => io_lib:format(Format, Args)
+        }
+    ).
+
+prepare_template(Config, State) ->
+    do_prepare_template(maps:to_list(maps:with([base_object, filter], Config)), State).
+
+do_prepare_template([{base_object, V} | T], State) ->
+    do_prepare_template(T, State#{base_tokens => emqx_placeholder:preproc_tmpl(V)});
+do_prepare_template([{filter, V} | T], State) ->
+    do_prepare_template(T, State#{filter_tokens => emqx_placeholder:preproc_tmpl(V)}).

+ 24 - 0
apps/emqx_ldap/src/emqx_ldap_filter_lexer.xrl

@@ -0,0 +1,24 @@
+Definitions.
+
+NonControl = [^()&|!=~><:*]
+String = {NonControl}*
+White = [\s\t\n\r]+
+
+Rules.
+
+\( : {token, {lparen, TokenLine}}.
+\) : {token, {rparen, TokenLine}}.
+\& : {token, {'and', TokenLine}}.
+\| : {token, {'or', TokenLine}}.
+\! : {token, {'not', TokenLine}}.
+= : {token, {equal, TokenLine}}.
+~= : {token, {approx, TokenLine}}.
+>= : {token, {greaterOrEqual, TokenLine}}.
+<= : {token, {lessOrEqual, TokenLine}}.
+\* : {token, {asterisk, TokenLine}}.
+\: : {token, {colon, TokenLine}}.
+dn : {token, {dn, TokenLine}}.
+{White} : skip_token.
+{String} : {token, {string, TokenLine, TokenChars}}.
+
+Erlang code.

+ 133 - 0
apps/emqx_ldap/src/emqx_ldap_filter_parser.yrl

@@ -0,0 +1,133 @@
+Nonterminals
+filter filtercomp filterlist item simple present substring initial any final extensible attr value type dnattrs matchingrule.
+
+Terminals
+lparen rparen 'and' 'or' 'not' equal approx greaterOrEqual lessOrEqual asterisk colon dn string.
+
+Rootsymbol filter.
+Left 100 present.
+Left 500 substring.
+
+filter ->
+    lparen filtercomp rparen : '$2'.
+
+filtercomp ->
+    'and' filterlist: 'and'('$2').
+filtercomp ->
+    'or' filterlist: 'or'('$2').
+filtercomp ->
+    'not' filterlist: 'not'('$2').
+filtercomp ->
+    item: '$1'.
+
+filterlist ->
+    filter: '$1'.
+filterlist ->
+    filter filterlist: ['$1' | '$2'].
+
+item ->
+    simple: '$1'.
+item ->
+    present: '$1'.
+item ->
+    substring: '$1'.
+item->
+    extensible: '$1'.
+
+simple ->
+    attr equal value: equal('$1', '$3').
+simple ->
+    attr approx value: approx('$1', '$3').
+simple ->
+    attr greaterOrEqual value: greaterOrEqual('$1', '$3').
+simple ->
+    attr lessOrEqual value: lessOrEqual('$1', '$3').
+
+present ->
+    attr equal asterisk: present('$1').
+
+substring ->
+    attr equal initial asterisk any final: substrings('$1', ['$3', '$5', '$6']).
+substring ->
+    attr equal asterisk any final: substrings('$1', ['$4', '$5']).
+substring ->
+    attr equal initial asterisk any: substrings('$1', ['$3', '$5']).
+substring ->
+    attr equal asterisk any: substrings('$1', ['$4']).
+
+initial ->
+    value: {initial, '$1'}.
+
+final ->
+    value: {final, '$1'}.
+
+any -> any value asterisk: 'any'('$1', '$2').
+any -> '$empty': [].
+
+extensible ->
+    type dnattrs matchingrule colon equal value : extensible('$6', ['$1', '$2', '$3']).
+extensible ->
+    type dnattrs colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+    type matchingrule colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+    type colon equal value: extensible('$4', []).
+
+extensible ->
+    dnattrs matchingrule colon equal value: extensible('$5', ['$1', '$2']).
+extensible ->
+    matchingrule colon equal value: extensible('$4', ['$1']).
+
+attr ->
+    string: get_value('$1').
+
+value ->
+    string: get_value('$1').
+
+type ->
+    value: {type, '$1'}.
+
+dnattrs ->
+    colon dn: {dnAttributes, true}.
+
+matchingrule ->
+    colon value: {matchingRule, '$2'}.
+
+Erlang code.
+
+'and'(Value) ->
+    eldap:'and'(Value).
+
+'or'(Value) ->
+    eldap:'or'(Value).
+
+'not'(Value) ->
+    eldap:'not'(Value).
+
+equal(Attr, Value) ->
+    eldap:equalityMatch(Attr, Value).
+
+approx(Attr, Value) ->
+    eldap:approxMatch(Attr, Value).
+
+greaterOrEqual(Attr, Value) ->
+    eldap:greaterOrEqual(Attr, Value).
+
+lessOrEqual(Attr, Value) ->
+    eldap:lessOrEqual(Attr, Value).
+
+present(Value) ->
+    eldap:present(Value).
+
+substrings(Attr, List) ->
+    eldap:substrings(Attr, flatten(List)).
+
+'any'(List, Item) ->
+    [{any, Item} | List].
+
+extensible(Value, Opts) -> eldap:extensibleMatch(Value, Opts).
+
+flatten(List) -> lists:flatten(List).
+
+get_value({_Token, _Line, Value}) ->
+    Value.

+ 165 - 0
apps/emqx_ldap/test/emqx_ldap_SUITE.erl

@@ -0,0 +1,165 @@
+% %%--------------------------------------------------------------------
+% %% Copyright (c) 2020-2023 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_ldap_SUITE).
+
+-compile(nowarn_export_all).
+-compile(export_all).
+
+-include_lib("emqx_connector/include/emqx_connector.hrl").
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("emqx/include/emqx.hrl").
+-include_lib("stdlib/include/assert.hrl").
+
+-define(MYSQL_HOST, "ldap").
+-define(MYSQL_RESOURCE_MOD, emqx_ldap).
+
+all() ->
+    emqx_common_test_helpers:all(?MODULE).
+
+groups() ->
+    [].
+
+init_per_suite(Config) ->
+    case emqx_common_test_helpers:is_tcp_server_available(?MYSQL_HOST, ?MYSQL_DEFAULT_PORT) of
+        true ->
+            ok = emqx_common_test_helpers:start_apps([emqx_conf]),
+            ok = emqx_connector_test_helpers:start_apps([emqx_resource]),
+            {ok, _} = application:ensure_all_started(emqx_connector),
+            Config;
+        false ->
+            {skip, no_ldap}
+    end.
+
+end_per_suite(_Config) ->
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf]),
+    ok = emqx_connector_test_helpers:stop_apps([emqx_resource]),
+    _ = application:stop(emqx_connector).
+
+init_per_testcase(_, Config) ->
+    Config.
+
+end_per_testcase(_, _Config) ->
+    ok.
+
+% %%------------------------------------------------------------------------------
+% %% Testcases
+% %%------------------------------------------------------------------------------
+
+t_lifecycle(_Config) ->
+    perform_lifecycle_check(
+        <<"emqx_ldap_SUITE">>,
+        ldap_config()
+    ).
+
+perform_lifecycle_check(ResourceId, InitialConfig) ->
+    {ok, #{config := CheckedConfig}} =
+        emqx_resource:check_config(?MYSQL_RESOURCE_MOD, InitialConfig),
+    {ok, #{
+        state := #{pool_name := PoolName} = State,
+        status := InitialStatus
+    }} = emqx_resource:create_local(
+        ResourceId,
+        ?CONNECTOR_RESOURCE_GROUP,
+        ?MYSQL_RESOURCE_MOD,
+        CheckedConfig,
+        #{}
+    ),
+    ?assertEqual(InitialStatus, connected),
+    % Instance should match the state and status of the just started resource
+    {ok, ?CONNECTOR_RESOURCE_GROUP, #{
+        state := State,
+        status := InitialStatus
+    }} =
+        emqx_resource:get_instance(ResourceId),
+    ?assertEqual({ok, connected}, emqx_resource:health_check(ResourceId)),
+    % % Perform query as further check that the resource is working as expected
+    ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_no_params())),
+    ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_with_params())),
+    ?assertMatch(
+        {ok, _, [[1]]},
+        emqx_resource:query(
+            ResourceId,
+            test_query_with_params_and_timeout()
+        )
+    ),
+    ?assertEqual(ok, emqx_resource:stop(ResourceId)),
+    % Resource will be listed still, but state will be changed and healthcheck will fail
+    % as the worker no longer exists.
+    {ok, ?CONNECTOR_RESOURCE_GROUP, #{
+        state := State,
+        status := StoppedStatus
+    }} =
+        emqx_resource:get_instance(ResourceId),
+    ?assertEqual(stopped, StoppedStatus),
+    ?assertEqual({error, resource_is_stopped}, emqx_resource:health_check(ResourceId)),
+    % Resource healthcheck shortcuts things by checking ets. Go deeper by checking pool itself.
+    ?assertEqual({error, not_found}, ecpool:stop_sup_pool(PoolName)),
+    % Can call stop/1 again on an already stopped instance
+    ?assertEqual(ok, emqx_resource:stop(ResourceId)),
+    % Make sure it can be restarted and the healthchecks and queries work properly
+    ?assertEqual(ok, emqx_resource:restart(ResourceId)),
+    % async restart, need to wait resource
+    timer:sleep(500),
+    {ok, ?CONNECTOR_RESOURCE_GROUP, #{status := InitialStatus}} =
+        emqx_resource:get_instance(ResourceId),
+    ?assertEqual({ok, connected}, emqx_resource:health_check(ResourceId)),
+    ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_no_params())),
+    ?assertMatch({ok, _, [[1]]}, emqx_resource:query(ResourceId, test_query_with_params())),
+    ?assertMatch(
+        {ok, _, [[1]]},
+        emqx_resource:query(
+            ResourceId,
+            test_query_with_params_and_timeout()
+        )
+    ),
+    % Stop and remove the resource in one go.
+    ?assertEqual(ok, emqx_resource:remove_local(ResourceId)),
+    ?assertEqual({error, not_found}, ecpool:stop_sup_pool(PoolName)),
+    % Should not even be able to get the resource data out of ets now unlike just stopping.
+    ?assertEqual({error, not_found}, emqx_resource:get_instance(ResourceId)).
+
+% %%------------------------------------------------------------------------------
+% %% Helpers
+% %%------------------------------------------------------------------------------
+
+ldap_config() ->
+    RawConfig = list_to_binary(
+        io_lib:format(
+            ""
+            "\n"
+            "    auto_reconnect = true\n"
+            "    database = mqtt\n"
+            "    username= root\n"
+            "    password = public\n"
+            "    pool_size = 8\n"
+            "    server = \"~s:~b\"\n"
+            "    "
+            "",
+            [?MYSQL_HOST, ?MYSQL_DEFAULT_PORT]
+        )
+    ),
+
+    {ok, Config} = hocon:binary(RawConfig),
+    #{<<"config">> => Config}.
+
+test_query_no_params() ->
+    {sql, <<"SELECT 1">>}.
+
+test_query_with_params() ->
+    {sql, <<"SELECT ?">>, [1]}.
+
+test_query_with_params_and_timeout() ->
+    {sql, <<"SELECT ?">>, [1], 1000}.

+ 25 - 0
rel/i18n/emqx_ldap.hocon

@@ -0,0 +1,25 @@
+emqx_ldap {
+
+server.desc:
+"""The IPv4 or IPv6 address or the hostname to connect to.<br/>
+A host entry has the following form: `Host[:Port]`.<br/>
+The MySQL default port 3306 is used if `[:Port]` is not specified."""
+
+server.label:
+"""Server Host"""
+
+base_object.desc:
+"""The name of the base object entry (or possibly the root) relative to
+which the Search is to be performed."""
+
+base_object.label:
+"""Base Object"""
+
+filter.desc:
+"""The filter that defines the conditions that must be fulfilled in order
+for the Search to match a given entry."""
+
+filter.label:
+"""Filter"""
+
+}