Przeglądaj źródła

Merge pull request #10128 from thalesmg/ocsp-v50-mkII

feat: add ocsp stapling support to mqtt ssl listener (5.0)
Thales Macedo Garitezi 2 lat temu
rodzic
commit
91a57faa95
42 zmienionych plików z 2266 dodań i 112 usunięć
  1. 57 0
      apps/emqx/i18n/emqx_schema_i18n.conf
  2. 2 1
      apps/emqx/rebar.config
  3. 9 0
      apps/emqx/src/emqx_config.erl
  4. 24 0
      apps/emqx/src/emqx_const_v1.erl
  5. 2 1
      apps/emqx/src/emqx_kernel_sup.erl
  6. 11 2
      apps/emqx/src/emqx_listeners.erl
  7. 532 0
      apps/emqx/src/emqx_ocsp_cache.erl
  8. 123 9
      apps/emqx/src/emqx_schema.erl
  9. 57 37
      apps/emqx/src/emqx_tls_lib.erl
  10. 1 0
      apps/emqx/test/emqx_SUITE.erl
  11. 34 13
      apps/emqx/test/emqx_common_test_helpers.erl
  12. 944 0
      apps/emqx/test/emqx_ocsp_cache_SUITE.erl
  13. 68 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/ca.pem
  14. 52 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/client.key
  15. 38 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/client.pem
  16. 6 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/index.txt
  17. 52 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/ocsp-issuer.key
  18. 34 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/ocsp-issuer.pem
  19. 14 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/openssl_listeners.conf
  20. 28 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/server.key
  21. 35 0
      apps/emqx/test/emqx_ocsp_cache_SUITE_data/server.pem
  22. 40 0
      apps/emqx/test/emqx_schema_tests.erl
  23. 42 13
      apps/emqx/test/emqx_tls_lib_tests.erl
  24. 1 1
      apps/emqx_authz/test/emqx_authz_file_SUITE.erl
  25. 1 1
      apps/emqx_authz/test/emqx_authz_http_SUITE.erl
  26. 1 1
      apps/emqx_authz/test/emqx_authz_mnesia_SUITE.erl
  27. 1 1
      apps/emqx_authz/test/emqx_authz_mongodb_SUITE.erl
  28. 1 1
      apps/emqx_authz/test/emqx_authz_mysql_SUITE.erl
  29. 1 1
      apps/emqx_authz/test/emqx_authz_postgresql_SUITE.erl
  30. 1 1
      apps/emqx_authz/test/emqx_authz_redis_SUITE.erl
  31. 2 0
      apps/emqx_bridge/test/emqx_bridge_webhook_SUITE.erl
  32. 1 1
      apps/emqx_dashboard/src/emqx_dashboard_listener.erl
  33. 12 3
      apps/emqx_exhook/test/emqx_exhook_SUITE.erl
  34. 1 1
      apps/emqx_gateway/test/emqx_gateway_authn_SUITE.erl
  35. 6 3
      apps/emqx_management/test/emqx_mgmt_api_test_util.erl
  36. 25 18
      apps/emqx_resource/test/emqx_resource_SUITE.erl
  37. 1 1
      apps/emqx_retainer/rebar.config
  38. 1 0
      changes/ce/feat-10128.en.md
  39. 1 0
      changes/ce/feat-10128.zh.md
  40. 1 1
      mix.exs
  41. 1 1
      rebar.config
  42. 2 0
      scripts/spellcheck/dicts/emqx.txt

+ 57 - 0
apps/emqx/i18n/emqx_schema_i18n.conf

@@ -1753,6 +1753,63 @@ server_ssl_opts_schema_gc_after_handshake {
     }
 }
 
+server_ssl_opts_schema_enable_ocsp_stapling {
+    desc {
+        en: "Whether to enable Online Certificate Status Protocol (OCSP) stapling for the listener."
+            "  If set to true, requires defining the OCSP responder URL and issuer PEM path."
+        zh: "是否为监听器启用 OCSP Stapling 功能。 如果设置为 true,"
+            "需要定义 OCSP Responder 的 URL 和证书签发者的 PEM 文件路径。"
+    }
+    label: {
+        en: "Enable OCSP Stapling"
+        zh: "启用 OCSP Stapling"
+    }
+}
+
+server_ssl_opts_schema_ocsp_responder_url {
+    desc {
+        en: "URL for the OCSP responder to check the server certificate against."
+        zh: "用于检查服务器证书的 OCSP Responder 的 URL。"
+    }
+    label: {
+        en: "OCSP Responder URL"
+        zh: "OCSP Responder 的 URL"
+    }
+}
+
+server_ssl_opts_schema_ocsp_issuer_pem {
+    desc {
+        en: "PEM-encoded certificate of the OCSP issuer for the server certificate."
+        zh: "服务器证书的 OCSP 签发者的 PEM 编码证书。"
+    }
+    label: {
+        en: "OCSP Issuer Certificate"
+        zh: "OCSP 签发者证书"
+    }
+}
+
+server_ssl_opts_schema_ocsp_refresh_interval {
+    desc {
+        en: "The period to refresh the OCSP response for the server."
+        zh: "为服务器刷新OCSP响应的周期。"
+    }
+    label: {
+        en: "OCSP Refresh Interval"
+        zh: "OCSP 刷新间隔"
+    }
+}
+
+server_ssl_opts_schema_ocsp_refresh_http_timeout {
+    desc {
+        en: "The timeout for the HTTP request when checking OCSP responses."
+        zh: "检查 OCSP 响应时,HTTP 请求的超时。"
+    }
+    label: {
+        en: "OCSP Refresh HTTP Timeout"
+        zh: "OCSP 刷新 HTTP 超时"
+    }
+}
+
 fields_listeners_tcp {
     desc {
         en: """TCP listeners."""

+ 2 - 1
apps/emqx/rebar.config

@@ -30,6 +30,7 @@
     {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.14.5"}}},
     {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.8.1"}}},
     {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.37.0"}}},
+    {emqx_http_lib, {git, "https://github.com/emqx/emqx_http_lib.git", {tag, "0.5.2"}}},
     {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}},
     {recon, {git, "https://github.com/ferd/recon", {tag, "2.5.1"}}},
     {snabbkaffe, {git, "https://github.com/kafka4beam/snabbkaffe.git", {tag, "1.0.0"}}}
@@ -43,7 +44,7 @@
             {meck, "0.9.2"},
             {proper, "1.4.0"},
             {bbmustache, "1.10.0"},
-            {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.8.2"}}}
+            {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.8.5"}}}
         ]},
         {extra_src_dirs, [{"test", [recursive]}]}
     ]}

+ 9 - 0
apps/emqx/src/emqx_config.erl

@@ -87,6 +87,10 @@
     remove_handlers/0
 ]).
 
+-ifdef(TEST).
+-export([erase_schema_mod_and_names/0]).
+-endif.
+
 -include("logger.hrl").
 -include_lib("hocon/include/hoconsc.hrl").
 
@@ -501,6 +505,11 @@ save_schema_mod_and_names(SchemaMod) ->
         names => lists:usort(OldNames ++ RootNames)
     }).
 
+-ifdef(TEST).
+erase_schema_mod_and_names() ->
+    persistent_term:erase(?PERSIS_SCHEMA_MODS).
+-endif.
+
 -spec get_schema_mod() -> #{binary() => atom()}.
 get_schema_mod() ->
     maps:get(mods, persistent_term:get(?PERSIS_SCHEMA_MODS, #{mods => #{}})).

+ 24 - 0
apps/emqx/src/emqx_const_v1.erl

@@ -0,0 +1,24 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-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.
+%%
+%% @doc Never update this module, create a v2 instead.
+%%--------------------------------------------------------------------
+
+-module(emqx_const_v1).
+
+-export([make_sni_fun/1]).
+
+make_sni_fun(ListenerID) ->
+    fun(SN) -> emqx_ocsp_cache:sni_fun(SN, ListenerID) end.

+ 2 - 1
apps/emqx/src/emqx_kernel_sup.erl

@@ -35,7 +35,8 @@ init([]) ->
             child_spec(emqx_hooks, worker),
             child_spec(emqx_stats, worker),
             child_spec(emqx_metrics, worker),
-            child_spec(emqx_authn_authz_metrics_sup, supervisor)
+            child_spec(emqx_authn_authz_metrics_sup, supervisor),
+            child_spec(emqx_ocsp_cache, worker)
         ]
     }}.
 

+ 11 - 2
apps/emqx/src/emqx_listeners.erl

@@ -484,8 +484,12 @@ esockd_opts(ListenerId, Type, Opts0) ->
     },
     maps:to_list(
         case Type of
-            tcp -> Opts3#{tcp_options => tcp_opts(Opts0)};
-            ssl -> Opts3#{ssl_options => ssl_opts(Opts0), tcp_options => tcp_opts(Opts0)}
+            tcp ->
+                Opts3#{tcp_options => tcp_opts(Opts0)};
+            ssl ->
+                OptsWithSNI = inject_sni_fun(ListenerId, Opts0),
+                SSLOpts = ssl_opts(OptsWithSNI),
+                Opts3#{ssl_options => SSLOpts, tcp_options => tcp_opts(Opts0)}
         end
     ).
 
@@ -785,3 +789,8 @@ quic_listener_optional_settings() ->
         max_binding_stateless_operations,
         stateless_operation_expiration_ms
     ].
+
+inject_sni_fun(ListenerId, Conf = #{ssl_options := #{ocsp := #{enable_ocsp_stapling := true}}}) ->
+    emqx_ocsp_cache:inject_sni_fun(ListenerId, Conf);
+inject_sni_fun(_ListenerId, Conf) ->
+    Conf.

+ 532 - 0
apps/emqx/src/emqx_ocsp_cache.erl

@@ -0,0 +1,532 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-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.
+%%
+%% @doc EMQX OCSP cache.
+%%--------------------------------------------------------------------
+
+-module(emqx_ocsp_cache).
+
+-include("logger.hrl").
+-include_lib("public_key/include/public_key.hrl").
+-include_lib("ssl/src/ssl_handshake.hrl").
+-include_lib("snabbkaffe/include/snabbkaffe.hrl").
+
+-behaviour(gen_server).
+
+-export([
+    start_link/0,
+    sni_fun/2,
+    fetch_response/1,
+    register_listener/2,
+    inject_sni_fun/2
+]).
+
+%% gen_server API
+-export([
+    init/1,
+    handle_call/3,
+    handle_cast/2,
+    handle_info/2,
+    code_change/3
+]).
+
+%% internal export; only for mocking in tests
+-export([http_get/2]).
+
+-define(CACHE_TAB, ?MODULE).
+-define(CALL_TIMEOUT, 20_000).
+-define(RETRY_TIMEOUT, 5_000).
+-define(REFRESH_TIMER(LID), {refresh_timer, LID}).
+-ifdef(TEST).
+-define(MIN_REFRESH_INTERVAL, timer:seconds(5)).
+-else.
+-define(MIN_REFRESH_INTERVAL, timer:minutes(1)).
+-endif.
+
+%% Allow usage of OTP certificate record fields (camelCase).
+-elvis([
+    {elvis_style, atom_naming_convention, #{
+        regex => "^([a-z][a-z0-9]*_?)([a-zA-Z0-9]*_?)*$",
+        enclosed_atoms => ".*"
+    }}
+]).
+
+%%--------------------------------------------------------------------
+%% API
+%%--------------------------------------------------------------------
+
+start_link() ->
+    gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
+
+sni_fun(_ServerName, ListenerID) ->
+    Res =
+        try
+            fetch_response(ListenerID)
+        catch
+            _:_ -> error
+        end,
+    case Res of
+        {ok, Response} ->
+            [
+                {certificate_status, #certificate_status{
+                    status_type = ?CERTIFICATE_STATUS_TYPE_OCSP,
+                    response = Response
+                }}
+            ];
+        error ->
+            []
+    end.
+
+fetch_response(ListenerID) ->
+    case do_lookup(ListenerID) of
+        {ok, DERResponse} ->
+            {ok, DERResponse};
+        {error, invalid_listener_id} ->
+            error;
+        {error, not_cached} ->
+            ?tp(ocsp_cache_miss, #{listener_id => ListenerID}),
+            ?SLOG(debug, #{
+                msg => "fetching_new_ocsp_response",
+                listener_id => ListenerID
+            }),
+            http_fetch(ListenerID)
+    end.
+
+register_listener(ListenerID, Opts) ->
+    gen_server:call(?MODULE, {register_listener, ListenerID, Opts}, ?CALL_TIMEOUT).
+
+-spec inject_sni_fun(emqx_listeners:listener_id(), map()) -> map().
+inject_sni_fun(ListenerID, Conf0) ->
+    SNIFun = emqx_const_v1:make_sni_fun(ListenerID),
+    Conf = emqx_map_lib:deep_merge(Conf0, #{ssl_options => #{sni_fun => SNIFun}}),
+    ok = ?MODULE:register_listener(ListenerID, Conf),
+    Conf.
+
+%%--------------------------------------------------------------------
+%% gen_server behaviour
+%%--------------------------------------------------------------------
+
+init(_Args) ->
+    logger:set_process_metadata(#{domain => [emqx, ocsp, cache]}),
+    emqx_tables:new(?CACHE_TAB, [
+        named_table,
+        public,
+        {heir, whereis(emqx_kernel_sup), none},
+        {read_concurrency, true}
+    ]),
+    ?tp(ocsp_cache_init, #{}),
+    {ok, #{}}.
+
+handle_call({http_fetch, ListenerID}, _From, State) ->
+    case do_lookup(ListenerID) of
+        {ok, DERResponse} ->
+            {reply, {ok, DERResponse}, State};
+        {error, invalid_listener_id} ->
+            {reply, error, State};
+        {error, not_cached} ->
+            Conf = undefined,
+            with_refresh_params(ListenerID, Conf, {reply, error, State}, fun(Params) ->
+                case do_http_fetch_and_cache(ListenerID, Params) of
+                    error -> {reply, error, ensure_timer(ListenerID, State, ?RETRY_TIMEOUT)};
+                    {ok, Response} -> {reply, {ok, Response}, ensure_timer(ListenerID, State)}
+                end
+            end)
+    end;
+handle_call({register_listener, ListenerID, Conf}, _From, State0) ->
+    ?SLOG(debug, #{
+        msg => "registering_ocsp_cache",
+        listener_id => ListenerID
+    }),
+    RefreshInterval0 = emqx_map_lib:deep_get([ssl_options, ocsp, refresh_interval], Conf),
+    RefreshInterval = max(RefreshInterval0, ?MIN_REFRESH_INTERVAL),
+    State = State0#{{refresh_interval, ListenerID} => RefreshInterval},
+    %% we need to pass the config along because this might be called
+    %% during the listener's `post_config_update', hence the config is
+    %% not yet "commited" and accessible when we need it.
+    Message = {refresh, ListenerID, Conf},
+    {reply, ok, ensure_timer(ListenerID, Message, State, 0)};
+handle_call(Call, _From, State) ->
+    {reply, {error, {unknown_call, Call}}, State}.
+
+handle_cast(_Cast, State) ->
+    {noreply, State}.
+
+handle_info({timeout, TRef, {refresh, ListenerID}}, State0) ->
+    case maps:get(?REFRESH_TIMER(ListenerID), State0, undefined) of
+        TRef ->
+            ?tp(ocsp_refresh_timer, #{listener_id => ListenerID}),
+            ?SLOG(debug, #{
+                msg => "refreshing_ocsp_response",
+                listener_id => ListenerID
+            }),
+            Conf = undefined,
+            handle_refresh(ListenerID, Conf, State0);
+        _ ->
+            {noreply, State0}
+    end;
+handle_info({timeout, TRef, {refresh, ListenerID, Conf}}, State0) ->
+    case maps:get(?REFRESH_TIMER(ListenerID), State0, undefined) of
+        TRef ->
+            ?tp(ocsp_refresh_timer, #{listener_id => ListenerID}),
+            ?SLOG(debug, #{
+                msg => "refreshing_ocsp_response",
+                listener_id => ListenerID
+            }),
+            handle_refresh(ListenerID, Conf, State0);
+        _ ->
+            {noreply, State0}
+    end;
+handle_info(_Info, State) ->
+    {noreply, State}.
+
+code_change(_Vsn, State, _Extra) ->
+    {ok, State}.
+
+%%--------------------------------------------------------------------
+%% internal functions
+%%--------------------------------------------------------------------
+
+http_fetch(ListenerID) ->
+    %% TODO: configurable call timeout?
+    gen_server:call(?MODULE, {http_fetch, ListenerID}, ?CALL_TIMEOUT).
+
+with_listener_config(ListenerID, ConfPath, ErrorResp, Fn) ->
+    case emqx_listeners:parse_listener_id(ListenerID) of
+        {ok, #{type := Type, name := Name}} ->
+            case emqx_config:get_listener_conf(Type, Name, ConfPath, not_found) of
+                not_found ->
+                    ?SLOG(error, #{
+                        msg => "listener_config_missing",
+                        listener_id => ListenerID
+                    }),
+                    ErrorResp;
+                Config ->
+                    Fn(Config)
+            end;
+        _Err ->
+            ?SLOG(error, #{
+                msg => "listener_id_not_found",
+                listener_id => ListenerID
+            }),
+            ErrorResp
+    end.
+
+cache_key(ListenerID) ->
+    with_listener_config(ListenerID, [ssl_options], error, fun
+        (#{certfile := ServerCertPemPath}) ->
+            #'Certificate'{
+                tbsCertificate =
+                    #'TBSCertificate'{
+                        signature = Signature
+                    }
+            } = read_server_cert(ServerCertPemPath),
+            {ok, {ocsp_response, Signature}};
+        (OtherConfig) ->
+            ?SLOG(error, #{
+                msg => "listener_config_inconsistent",
+                listener_id => ListenerID,
+                config => OtherConfig
+            }),
+            error
+    end).
+
+do_lookup(ListenerID) ->
+    CacheKey = cache_key(ListenerID),
+    case CacheKey of
+        error ->
+            {error, invalid_listener_id};
+        {ok, Key} ->
+            %% Respond immediately if a concurrent call already fetched it.
+            case ets:lookup(?CACHE_TAB, Key) of
+                [{_, DERResponse}] ->
+                    ?tp(ocsp_cache_hit, #{listener_id => ListenerID}),
+                    {ok, DERResponse};
+                [] ->
+                    {error, not_cached}
+            end
+    end.
+
+read_server_cert(ServerCertPemPath0) ->
+    ServerCertPemPath = to_bin(ServerCertPemPath0),
+    case ets:lookup(ssl_pem_cache, ServerCertPemPath) of
+        [{_, [{'Certificate', ServerCertDer, _} | _]}] ->
+            public_key:der_decode('Certificate', ServerCertDer);
+        [] ->
+            case file:read_file(ServerCertPemPath) of
+                {ok, ServerCertPem} ->
+                    [{'Certificate', ServerCertDer, _} | _] =
+                        public_key:pem_decode(ServerCertPem),
+                    public_key:der_decode('Certificate', ServerCertDer);
+                {error, Error1} ->
+                    error({bad_server_cert_file, Error1})
+            end
+    end.
+
+handle_refresh(ListenerID, Conf, State0) ->
+    %% no point in retrying if the config is inconsistent or non
+    %% existent.
+    State1 = maps:without([{refresh_interval, ListenerID}, ?REFRESH_TIMER(ListenerID)], State0),
+    with_refresh_params(ListenerID, Conf, {noreply, State1}, fun(Params) ->
+        case do_http_fetch_and_cache(ListenerID, Params) of
+            error ->
+                ?SLOG(debug, #{
+                    msg => "failed_to_fetch_ocsp_response",
+                    listener_id => ListenerID
+                }),
+                {noreply, ensure_timer(ListenerID, State0, ?RETRY_TIMEOUT)};
+            {ok, _Response} ->
+                ?SLOG(debug, #{
+                    msg => "fetched_ocsp_response",
+                    listener_id => ListenerID
+                }),
+                {noreply, ensure_timer(ListenerID, State0)}
+        end
+    end).
+
+with_refresh_params(ListenerID, Conf, ErrorRet, Fn) ->
+    case get_refresh_params(ListenerID, Conf) of
+        error ->
+            ErrorRet;
+        {ok, Params} ->
+            try
+                Fn(Params)
+            catch
+                Kind:Error ->
+                    ?SLOG(error, #{
+                        msg => "error_fetching_ocsp_response",
+                        listener_id => ListenerID,
+                        error => {Kind, Error}
+                    }),
+                    ErrorRet
+            end
+    end.
+
+get_refresh_params(ListenerID, undefined = _Conf) ->
+    %% during normal periodic refreshes, we read from the emqx config.
+    with_listener_config(ListenerID, [ssl_options], error, fun
+        (
+            #{
+                ocsp := #{
+                    issuer_pem := IssuerPemPath,
+                    responder_url := ResponderURL,
+                    refresh_http_timeout := HTTPTimeout
+                },
+                certfile := ServerCertPemPath
+            }
+        ) ->
+            {ok, #{
+                issuer_pem => IssuerPemPath,
+                responder_url => ResponderURL,
+                refresh_http_timeout => HTTPTimeout,
+                server_certfile => ServerCertPemPath
+            }};
+        (OtherConfig) ->
+            ?SLOG(error, #{
+                msg => "listener_config_inconsistent",
+                listener_id => ListenerID,
+                config => OtherConfig
+            }),
+            error
+    end);
+get_refresh_params(_ListenerID, #{
+    ssl_options := #{
+        ocsp := #{
+            issuer_pem := IssuerPemPath,
+            responder_url := ResponderURL,
+            refresh_http_timeout := HTTPTimeout
+        },
+        certfile := ServerCertPemPath
+    }
+}) ->
+    {ok, #{
+        issuer_pem => IssuerPemPath,
+        responder_url => ResponderURL,
+        refresh_http_timeout => HTTPTimeout,
+        server_certfile => ServerCertPemPath
+    }};
+get_refresh_params(_ListenerID, _Conf) ->
+    error.
+
+do_http_fetch_and_cache(ListenerID, Params) ->
+    #{
+        issuer_pem := IssuerPemPath,
+        responder_url := ResponderURL,
+        refresh_http_timeout := HTTPTimeout,
+        server_certfile := ServerCertPemPath
+    } = Params,
+    IssuerPem =
+        case file:read_file(IssuerPemPath) of
+            {ok, IssuerPem0} -> IssuerPem0;
+            {error, Error0} -> error({bad_issuer_pem_file, Error0})
+        end,
+    ServerCert = read_server_cert(ServerCertPemPath),
+    Request = build_ocsp_request(IssuerPem, ServerCert),
+    ?tp(ocsp_http_fetch, #{
+        listener_id => ListenerID,
+        responder_url => ResponderURL,
+        timeout => HTTPTimeout
+    }),
+    RequestURI = iolist_to_binary([ResponderURL, Request]),
+    Resp = ?MODULE:http_get(RequestURI, HTTPTimeout),
+    case Resp of
+        {ok, {{_, 200, _}, _, Body}} ->
+            ?SLOG(debug, #{
+                msg => "caching_ocsp_response",
+                listener_id => ListenerID
+            }),
+            %% if we got this far, the certfile is correct.
+            {ok, CacheKey} = cache_key(ListenerID),
+            true = ets:insert(?CACHE_TAB, {CacheKey, Body}),
+            ?tp(ocsp_http_fetch_and_cache, #{
+                listener_id => ListenerID,
+                headers => true
+            }),
+            {ok, Body};
+        {ok, {200, Body}} ->
+            ?SLOG(debug, #{
+                msg => "caching_ocsp_response",
+                listener_id => ListenerID
+            }),
+            %% if we got this far, the certfile is correct.
+            {ok, CacheKey} = cache_key(ListenerID),
+            true = ets:insert(?CACHE_TAB, {CacheKey, Body}),
+            ?tp(ocsp_http_fetch_and_cache, #{
+                listener_id => ListenerID,
+                headers => false
+            }),
+            {ok, Body};
+        {ok, {{_, Code, _}, _, Body}} ->
+            ?tp(
+                error,
+                ocsp_http_fetch_bad_code,
+                #{
+                    listener_id => ListenerID,
+                    body => Body,
+                    code => Code,
+                    headers => true
+                }
+            ),
+            ?SLOG(error, #{
+                msg => "error_fetching_ocsp_response",
+                listener_id => ListenerID,
+                code => Code,
+                body => Body
+            }),
+            error;
+        {ok, {Code, Body}} ->
+            ?tp(
+                error,
+                ocsp_http_fetch_bad_code,
+                #{
+                    listener_id => ListenerID,
+                    body => Body,
+                    code => Code,
+                    headers => false
+                }
+            ),
+            ?SLOG(error, #{
+                msg => "error_fetching_ocsp_response",
+                listener_id => ListenerID,
+                code => Code,
+                body => Body
+            }),
+            error;
+        {error, Error} ->
+            ?tp(
+                error,
+                ocsp_http_fetch_error,
+                #{
+                    listener_id => ListenerID,
+                    error => Error
+                }
+            ),
+            ?SLOG(error, #{
+                msg => "error_fetching_ocsp_response",
+                listener_id => ListenerID,
+                error => Error
+            }),
+            error
+    end.
+
+http_get(URL, HTTPTimeout) ->
+    httpc:request(
+        get,
+        {URL, [{"connection", "close"}]},
+        [{timeout, HTTPTimeout}],
+        [{body_format, binary}]
+    ).
+
+ensure_timer(ListenerID, State) ->
+    Timeout = maps:get({refresh_interval, ListenerID}, State, timer:minutes(5)),
+    ensure_timer(ListenerID, State, Timeout).
+
+ensure_timer(ListenerID, State, Timeout) ->
+    ensure_timer(ListenerID, {refresh, ListenerID}, State, Timeout).
+
+ensure_timer(ListenerID, Message, State, Timeout) ->
+    emqx_misc:cancel_timer(maps:get(?REFRESH_TIMER(ListenerID), State, undefined)),
+    State#{
+        ?REFRESH_TIMER(ListenerID) => emqx_misc:start_timer(
+            Timeout,
+            Message
+        )
+    }.
+
+build_ocsp_request(IssuerPem, ServerCert) ->
+    [{'Certificate', IssuerDer, _} | _] = public_key:pem_decode(IssuerPem),
+    #'Certificate'{
+        tbsCertificate =
+            #'TBSCertificate'{
+                serialNumber = SerialNumber,
+                issuer = Issuer
+            }
+    } = ServerCert,
+    #'Certificate'{
+        tbsCertificate =
+            #'TBSCertificate'{
+                subjectPublicKeyInfo =
+                    #'SubjectPublicKeyInfo'{subjectPublicKey = IssuerPublicKeyDer}
+            }
+    } = public_key:der_decode('Certificate', IssuerDer),
+    IssuerDNHash = crypto:hash(sha, public_key:der_encode('Name', Issuer)),
+    IssuerPKHash = crypto:hash(sha, IssuerPublicKeyDer),
+    Req = #'OCSPRequest'{
+        tbsRequest =
+            #'TBSRequest'{
+                version = 0,
+                requestList =
+                    [
+                        #'Request'{
+                            reqCert =
+                                #'CertID'{
+                                    hashAlgorithm =
+                                        #'AlgorithmIdentifier'{
+                                            algorithm = ?'id-sha1',
+                                            %% ???
+                                            parameters = <<5, 0>>
+                                        },
+                                    issuerNameHash = IssuerDNHash,
+                                    issuerKeyHash = IssuerPKHash,
+                                    serialNumber = SerialNumber
+                                }
+                        }
+                    ]
+            }
+    },
+    ReqDer = public_key:der_encode('OCSPRequest', Req),
+    base64:encode_to_string(ReqDer).
+
+to_bin(Str) when is_list(Str) -> list_to_binary(Str);
+to_bin(Bin) when is_binary(Bin) -> Bin.

+ 123 - 9
apps/emqx/src/emqx_schema.erl

@@ -43,6 +43,7 @@
 -type cipher() :: map().
 -type port_number() :: 1..65536.
 -type server_parse_option() :: #{default_port => port_number(), no_port => boolean()}.
+-type url() :: binary().
 
 -typerefl_from_string({duration/0, emqx_schema, to_duration}).
 -typerefl_from_string({duration_s/0, emqx_schema, to_duration_s}).
@@ -56,6 +57,7 @@
 -typerefl_from_string({ip_port/0, emqx_schema, to_ip_port}).
 -typerefl_from_string({cipher/0, emqx_schema, to_erl_cipher_suite}).
 -typerefl_from_string({comma_separated_atoms/0, emqx_schema, to_comma_separated_atoms}).
+-typerefl_from_string({url/0, emqx_schema, to_url}).
 
 -export([
     validate_heap_size/1,
@@ -81,7 +83,8 @@
     to_bar_separated_list/1,
     to_ip_port/1,
     to_erl_cipher_suite/1,
-    to_comma_separated_atoms/1
+    to_comma_separated_atoms/1,
+    to_url/1
 ]).
 
 -export([
@@ -108,7 +111,8 @@
     bar_separated_list/0,
     ip_port/0,
     cipher/0,
-    comma_separated_atoms/0
+    comma_separated_atoms/0,
+    url/0
 ]).
 
 -export([namespace/0, roots/0, roots/1, fields/1, desc/1, tags/0]).
@@ -810,7 +814,7 @@ fields("mqtt_ssl_listener") ->
             {"ssl_options",
                 sc(
                     ref("listener_ssl_opts"),
-                    #{}
+                    #{validator => fun mqtt_ssl_listener_ssl_options_validator/1}
                 )}
         ];
 fields("mqtt_ws_listener") ->
@@ -1294,6 +1298,49 @@ fields("listener_quic_ssl_opts") ->
     );
 fields("ssl_client_opts") ->
     client_ssl_opts_schema(#{});
+fields("ocsp") ->
+    [
+        {"enable_ocsp_stapling",
+            sc(
+                boolean(),
+                #{
+                    default => false,
+                    desc => ?DESC("server_ssl_opts_schema_enable_ocsp_stapling")
+                }
+            )},
+        {"responder_url",
+            sc(
+                url(),
+                #{
+                    required => false,
+                    desc => ?DESC("server_ssl_opts_schema_ocsp_responder_url")
+                }
+            )},
+        {"issuer_pem",
+            sc(
+                binary(),
+                #{
+                    required => false,
+                    desc => ?DESC("server_ssl_opts_schema_ocsp_issuer_pem")
+                }
+            )},
+        {"refresh_interval",
+            sc(
+                duration(),
+                #{
+                    default => <<"5m">>,
+                    desc => ?DESC("server_ssl_opts_schema_ocsp_refresh_interval")
+                }
+            )},
+        {"refresh_http_timeout",
+            sc(
+                duration(),
+                #{
+                    default => <<"15s">>,
+                    desc => ?DESC("server_ssl_opts_schema_ocsp_refresh_http_timeout")
+                }
+            )}
+    ];
 fields("deflate_opts") ->
     [
         {"level",
@@ -2017,6 +2064,8 @@ desc("trace") ->
     "Real-time filtering logs for the ClientID or Topic or IP for debugging.";
 desc("shared_subscription_group") ->
     "Per group dispatch strategy for shared subscription";
+desc("ocsp") ->
+    "Per listener OCSP Stapling configuration.";
 desc(_) ->
     undefined.
 
@@ -2199,14 +2248,62 @@ server_ssl_opts_schema(Defaults, IsRanchListener) ->
                 )}
         ] ++
         [
-            {"gc_after_handshake",
-                sc(boolean(), #{
-                    default => false,
-                    desc => ?DESC(server_ssl_opts_schema_gc_after_handshake)
-                })}
-         || not IsRanchListener
+            Field
+         || not IsRanchListener,
+            Field <- [
+                {"gc_after_handshake",
+                    sc(boolean(), #{
+                        default => false,
+                        desc => ?DESC(server_ssl_opts_schema_gc_after_handshake)
+                    })},
+                {"ocsp",
+                    sc(
+                        ref("ocsp"),
+                        #{
+                            required => false,
+                            validator => fun ocsp_inner_validator/1
+                        }
+                    )}
+            ]
         ].
 
+mqtt_ssl_listener_ssl_options_validator(Conf) ->
+    Checks = [
+        fun ocsp_outer_validator/1
+    ],
+    case emqx_misc:pipeline(Checks, Conf, not_used) of
+        {ok, _, _} ->
+            ok;
+        {error, Reason, _NotUsed} ->
+            {error, Reason}
+    end.
+
+ocsp_outer_validator(#{<<"ocsp">> := #{<<"enable_ocsp_stapling">> := true}} = Conf) ->
+    %% outer mqtt listener ssl server config
+    ServerCertPemPath = maps:get(<<"certfile">>, Conf, undefined),
+    case ServerCertPemPath of
+        undefined ->
+            {error, "Server certificate must be defined when using OCSP stapling"};
+        _ ->
+            %% check if issuer pem is readable and/or valid?
+            ok
+    end;
+ocsp_outer_validator(_Conf) ->
+    ok.
+
+ocsp_inner_validator(#{enable_ocsp_stapling := _} = Conf) ->
+    ocsp_inner_validator(emqx_map_lib:binary_key_map(Conf));
+ocsp_inner_validator(#{<<"enable_ocsp_stapling">> := false} = _Conf) ->
+    ok;
+ocsp_inner_validator(#{<<"enable_ocsp_stapling">> := true} = Conf) ->
+    assert_required_field(
+        Conf, <<"responder_url">>, "The responder URL is required for OCSP stapling"
+    ),
+    assert_required_field(
+        Conf, <<"issuer_pem">>, "The issuer PEM path is required for OCSP stapling"
+    ),
+    ok.
+
 %% @doc Make schema for SSL client.
 -spec client_ssl_opts_schema(map()) -> hocon_schema:field_schema().
 client_ssl_opts_schema(Defaults) ->
@@ -2408,6 +2505,15 @@ to_comma_separated_binary(Str) ->
 to_comma_separated_atoms(Str) ->
     {ok, lists:map(fun to_atom/1, string:tokens(Str, ", "))}.
 
+to_url(Str) ->
+    case emqx_http_lib:uri_parse(Str) of
+        {ok, URIMap} ->
+            URIString = emqx_http_lib:normalize(URIMap),
+            {ok, iolist_to_binary(URIString)};
+        Error ->
+            Error
+    end.
+
 to_bar_separated_list(Str) ->
     {ok, string:tokens(Str, "| ")}.
 
@@ -2865,3 +2971,11 @@ is_quic_ssl_opts(Name) ->
         %% , "handshake_timeout"
         %% , "gc_after_handshake"
     ]).
+
+assert_required_field(Conf, Key, ErrorMessage) ->
+    case maps:get(Key, Conf, undefined) of
+        undefined ->
+            throw(ErrorMessage);
+        _ ->
+            ok
+    end.

+ 57 - 37
apps/emqx/src/emqx_tls_lib.erl

@@ -47,8 +47,18 @@
 -define(IS_TRUE(Val), ((Val =:= true) orelse (Val =:= <<"true">>))).
 -define(IS_FALSE(Val), ((Val =:= false) orelse (Val =:= <<"false">>))).
 
--define(SSL_FILE_OPT_NAMES, [<<"keyfile">>, <<"certfile">>, <<"cacertfile">>]).
--define(SSL_FILE_OPT_NAMES_A, [keyfile, certfile, cacertfile]).
+-define(SSL_FILE_OPT_PATHS, [
+    [<<"keyfile">>],
+    [<<"certfile">>],
+    [<<"cacertfile">>],
+    [<<"ocsp">>, <<"issuer_pem">>]
+]).
+-define(SSL_FILE_OPT_PATHS_A, [
+    [keyfile],
+    [certfile],
+    [cacertfile],
+    [ocsp, issuer_pem]
+]).
 
 %% non-empty string
 -define(IS_STRING(L), (is_list(L) andalso L =/= [] andalso is_integer(hd(L)))).
@@ -298,20 +308,20 @@ ensure_ssl_files(Dir, SSL, Opts) ->
     RequiredKeys = maps:get(required_keys, Opts, []),
     case ensure_ssl_file_key(SSL, RequiredKeys) of
         ok ->
-            Keys = ?SSL_FILE_OPT_NAMES ++ ?SSL_FILE_OPT_NAMES_A,
-            ensure_ssl_files(Dir, SSL, Keys, Opts);
+            KeyPaths = ?SSL_FILE_OPT_PATHS ++ ?SSL_FILE_OPT_PATHS_A,
+            ensure_ssl_files(Dir, SSL, KeyPaths, Opts);
         {error, _} = Error ->
             Error
     end.
 
 ensure_ssl_files(_Dir, SSL, [], _Opts) ->
     {ok, SSL};
-ensure_ssl_files(Dir, SSL, [Key | Keys], Opts) ->
-    case ensure_ssl_file(Dir, Key, SSL, maps:get(Key, SSL, undefined), Opts) of
+ensure_ssl_files(Dir, SSL, [KeyPath | KeyPaths], Opts) ->
+    case ensure_ssl_file(Dir, KeyPath, SSL, emqx_map_lib:deep_get(KeyPath, SSL, undefined), Opts) of
         {ok, NewSSL} ->
-            ensure_ssl_files(Dir, NewSSL, Keys, Opts);
+            ensure_ssl_files(Dir, NewSSL, KeyPaths, Opts);
         {error, Reason} ->
-            {error, Reason#{which_options => [Key]}}
+            {error, Reason#{which_options => [KeyPath]}}
     end.
 
 %% @doc Compare old and new config, delete the ones in old but not in new.
@@ -321,12 +331,12 @@ delete_ssl_files(Dir, NewOpts0, OldOpts0) ->
     {ok, NewOpts} = ensure_ssl_files(Dir, NewOpts0, #{dry_run => DryRun}),
     {ok, OldOpts} = ensure_ssl_files(Dir, OldOpts0, #{dry_run => DryRun}),
     Get = fun
-        (_K, undefined) -> undefined;
-        (K, Opts) -> maps:get(K, Opts, undefined)
+        (_KP, undefined) -> undefined;
+        (KP, Opts) -> emqx_map_lib:deep_get(KP, Opts, undefined)
     end,
     lists:foreach(
-        fun(Key) -> delete_old_file(Get(Key, NewOpts), Get(Key, OldOpts)) end,
-        ?SSL_FILE_OPT_NAMES ++ ?SSL_FILE_OPT_NAMES_A
+        fun(KeyPath) -> delete_old_file(Get(KeyPath, NewOpts), Get(KeyPath, OldOpts)) end,
+        ?SSL_FILE_OPT_PATHS ++ ?SSL_FILE_OPT_PATHS_A
     ),
     %% try to delete the dir if it is empty
     _ = file:del_dir(pem_dir(Dir)),
@@ -346,29 +356,33 @@ delete_old_file(_New, Old) ->
             ?SLOG(error, #{msg => "failed_to_delete_ssl_file", file_path => Old, reason => Reason})
     end.
 
-ensure_ssl_file(_Dir, _Key, SSL, undefined, _Opts) ->
+ensure_ssl_file(_Dir, _KeyPath, SSL, undefined, _Opts) ->
     {ok, SSL};
-ensure_ssl_file(Dir, Key, SSL, MaybePem, Opts) ->
+ensure_ssl_file(Dir, KeyPath, SSL, MaybePem, Opts) ->
     case is_valid_string(MaybePem) of
         true ->
             DryRun = maps:get(dry_run, Opts, false),
-            do_ensure_ssl_file(Dir, Key, SSL, MaybePem, DryRun);
+            do_ensure_ssl_file(Dir, KeyPath, SSL, MaybePem, DryRun);
         false ->
             {error, #{reason => invalid_file_path_or_pem_string}}
     end.
 
-do_ensure_ssl_file(Dir, Key, SSL, MaybePem, DryRun) ->
+do_ensure_ssl_file(Dir, KeyPath, SSL, MaybePem, DryRun) ->
     case is_pem(MaybePem) of
         true ->
-            case save_pem_file(Dir, Key, MaybePem, DryRun) of
-                {ok, Path} -> {ok, SSL#{Key => Path}};
-                {error, Reason} -> {error, Reason}
+            case save_pem_file(Dir, KeyPath, MaybePem, DryRun) of
+                {ok, Path} ->
+                    NewSSL = emqx_map_lib:deep_put(KeyPath, SSL, Path),
+                    {ok, NewSSL};
+                {error, Reason} ->
+                    {error, Reason}
             end;
         false ->
             case is_valid_pem_file(MaybePem) of
                 true ->
                     {ok, SSL};
-                {error, enoent} when DryRun -> {ok, SSL};
+                {error, enoent} when DryRun ->
+                    {ok, SSL};
                 {error, Reason} ->
                     {error, #{
                         pem_check => invalid_pem,
@@ -398,8 +412,8 @@ is_pem(MaybePem) ->
 %% To make it simple, the file is always overwritten.
 %% Also a potentially half-written PEM file (e.g. due to power outage)
 %% can be corrected with an overwrite.
-save_pem_file(Dir, Key, Pem, DryRun) ->
-    Path = pem_file_name(Dir, Key, Pem),
+save_pem_file(Dir, KeyPath, Pem, DryRun) ->
+    Path = pem_file_name(Dir, KeyPath, Pem),
     case filelib:ensure_dir(Path) of
         ok when DryRun ->
             {ok, Path};
@@ -422,11 +436,14 @@ is_generated_file(Filename) ->
         _ -> false
     end.
 
-pem_file_name(Dir, Key, Pem) ->
+pem_file_name(Dir, KeyPath, Pem) ->
     <<CK:8/binary, _/binary>> = crypto:hash(md5, Pem),
     Suffix = hex_str(CK),
-    FileName = binary:replace(ensure_bin(Key), <<"file">>, <<"-", Suffix/binary>>),
-    filename:join([pem_dir(Dir), FileName]).
+    Segments = lists:map(fun ensure_bin/1, KeyPath),
+    Filename0 = iolist_to_binary(lists:join(<<"_">>, Segments)),
+    Filename1 = binary:replace(Filename0, <<"file">>, <<>>),
+    Filename = <<Filename1/binary, "-", Suffix/binary>>,
+    filename:join([pem_dir(Dir), Filename]).
 
 pem_dir(Dir) ->
     filename:join([emqx:mutable_certs_dir(), Dir]).
@@ -465,24 +482,26 @@ is_valid_pem_file(Path) ->
 %% so they are forced to upload a cert file, or use an existing file path.
 -spec drop_invalid_certs(map()) -> map().
 drop_invalid_certs(#{enable := False} = SSL) when ?IS_FALSE(False) ->
-    maps:without(?SSL_FILE_OPT_NAMES_A, SSL);
+    lists:foldl(fun emqx_map_lib:deep_remove/2, SSL, ?SSL_FILE_OPT_PATHS_A);
 drop_invalid_certs(#{<<"enable">> := False} = SSL) when ?IS_FALSE(False) ->
-    maps:without(?SSL_FILE_OPT_NAMES, SSL);
+    lists:foldl(fun emqx_map_lib:deep_remove/2, SSL, ?SSL_FILE_OPT_PATHS);
 drop_invalid_certs(#{enable := True} = SSL) when ?IS_TRUE(True) ->
-    do_drop_invalid_certs(?SSL_FILE_OPT_NAMES_A, SSL);
+    do_drop_invalid_certs(?SSL_FILE_OPT_PATHS_A, SSL);
 drop_invalid_certs(#{<<"enable">> := True} = SSL) when ?IS_TRUE(True) ->
-    do_drop_invalid_certs(?SSL_FILE_OPT_NAMES, SSL).
+    do_drop_invalid_certs(?SSL_FILE_OPT_PATHS, SSL).
 
 do_drop_invalid_certs([], SSL) ->
     SSL;
-do_drop_invalid_certs([Key | Keys], SSL) ->
-    case maps:get(Key, SSL, undefined) of
+do_drop_invalid_certs([KeyPath | KeyPaths], SSL) ->
+    case emqx_map_lib:deep_get(KeyPath, SSL, undefined) of
         undefined ->
-            do_drop_invalid_certs(Keys, SSL);
+            do_drop_invalid_certs(KeyPaths, SSL);
         PemOrPath ->
             case is_pem(PemOrPath) orelse is_valid_pem_file(PemOrPath) of
-                true -> do_drop_invalid_certs(Keys, SSL);
-                {error, _} -> do_drop_invalid_certs(Keys, maps:without([Key], SSL))
+                true ->
+                    do_drop_invalid_certs(KeyPaths, SSL);
+                {error, _} ->
+                    do_drop_invalid_certs(KeyPaths, emqx_map_lib:deep_remove(KeyPath, SSL))
             end
     end.
 
@@ -565,9 +584,10 @@ ensure_bin(A) when is_atom(A) -> atom_to_binary(A, utf8).
 
 ensure_ssl_file_key(_SSL, []) ->
     ok;
-ensure_ssl_file_key(SSL, RequiredKeys) ->
-    Filter = fun(Key) -> not maps:is_key(Key, SSL) end,
-    case lists:filter(Filter, RequiredKeys) of
+ensure_ssl_file_key(SSL, RequiredKeyPaths) ->
+    NotFoundRef = make_ref(),
+    Filter = fun(KeyPath) -> NotFoundRef =:= emqx_map_lib:deep_get(KeyPath, SSL, NotFoundRef) end,
+    case lists:filter(Filter, RequiredKeyPaths) of
         [] -> ok;
         Miss -> {error, #{reason => ssl_file_option_not_found, which_options => Miss}}
     end.

+ 1 - 0
apps/emqx/test/emqx_SUITE.erl

@@ -26,6 +26,7 @@
 all() -> emqx_common_test_helpers:all(?MODULE).
 
 init_per_suite(Config) ->
+    emqx_common_test_helpers:boot_modules(all),
     emqx_common_test_helpers:start_apps([]),
     Config.
 

+ 34 - 13
apps/emqx/test/emqx_common_test_helpers.erl

@@ -16,6 +16,8 @@
 
 -module(emqx_common_test_helpers).
 
+-include("emqx_authentication.hrl").
+
 -type special_config_handler() :: fun().
 
 -type apps() :: list(atom()).
@@ -27,6 +29,7 @@
     boot_modules/1,
     start_apps/1,
     start_apps/2,
+    start_apps/3,
     stop_apps/1,
     reload/2,
     app_path/2,
@@ -34,7 +37,8 @@
     deps_path/2,
     flush/0,
     flush/1,
-    render_and_load_app_config/1
+    render_and_load_app_config/1,
+    render_and_load_app_config/2
 ]).
 
 -export([
@@ -183,17 +187,21 @@ start_apps(Apps) ->
             application:set_env(system_monitor, db_hostname, ""),
             ok
         end,
-    start_apps(Apps, DefaultHandler).
+    start_apps(Apps, DefaultHandler, #{}).
 
 -spec start_apps(Apps :: apps(), Handler :: special_config_handler()) -> ok.
 start_apps(Apps, SpecAppConfig) when is_function(SpecAppConfig) ->
+    start_apps(Apps, SpecAppConfig, #{}).
+
+-spec start_apps(Apps :: apps(), Handler :: special_config_handler(), map()) -> ok.
+start_apps(Apps, SpecAppConfig, Opts) when is_function(SpecAppConfig) ->
     %% Load all application code to beam vm first
     %% Because, minirest, ekka etc.. application will scan these modules
     lists:foreach(fun load/1, [emqx | Apps]),
     ok = start_ekka(),
     mnesia:clear_table(emqx_admin),
     ok = emqx_ratelimiter_SUITE:load_conf(),
-    lists:foreach(fun(App) -> start_app(App, SpecAppConfig) end, [emqx | Apps]).
+    lists:foreach(fun(App) -> start_app(App, SpecAppConfig, Opts) end, [emqx | Apps]).
 
 load(App) ->
     case application:load(App) of
@@ -203,27 +211,31 @@ load(App) ->
     end.
 
 render_and_load_app_config(App) ->
+    render_and_load_app_config(App, #{}).
+
+render_and_load_app_config(App, Opts) ->
     load(App),
     Schema = app_schema(App),
-    Conf = app_path(App, filename:join(["etc", app_conf_file(App)])),
+    ConfFilePath = maps:get(conf_file_path, Opts, filename:join(["etc", app_conf_file(App)])),
+    Conf = app_path(App, ConfFilePath),
     try
-        do_render_app_config(App, Schema, Conf)
+        do_render_app_config(App, Schema, Conf, Opts)
     catch
         throw:E:St ->
             %% turn throw into error
             error({Conf, E, St})
     end.
 
-do_render_app_config(App, Schema, ConfigFile) ->
-    Vars = mustache_vars(App),
+do_render_app_config(App, Schema, ConfigFile, Opts) ->
+    Vars = mustache_vars(App, Opts),
     RenderedConfigFile = render_config_file(ConfigFile, Vars),
     read_schema_configs(Schema, RenderedConfigFile),
     force_set_config_file_paths(App, [RenderedConfigFile]),
     copy_certs(App, RenderedConfigFile),
     ok.
 
-start_app(App, SpecAppConfig) ->
-    render_and_load_app_config(App),
+start_app(App, SpecAppConfig, Opts) ->
+    render_and_load_app_config(App, Opts),
     SpecAppConfig(App),
     case application:ensure_all_started(App) of
         {ok, _} ->
@@ -246,12 +258,13 @@ app_schema(App) ->
             no_schema
     end.
 
-mustache_vars(App) ->
+mustache_vars(App, Opts) ->
+    ExtraMustacheVars = maps:get(extra_mustache_vars, Opts, []),
     [
         {platform_data_dir, app_path(App, "data")},
         {platform_etc_dir, app_path(App, "etc")},
         {platform_log_dir, app_path(App, "log")}
-    ].
+    ] ++ ExtraMustacheVars.
 
 render_config_file(ConfigFile, Vars0) ->
     Temp =
@@ -283,6 +296,14 @@ generate_config(SchemaModule, ConfigFile) when is_atom(SchemaModule) ->
 -spec stop_apps(list()) -> ok.
 stop_apps(Apps) ->
     [application:stop(App) || App <- Apps ++ [emqx, ekka, mria, mnesia]],
+    %% to avoid inter-suite flakiness
+    application:unset_env(emqx, init_config_load_done),
+    persistent_term:erase(?EMQX_AUTHENTICATION_SCHEMA_MODULE_PT_KEY),
+    emqx_config:erase_schema_mod_and_names(),
+    ok = emqx_config:delete_override_conf_files(),
+    application:unset_env(emqx, local_override_conf_file),
+    application:unset_env(emqx, cluster_override_conf_file),
+    application:unset_env(gen_rpc, port_discovery),
     ok.
 
 proj_root() ->
@@ -327,7 +348,7 @@ safe_relative_path_2(Path) ->
 -spec reload(App :: atom(), SpecAppConfig :: special_config_handler()) -> ok.
 reload(App, SpecAppConfigHandler) ->
     application:stop(App),
-    start_app(App, SpecAppConfigHandler),
+    start_app(App, SpecAppConfigHandler, #{}),
     application:start(App).
 
 ensure_mnesia_stopped() ->
@@ -469,7 +490,7 @@ is_all_tcp_servers_available(Servers) ->
         {_, []} ->
             true;
         {_, Unavail} ->
-            ct:print("Unavailable servers: ~p", [Unavail]),
+            ct:pal("Unavailable servers: ~p", [Unavail]),
             false
     end.
 

+ 944 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE.erl

@@ -0,0 +1,944 @@
+%%--------------------------------------------------------------------
+%% Copyright (c) 2022-2023 EMQ Technologies Co., Ltd. All Rights Reserved.
+%%--------------------------------------------------------------------
+
+-module(emqx_ocsp_cache_SUITE).
+
+-compile(export_all).
+-compile(nowarn_export_all).
+
+-include_lib("eunit/include/eunit.hrl").
+-include_lib("common_test/include/ct.hrl").
+-include_lib("snabbkaffe/include/snabbkaffe.hrl").
+
+-include_lib("ssl/src/ssl_handshake.hrl").
+
+-define(CACHE_TAB, emqx_ocsp_cache).
+
+all() ->
+    [{group, openssl}] ++ tests().
+
+tests() ->
+    emqx_common_test_helpers:all(?MODULE) -- openssl_tests().
+
+openssl_tests() ->
+    [t_openssl_client].
+
+groups() ->
+    OpensslTests = openssl_tests(),
+    [
+        {openssl, [
+            {group, tls12},
+            {group, tls13}
+        ]},
+        {tls12, [
+            {group, with_status_request},
+            {group, without_status_request}
+        ]},
+        {tls13, [
+            {group, with_status_request},
+            {group, without_status_request}
+        ]},
+        {with_status_request, [], OpensslTests},
+        {without_status_request, [], OpensslTests}
+    ].
+
+init_per_suite(Config) ->
+    application:load(emqx),
+    emqx_config:save_schema_mod_and_names(emqx_schema),
+    emqx_common_test_helpers:boot_modules(all),
+    Config.
+
+end_per_suite(_Config) ->
+    ok.
+
+init_per_group(tls12, Config) ->
+    [{tls_vsn, "-tls1_2"} | Config];
+init_per_group(tls13, Config) ->
+    [{tls_vsn, "-tls1_3"} | Config];
+init_per_group(with_status_request, Config) ->
+    [{status_request, true} | Config];
+init_per_group(without_status_request, Config) ->
+    [{status_request, false} | Config];
+init_per_group(_Group, Config) ->
+    Config.
+
+end_per_group(_Group, _Config) ->
+    ok.
+
+init_per_testcase(t_openssl_client, Config) ->
+    ct:timetrap({seconds, 30}),
+    DataDir = ?config(data_dir, Config),
+    Handler = fun(_) -> ok end,
+    {OCSPResponderPort, OCSPOSPid} = setup_openssl_ocsp(Config),
+    ConfFilePath = filename:join([DataDir, "openssl_listeners.conf"]),
+    emqx_common_test_helpers:start_apps(
+        [],
+        Handler,
+        #{
+            extra_mustache_vars => [{test_data_dir, DataDir}],
+            conf_file_path => ConfFilePath
+        }
+    ),
+    ct:sleep(1_000),
+    [
+        {ocsp_responder_port, OCSPResponderPort},
+        {ocsp_responder_os_pid, OCSPOSPid}
+        | Config
+    ];
+init_per_testcase(TestCase, Config) when
+    TestCase =:= t_update_listener;
+    TestCase =:= t_validations
+->
+    %% when running emqx standalone tests, we can't use those
+    %% features.
+    case does_module_exist(emqx_mgmt_api_test_util) of
+        true ->
+            ct:timetrap({seconds, 30}),
+            %% start the listener with the default (non-ocsp) config
+            TestPid = self(),
+            ok = meck:new(emqx_ocsp_cache, [non_strict, passthrough, no_history, no_link]),
+            meck:expect(
+                emqx_ocsp_cache,
+                http_get,
+                fun(URL, _HTTPTimeout) ->
+                    ct:pal("ocsp http request ~p", [URL]),
+                    TestPid ! {http_get, URL},
+                    {ok, {{"HTTP/1.0", 200, 'OK'}, [], <<"ocsp response">>}}
+                end
+            ),
+            emqx_mgmt_api_test_util:init_suite([emqx_conf]),
+            snabbkaffe:start_trace(),
+            Config;
+        false ->
+            [{skip_does_not_apply, true} | Config]
+    end;
+init_per_testcase(t_ocsp_responder_error_responses, Config) ->
+    ct:timetrap({seconds, 30}),
+    TestPid = self(),
+    ok = meck:new(emqx_ocsp_cache, [non_strict, passthrough, no_history, no_link]),
+    meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(URL, _HTTPTimeout) ->
+            ct:pal("ocsp http request ~p", [URL]),
+            TestPid ! {http_get, URL},
+            persistent_term:get({?MODULE, http_response})
+        end
+    ),
+    DataDir = ?config(data_dir, Config),
+    Type = ssl,
+    Name = test_ocsp,
+    ListenerOpts = #{
+        ssl_options =>
+            #{
+                certfile => filename:join(DataDir, "server.pem"),
+                ocsp => #{
+                    enable_ocsp_stapling => true,
+                    responder_url => <<"http://localhost:9877/">>,
+                    issuer_pem => filename:join(DataDir, "ocsp-issuer.pem"),
+                    refresh_http_timeout => 15_000,
+                    refresh_interval => 1_000
+                }
+            }
+    },
+    Conf = #{listeners => #{Type => #{Name => ListenerOpts}}},
+    ConfBin = emqx_map_lib:binary_key_map(Conf),
+    hocon_tconf:check_plain(emqx_schema, ConfBin, #{required => false, atom_keys => false}),
+    emqx_config:put_listener_conf(Type, Name, [], ListenerOpts),
+    snabbkaffe:start_trace(),
+    _Heir = spawn_dummy_heir(),
+    {ok, CachePid} = emqx_ocsp_cache:start_link(),
+    [
+        {cache_pid, CachePid}
+        | Config
+    ];
+init_per_testcase(_TestCase, Config) ->
+    ct:timetrap({seconds, 10}),
+    TestPid = self(),
+    ok = meck:new(emqx_ocsp_cache, [non_strict, passthrough, no_history, no_link]),
+    meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(URL, _HTTPTimeout) ->
+            TestPid ! {http_get, URL},
+            {ok, {{"HTTP/1.0", 200, 'OK'}, [], <<"ocsp response">>}}
+        end
+    ),
+    _Heir = spawn_dummy_heir(),
+    {ok, CachePid} = emqx_ocsp_cache:start_link(),
+    DataDir = ?config(data_dir, Config),
+    Type = ssl,
+    Name = test_ocsp,
+    ListenerOpts = #{
+        ssl_options =>
+            #{
+                certfile => filename:join(DataDir, "server.pem"),
+                ocsp => #{
+                    enable_ocsp_stapling => true,
+                    responder_url => <<"http://localhost:9877/">>,
+                    issuer_pem => filename:join(DataDir, "ocsp-issuer.pem"),
+                    refresh_http_timeout => 15_000,
+                    refresh_interval => 1_000
+                }
+            }
+    },
+    Conf = #{listeners => #{Type => #{Name => ListenerOpts}}},
+    ConfBin = emqx_map_lib:binary_key_map(Conf),
+    hocon_tconf:check_plain(emqx_schema, ConfBin, #{required => false, atom_keys => false}),
+    emqx_config:put_listener_conf(Type, Name, [], ListenerOpts),
+    snabbkaffe:start_trace(),
+    [
+        {cache_pid, CachePid}
+        | Config
+    ].
+
+end_per_testcase(t_openssl_client, Config) ->
+    OCSPResponderOSPid = ?config(ocsp_responder_os_pid, Config),
+    catch kill_pid(OCSPResponderOSPid),
+    emqx_common_test_helpers:stop_apps([]),
+    ok;
+end_per_testcase(TestCase, Config) when
+    TestCase =:= t_update_listener;
+    TestCase =:= t_validations
+->
+    Skip = proplists:get_bool(skip_does_not_apply, Config),
+    case Skip of
+        true ->
+            ok;
+        false ->
+            emqx_mgmt_api_test_util:end_suite([emqx_conf]),
+            meck:unload([emqx_ocsp_cache]),
+            ok
+    end;
+end_per_testcase(t_ocsp_responder_error_responses, Config) ->
+    CachePid = ?config(cache_pid, Config),
+    catch gen_server:stop(CachePid),
+    meck:unload([emqx_ocsp_cache]),
+    persistent_term:erase({?MODULE, http_response}),
+    ok;
+end_per_testcase(_TestCase, Config) ->
+    CachePid = ?config(cache_pid, Config),
+    catch gen_server:stop(CachePid),
+    meck:unload([emqx_ocsp_cache]),
+    ok.
+
+%%--------------------------------------------------------------------
+%% Helper functions
+%%--------------------------------------------------------------------
+
+%% The real cache makes `emqx_kernel_sup' the heir to its ETS table.
+%% In some tests, we don't start the full supervision tree, so we need
+%% this dummy process.
+spawn_dummy_heir() ->
+    spawn_link(fun() ->
+        true = register(emqx_kernel_sup, self()),
+        receive
+            stop -> ok
+        end
+    end).
+
+does_module_exist(Mod) ->
+    case erlang:module_loaded(Mod) of
+        true ->
+            true;
+        false ->
+            case code:ensure_loaded(Mod) of
+                ok ->
+                    true;
+                {module, Mod} ->
+                    true;
+                _ ->
+                    false
+            end
+    end.
+
+assert_no_http_get() ->
+    receive
+        {http_get, _URL} ->
+            error(should_be_cached)
+    after 0 ->
+        ok
+    end.
+
+assert_http_get(N) ->
+    assert_http_get(N, 0).
+
+assert_http_get(0, _Timeout) ->
+    ok;
+assert_http_get(N, Timeout) when N > 0 ->
+    receive
+        {http_get, URL} ->
+            ?assertMatch(<<"http://localhost:9877/", _Request64/binary>>, URL),
+            ok
+    after Timeout ->
+        error({no_http_get, #{mailbox => process_info(self(), messages)}})
+    end,
+    assert_http_get(N - 1, Timeout).
+
+openssl_client_command(TLSVsn, RequestStatus, Config) ->
+    DataDir = ?config(data_dir, Config),
+    ClientCert = filename:join([DataDir, "client.pem"]),
+    ClientKey = filename:join([DataDir, "client.key"]),
+    Cacert = filename:join([DataDir, "ca.pem"]),
+    Openssl = os:find_executable("openssl"),
+    StatusOpt =
+        case RequestStatus of
+            true -> ["-status"];
+            false -> []
+        end,
+    [
+        Openssl,
+        "s_client",
+        "-connect",
+        "localhost:8883",
+        %% needed to trigger `sni_fun'
+        "-servername",
+        "localhost",
+        TLSVsn,
+        "-CAfile",
+        Cacert,
+        "-cert",
+        ClientCert,
+        "-key",
+        ClientKey
+    ] ++ StatusOpt.
+
+run_openssl_client(TLSVsn, RequestStatus, Config) ->
+    Command0 = openssl_client_command(TLSVsn, RequestStatus, Config),
+    Command = lists:flatten(lists:join(" ", Command0)),
+    os:cmd(Command).
+
+%% fixme: for some reason, the port program doesn't return any output
+%% when running in OTP 25 using `open_port`, but the `os:cmd` version
+%% works fine.
+%% the `open_port' version works fine in OTP 24 for some reason.
+spawn_openssl_client(TLSVsn, RequestStatus, Config) ->
+    [Openssl | Args] = openssl_client_command(TLSVsn, RequestStatus, Config),
+    open_port(
+        {spawn_executable, Openssl},
+        [
+            {args, Args},
+            binary,
+            stderr_to_stdout
+        ]
+    ).
+
+spawn_openssl_ocsp_responder(Config) ->
+    DataDir = ?config(data_dir, Config),
+    IssuerCert = filename:join([DataDir, "ocsp-issuer.pem"]),
+    IssuerKey = filename:join([DataDir, "ocsp-issuer.key"]),
+    Cacert = filename:join([DataDir, "ca.pem"]),
+    Index = filename:join([DataDir, "index.txt"]),
+    Openssl = os:find_executable("openssl"),
+    open_port(
+        {spawn_executable, Openssl},
+        [
+            {args, [
+                "ocsp",
+                "-ignore_err",
+                "-port",
+                "9877",
+                "-CA",
+                Cacert,
+                "-rkey",
+                IssuerKey,
+                "-rsigner",
+                IssuerCert,
+                "-index",
+                Index
+            ]},
+            binary,
+            stderr_to_stdout
+        ]
+    ).
+
+kill_pid(OSPid) ->
+    os:cmd("kill -9 " ++ integer_to_list(OSPid)).
+
+test_ocsp_connection(TLSVsn, WithRequestStatus = true, Config) ->
+    OCSPOutput = run_openssl_client(TLSVsn, WithRequestStatus, Config),
+    ?assertMatch(
+        {match, _},
+        re:run(OCSPOutput, "OCSP Response Status: successful"),
+        #{mailbox => process_info(self(), messages)}
+    ),
+    ?assertMatch(
+        {match, _},
+        re:run(OCSPOutput, "Cert Status: good"),
+        #{mailbox => process_info(self(), messages)}
+    ),
+    ok;
+test_ocsp_connection(TLSVsn, WithRequestStatus = false, Config) ->
+    OCSPOutput = run_openssl_client(TLSVsn, WithRequestStatus, Config),
+    ?assertMatch(
+        nomatch,
+        re:run(OCSPOutput, "Cert Status: good", [{capture, none}]),
+        #{mailbox => process_info(self(), messages)}
+    ),
+    ok.
+
+ensure_port_open(Port) ->
+    do_ensure_port_open(Port, 10).
+
+do_ensure_port_open(Port, 0) ->
+    error({port_not_open, Port});
+do_ensure_port_open(Port, N) when N > 0 ->
+    Timeout = 1_000,
+    case gen_tcp:connect("localhost", Port, [], Timeout) of
+        {ok, Sock} ->
+            gen_tcp:close(Sock),
+            ok;
+        {error, _} ->
+            ct:sleep(500),
+            do_ensure_port_open(Port, N - 1)
+    end.
+
+get_sni_fun(ListenerID) ->
+    #{opts := Opts} = emqx_listeners:find_by_id(ListenerID),
+    SSLOpts = proplists:get_value(ssl_options, Opts),
+    proplists:get_value(sni_fun, SSLOpts).
+
+openssl_version() ->
+    Res0 = string:trim(os:cmd("openssl version"), trailing),
+    [_, Res] = string:split(Res0, " "),
+    {match, [Version]} = re:run(Res, "^([^ ]+)", [{capture, first, list}]),
+    Version.
+
+setup_openssl_ocsp(Config) ->
+    OCSPResponderPort = spawn_openssl_ocsp_responder(Config),
+    {os_pid, OCSPOSPid} = erlang:port_info(OCSPResponderPort, os_pid),
+    %%%%%%%%  Warning!!!
+    %% Apparently, openssl 3.0.7 introduced a bug in the responder
+    %% that makes it hang forever if one probes the port with
+    %% `gen_tcp:open' / `gen_tcp:close'...  Comment this out if
+    %% openssl gets updated in CI or in your local machine.
+    OpenSSLVersion = openssl_version(),
+    ct:pal("openssl version: ~p", [OpenSSLVersion]),
+    case OpenSSLVersion of
+        "3." ++ _ ->
+            %% hope that the responder has started...
+            ok;
+        _ ->
+            ensure_port_open(9877)
+    end,
+    ct:sleep(1_000),
+    {OCSPResponderPort, OCSPOSPid}.
+
+request(Method, Url, QueryParams, Body) ->
+    AuthHeader = emqx_mgmt_api_test_util:auth_header_(),
+    Opts = #{return_all => true},
+    case emqx_mgmt_api_test_util:request_api(Method, Url, QueryParams, AuthHeader, Body, Opts) of
+        {ok, {Reason, Headers, BodyR}} ->
+            {ok, {Reason, Headers, emqx_json:decode(BodyR, [return_maps])}};
+        Error ->
+            Error
+    end.
+
+get_listener_via_api(ListenerId) ->
+    Path = emqx_mgmt_api_test_util:api_path(["listeners", ListenerId]),
+    request(get, Path, [], []).
+
+update_listener_via_api(ListenerId, NewConfig) ->
+    Path = emqx_mgmt_api_test_util:api_path(["listeners", ListenerId]),
+    request(put, Path, [], NewConfig).
+
+put_http_response(Response) ->
+    persistent_term:put({?MODULE, http_response}, Response).
+
+%%--------------------------------------------------------------------
+%% Test cases
+%%--------------------------------------------------------------------
+
+t_request_ocsp_response(_Config) ->
+    ?check_trace(
+        begin
+            ListenerID = <<"ssl:test_ocsp">>,
+            %% not yet cached.
+            ?assertEqual([], ets:tab2list(?CACHE_TAB)),
+            ?assertEqual(
+                {ok, <<"ocsp response">>},
+                emqx_ocsp_cache:fetch_response(ListenerID)
+            ),
+            assert_http_get(1),
+            ?assertMatch([{_, <<"ocsp response">>}], ets:tab2list(?CACHE_TAB)),
+            %% already cached; should not perform request again.
+            ?assertEqual(
+                {ok, <<"ocsp response">>},
+                emqx_ocsp_cache:fetch_response(ListenerID)
+            ),
+            assert_no_http_get(),
+            ok
+        end,
+        fun(Trace) ->
+            ?assert(
+                ?strict_causality(
+                    #{?snk_kind := ocsp_cache_miss, listener_id := _ListenerID},
+                    #{?snk_kind := ocsp_http_fetch_and_cache, listener_id := _ListenerID},
+                    Trace
+                )
+            ),
+            ?assertMatch(
+                [_],
+                ?of_kind(ocsp_cache_miss, Trace)
+            ),
+            ?assertMatch(
+                [_],
+                ?of_kind(ocsp_http_fetch_and_cache, Trace)
+            ),
+            ?assertMatch(
+                [_],
+                ?of_kind(ocsp_cache_hit, Trace)
+            ),
+            ok
+        end
+    ).
+
+t_request_ocsp_response_restart_cache(Config) ->
+    process_flag(trap_exit, true),
+    CachePid = ?config(cache_pid, Config),
+    ListenerID = <<"ssl:test_ocsp">>,
+    ?check_trace(
+        begin
+            [] = ets:tab2list(?CACHE_TAB),
+            {ok, _} = emqx_ocsp_cache:fetch_response(ListenerID),
+            ?wait_async_action(
+                begin
+                    Ref = monitor(process, CachePid),
+                    exit(CachePid, kill),
+                    receive
+                        {'DOWN', Ref, process, CachePid, killed} ->
+                            ok
+                    after 1_000 ->
+                        error(cache_not_killed)
+                    end,
+                    {ok, _} = emqx_ocsp_cache:start_link(),
+                    ok
+                end,
+                #{?snk_kind := ocsp_cache_init}
+            ),
+            {ok, _} = emqx_ocsp_cache:fetch_response(ListenerID),
+            ok
+        end,
+        fun(Trace) ->
+            %% Only one fetch because the cache table was preserved by
+            %% its heir ("emqx_kernel_sup").
+            ?assertMatch(
+                [_],
+                ?of_kind(ocsp_http_fetch_and_cache, Trace)
+            ),
+            assert_http_get(1),
+            ok
+        end
+    ).
+
+t_request_ocsp_response_bad_http_status(_Config) ->
+    TestPid = self(),
+    meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(URL, _HTTPTimeout) ->
+            TestPid ! {http_get, URL},
+            {ok, {{"HTTP/1.0", 404, 'Not Found'}, [], <<"not found">>}}
+        end
+    ),
+    ListenerID = <<"ssl:test_ocsp">>,
+    %% not yet cached.
+    ?assertEqual([], ets:tab2list(?CACHE_TAB)),
+    ?assertEqual(
+        error,
+        emqx_ocsp_cache:fetch_response(ListenerID)
+    ),
+    assert_http_get(1),
+    ?assertEqual([], ets:tab2list(?CACHE_TAB)),
+    ok.
+
+t_request_ocsp_response_timeout(_Config) ->
+    TestPid = self(),
+    meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(URL, _HTTPTimeout) ->
+            TestPid ! {http_get, URL},
+            {error, timeout}
+        end
+    ),
+    ListenerID = <<"ssl:test_ocsp">>,
+    %% not yet cached.
+    ?assertEqual([], ets:tab2list(?CACHE_TAB)),
+    ?assertEqual(
+        error,
+        emqx_ocsp_cache:fetch_response(ListenerID)
+    ),
+    assert_http_get(1),
+    ?assertEqual([], ets:tab2list(?CACHE_TAB)),
+    ok.
+
+t_register_listener(_Config) ->
+    ListenerID = <<"ssl:test_ocsp">>,
+    Conf = emqx_config:get_listener_conf(ssl, test_ocsp, []),
+    %% should fetch and cache immediately
+    {ok, {ok, _}} =
+        ?wait_async_action(
+            emqx_ocsp_cache:register_listener(ListenerID, Conf),
+            #{?snk_kind := ocsp_http_fetch_and_cache, listener_id := ListenerID}
+        ),
+    assert_http_get(1),
+    ?assertMatch([{_, <<"ocsp response">>}], ets:tab2list(?CACHE_TAB)),
+    ok.
+
+t_register_twice(_Config) ->
+    ListenerID = <<"ssl:test_ocsp">>,
+    Conf = emqx_config:get_listener_conf(ssl, test_ocsp, []),
+    {ok, {ok, _}} =
+        ?wait_async_action(
+            emqx_ocsp_cache:register_listener(ListenerID, Conf),
+            #{?snk_kind := ocsp_http_fetch_and_cache, listener_id := ListenerID}
+        ),
+    assert_http_get(1),
+    ?assertMatch([{_, <<"ocsp response">>}], ets:tab2list(?CACHE_TAB)),
+    %% should have no problem in registering the same listener again.
+    %% this prompts an immediate refresh.
+    {ok, {ok, _}} =
+        ?wait_async_action(
+            emqx_ocsp_cache:register_listener(ListenerID, Conf),
+            #{?snk_kind := ocsp_http_fetch_and_cache, listener_id := ListenerID}
+        ),
+    ok.
+
+t_refresh_periodically(_Config) ->
+    ListenerID = <<"ssl:test_ocsp">>,
+    Conf = emqx_config:get_listener_conf(ssl, test_ocsp, []),
+    %% should refresh periodically
+    {ok, SubRef} =
+        snabbkaffe:subscribe(
+            fun
+                (#{?snk_kind := ocsp_http_fetch_and_cache, listener_id := ListenerID0}) ->
+                    ListenerID0 =:= ListenerID;
+                (_) ->
+                    false
+            end,
+            _NEvents = 2,
+            _Timeout = 10_000
+        ),
+    ok = emqx_ocsp_cache:register_listener(ListenerID, Conf),
+    ?assertMatch({ok, [_, _]}, snabbkaffe:receive_events(SubRef)),
+    assert_http_get(2),
+    ok.
+
+t_sni_fun_success(_Config) ->
+    ListenerID = <<"ssl:test_ocsp">>,
+    ServerName = "localhost",
+    ?assertEqual(
+        [
+            {certificate_status, #certificate_status{
+                status_type = ?CERTIFICATE_STATUS_TYPE_OCSP,
+                response = <<"ocsp response">>
+            }}
+        ],
+        emqx_ocsp_cache:sni_fun(ServerName, ListenerID)
+    ),
+    ok.
+
+t_sni_fun_http_error(_Config) ->
+    meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(_URL, _HTTPTimeout) ->
+            {error, timeout}
+        end
+    ),
+    ListenerID = <<"ssl:test_ocsp">>,
+    ServerName = "localhost",
+    ?assertEqual(
+        [],
+        emqx_ocsp_cache:sni_fun(ServerName, ListenerID)
+    ),
+    ok.
+
+%% check that we can start with a non-ocsp stapling listener and
+%% restart it with the new ocsp config.
+t_update_listener(Config) ->
+    case proplists:get_bool(skip_does_not_apply, Config) of
+        true ->
+            ok;
+        false ->
+            do_t_update_listener(Config)
+    end.
+
+do_t_update_listener(Config) ->
+    DataDir = ?config(data_dir, Config),
+    Keyfile = filename:join([DataDir, "server.key"]),
+    Certfile = filename:join([DataDir, "server.pem"]),
+    Cacertfile = filename:join([DataDir, "ca.pem"]),
+    IssuerPemPath = filename:join([DataDir, "ocsp-issuer.pem"]),
+    {ok, IssuerPem} = file:read_file(IssuerPemPath),
+
+    %% no ocsp at first
+    ListenerId = "ssl:default",
+    {ok, {{_, 200, _}, _, ListenerData0}} = get_listener_via_api(ListenerId),
+    ?assertMatch(
+        #{
+            <<"ssl_options">> :=
+                #{
+                    <<"ocsp">> :=
+                        #{<<"enable_ocsp_stapling">> := false}
+                }
+        },
+        ListenerData0
+    ),
+    assert_no_http_get(),
+
+    %% configure ocsp
+    OCSPConfig =
+        #{
+            <<"ssl_options">> =>
+                #{
+                    <<"keyfile">> => Keyfile,
+                    <<"certfile">> => Certfile,
+                    <<"cacertfile">> => Cacertfile,
+                    <<"ocsp">> =>
+                        #{
+                            <<"enable_ocsp_stapling">> => true,
+                            %% we use the file contents to check that
+                            %% the API converts that to an internally
+                            %% managed file
+                            <<"issuer_pem">> => IssuerPem,
+                            <<"responder_url">> => <<"http://localhost:9877">>
+                        }
+                }
+        },
+    ListenerData1 = emqx_map_lib:deep_merge(ListenerData0, OCSPConfig),
+    {ok, {_, _, ListenerData2}} = update_listener_via_api(ListenerId, ListenerData1),
+    ?assertMatch(
+        #{
+            <<"ssl_options">> :=
+                #{
+                    <<"ocsp">> :=
+                        #{
+                            <<"enable_ocsp_stapling">> := true,
+                            <<"issuer_pem">> := _,
+                            <<"responder_url">> := _
+                        }
+                }
+        },
+        ListenerData2
+    ),
+    %% issuer pem should have been uploaded and saved to a new
+    %% location
+    ?assertNotEqual(
+        IssuerPemPath,
+        emqx_map_lib:deep_get(
+            [<<"ssl_options">>, <<"ocsp">>, <<"issuer_pem">>],
+            ListenerData2
+        )
+    ),
+    ?assertNotEqual(
+        IssuerPem,
+        emqx_map_lib:deep_get(
+            [<<"ssl_options">>, <<"ocsp">>, <<"issuer_pem">>],
+            ListenerData2
+        )
+    ),
+    assert_http_get(1, 5_000),
+    ok.
+
+t_ocsp_responder_error_responses(_Config) ->
+    ListenerId = <<"ssl:test_ocsp">>,
+    Conf = emqx_config:get_listener_conf(ssl, test_ocsp, []),
+    ?check_trace(
+        begin
+            %% successful response without headers
+            put_http_response({ok, {200, <<"ocsp_response">>}}),
+            {ok, {ok, _}} =
+                ?wait_async_action(
+                    emqx_ocsp_cache:register_listener(ListenerId, Conf),
+                    #{?snk_kind := ocsp_http_fetch_and_cache, headers := false},
+                    1_000
+                ),
+
+            %% error response with headers
+            put_http_response({ok, {{"HTTP/1.0", 500, "Internal Server Error"}, [], <<"error">>}}),
+            {ok, {ok, _}} =
+                ?wait_async_action(
+                    emqx_ocsp_cache:register_listener(ListenerId, Conf),
+                    #{?snk_kind := ocsp_http_fetch_bad_code, code := 500, headers := true},
+                    1_000
+                ),
+
+            %% error response without headers
+            put_http_response({ok, {500, <<"error">>}}),
+            {ok, {ok, _}} =
+                ?wait_async_action(
+                    emqx_ocsp_cache:register_listener(ListenerId, Conf),
+                    #{?snk_kind := ocsp_http_fetch_bad_code, code := 500, headers := false},
+                    1_000
+                ),
+
+            %% econnrefused
+            put_http_response(
+                {error,
+                    {failed_connect, [
+                        {to_address, {"localhost", 9877}},
+                        {inet, [inet], econnrefused}
+                    ]}}
+            ),
+            {ok, {ok, _}} =
+                ?wait_async_action(
+                    emqx_ocsp_cache:register_listener(ListenerId, Conf),
+                    #{?snk_kind := ocsp_http_fetch_error, error := {failed_connect, _}},
+                    1_000
+                ),
+
+            %% timeout
+            put_http_response({error, timeout}),
+            {ok, {ok, _}} =
+                ?wait_async_action(
+                    emqx_ocsp_cache:register_listener(ListenerId, Conf),
+                    #{?snk_kind := ocsp_http_fetch_error, error := timeout},
+                    1_000
+                ),
+
+            ok
+        end,
+        []
+    ),
+    ok.
+
+t_unknown_requests(_Config) ->
+    emqx_ocsp_cache ! unknown,
+    ?assertEqual(ok, gen_server:cast(emqx_ocsp_cache, unknown)),
+    ?assertEqual({error, {unknown_call, unknown}}, gen_server:call(emqx_ocsp_cache, unknown)),
+    ok.
+
+t_validations(Config) ->
+    case proplists:get_bool(skip_does_not_apply, Config) of
+        true ->
+            ok;
+        false ->
+            do_t_validations(Config)
+    end.
+
+do_t_validations(_Config) ->
+    ListenerId = <<"ssl:default">>,
+    {ok, {{_, 200, _}, _, ListenerData0}} = get_listener_via_api(ListenerId),
+
+    ListenerData1 =
+        emqx_map_lib:deep_merge(
+            ListenerData0,
+            #{
+                <<"ssl_options">> =>
+                    #{<<"ocsp">> => #{<<"enable_ocsp_stapling">> => true}}
+            }
+        ),
+    {error, {_, _, ResRaw1}} = update_listener_via_api(ListenerId, ListenerData1),
+    #{<<"code">> := <<"BAD_REQUEST">>, <<"message">> := MsgRaw1} =
+        emqx_json:decode(ResRaw1, [return_maps]),
+    ?assertMatch(
+        #{
+            <<"mismatches">> :=
+                #{
+                    <<"listeners:ssl_not_required_bind">> :=
+                        #{
+                            <<"reason">> :=
+                                <<"The responder URL is required for OCSP stapling">>
+                        }
+                }
+        },
+        emqx_json:decode(MsgRaw1, [return_maps])
+    ),
+
+    ListenerData2 =
+        emqx_map_lib:deep_merge(
+            ListenerData0,
+            #{
+                <<"ssl_options">> =>
+                    #{
+                        <<"ocsp">> => #{
+                            <<"enable_ocsp_stapling">> => true,
+                            <<"responder_url">> => <<"http://localhost:9877">>
+                        }
+                    }
+            }
+        ),
+    {error, {_, _, ResRaw2}} = update_listener_via_api(ListenerId, ListenerData2),
+    #{<<"code">> := <<"BAD_REQUEST">>, <<"message">> := MsgRaw2} =
+        emqx_json:decode(ResRaw2, [return_maps]),
+    ?assertMatch(
+        #{
+            <<"mismatches">> :=
+                #{
+                    <<"listeners:ssl_not_required_bind">> :=
+                        #{
+                            <<"reason">> :=
+                                <<"The issuer PEM path is required for OCSP stapling">>
+                        }
+                }
+        },
+        emqx_json:decode(MsgRaw2, [return_maps])
+    ),
+
+    ListenerData3a =
+        emqx_map_lib:deep_merge(
+            ListenerData0,
+            #{
+                <<"ssl_options">> =>
+                    #{
+                        <<"ocsp">> => #{
+                            <<"enable_ocsp_stapling">> => true,
+                            <<"responder_url">> => <<"http://localhost:9877">>,
+                            <<"issuer_pem">> => <<"some_file">>
+                        }
+                    }
+            }
+        ),
+    ListenerData3 = emqx_map_lib:deep_remove([<<"ssl_options">>, <<"certfile">>], ListenerData3a),
+    {error, {_, _, ResRaw3}} = update_listener_via_api(ListenerId, ListenerData3),
+    #{<<"code">> := <<"BAD_REQUEST">>, <<"message">> := MsgRaw3} =
+        emqx_json:decode(ResRaw3, [return_maps]),
+    ?assertMatch(
+        #{
+            <<"mismatches">> :=
+                #{
+                    <<"listeners:ssl_not_required_bind">> :=
+                        #{
+                            <<"reason">> :=
+                                <<"Server certificate must be defined when using OCSP stapling">>
+                        }
+                }
+        },
+        emqx_json:decode(MsgRaw3, [return_maps])
+    ),
+
+    ok.
+
+t_unknown_error_fetching_ocsp_response(_Config) ->
+    ListenerID = <<"ssl:test_ocsp">>,
+    TestPid = self(),
+    ok = meck:expect(
+        emqx_ocsp_cache,
+        http_get,
+        fun(_RequestURI, _HTTPTimeout) ->
+            TestPid ! error_raised,
+            meck:exception(error, something_went_wrong)
+        end
+    ),
+    ?assertEqual(error, emqx_ocsp_cache:fetch_response(ListenerID)),
+    receive
+        error_raised -> ok
+    after 200 -> ct:fail("should have tried to fetch ocsp response")
+    end,
+    ok.
+
+t_openssl_client(Config) ->
+    TLSVsn = ?config(tls_vsn, Config),
+    WithStatusRequest = ?config(status_request, Config),
+    %% ensure ocsp response is already cached.
+    ListenerID = <<"ssl:default">>,
+    ?assertMatch(
+        {ok, _},
+        emqx_ocsp_cache:fetch_response(ListenerID),
+        #{msgs => process_info(self(), messages)}
+    ),
+    timer:sleep(500),
+    test_ocsp_connection(TLSVsn, WithStatusRequest, Config).

+ 68 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/ca.pem

@@ -0,0 +1,68 @@
+-----BEGIN CERTIFICATE-----
+MIIF+zCCA+OgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwbzELMAkGA1UEBhMCU0Ux
+EjAQBgNVBAgMCVN0b2NraG9sbTESMBAGA1UEBwwJU3RvY2tob2xtMRIwEAYDVQQK
+DAlNeU9yZ05hbWUxETAPBgNVBAsMCE15Um9vdENBMREwDwYDVQQDDAhNeVJvb3RD
+QTAeFw0yMzAxMTIxMzA4MTZaFw0zMzAxMDkxMzA4MTZaMGsxCzAJBgNVBAYTAlNF
+MRIwEAYDVQQIDAlTdG9ja2hvbG0xEjAQBgNVBAoMCU15T3JnTmFtZTEZMBcGA1UE
+CwwQTXlJbnRlcm1lZGlhdGVDQTEZMBcGA1UEAwwQTXlJbnRlcm1lZGlhdGVDQTCC
+AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALQG7dMeU/y9HDNHzhydR0bm
+wN9UGplqJOJPwqJRaZZcrn9umgJ9SU2il2ceEVxMDwzBWCRKJO5/H9A9k13SqsXM
+2c2c9xXfIF1kb820lCm1Uow5hZ/auDjxliNk9kNJDigCRi3QoIs/dVeWzFsgEC2l
+gxRqauN2eNFb6/yXY788YALHBsCRV2NFOFXxtPsvLXpD9Q/8EqYsSMuLARRdHVNU
+ryaEF5lhShpcuz0TlIuTy2TiuXJUtJ+p7a4Z7friZ6JsrmQWsVQBj44F8TJRHWzW
+C7vm9c+dzEX9eqbr5iPL+L4ctMW9Lz6ePcYfIXne6CElusRUf8G+xM1uwovF9bpV
++9IqY7tAu9G1iY9iNtJgNNDKOCcOGKcZCx6Cg1XYOEKReNnUMazvYeqRrrjV5WQ0
+vOcD5zcBRNTXCddCLa7U0guXP9mQrfuk4NTH1Bt77JieTJ8cfDXHwtaKf6aGbmZP
+wl1Xi/GuXNUP/xeog78RKyFwBmjt2JKwvWzMpfmH4mEkG9moh2alva+aEz6LIJuP
+16g6s0Q6c793/OvUtpNcewHw4Vjn39LD9o6VLp854G4n8dVpUWSbWS+sXD1ZE69H
+g/sMNMyq+09ufkbewY8xoCm/rQ1pqDZAVMWsstJEaYu7b/eb7R+RGOj1YECCV/Yp
+EZPdDotbSNRkIi2d/a1NAgMBAAGjgaQwgaEwHQYDVR0OBBYEFExwhjsVUom6tQ+S
+qq6xMUETvnPzMB8GA1UdIwQYMBaAFD90kfU5pc5l48THu0Ayj9SNpHuhMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMDsGA1UdHwQ0MDIwMKAuoCyG
+Kmh0dHA6Ly9sb2NhbGhvc3Q6OTg3OC9pbnRlcm1lZGlhdGUuY3JsLnBlbTANBgkq
+hkiG9w0BAQsFAAOCAgEAK6NgdWQYtPNKQNBGjsgtgqTRh+k30iqSO6Y3yE1KGABO
+EuQdVqkC2qUIbCB0M0qoV0ab50KNLfU6cbshggW4LDpcMpoQpI05fukNh1jm3ZuZ
+0xsB7vlmlsv00tpqmfIl/zykPDynHKOmFh/hJP/KetMy4+wDv4/+xP31UdEj5XvG
+HvMtuqOS23A+H6WPU7ol7KzKBnU2zz/xekvPbUD3JqV+ynP5bgbIZHAndd0o9T8e
+NFX23Us4cTenU2/ZlOq694bRzGaK+n3Ksz995Nbtzv5fbUgqmf7Mcq4iHGRVtV11
+MRyBrsXZp2vbF63c4hrf2Zd6SWRoaDKRhP2DMhajpH9zZASSTlfejg/ZRO2s+Clh
+YrSTkeMAdnRt6i/q4QRcOTCfsX75RFM5v67njvTXsSaSTnAwaPi78tRtf+WSh0EP
+VVPzy++BszBVlJ1VAf7soWZHCjZxZ8ZPqVTy5okoHwWQ09WmYe8GfulDh1oj0wbK
+3FjN7bODWHJN+bFf5aQfK+tumYKoPG8RXL6QxpEzjFWjxhIMJHHMKfDWnAV1o1+7
+/1/aDzq7MzEYBbrgQR7oE5ZHtyqhCf9LUgw0Kr7/8QWuNAdeDCJzjXRROU0hJczp
+dOyfRlLbHmLLmGOnROlx6LsGNQ17zuz6SPi7ei8/ylhykawDOAGkM1+xFakmQhM=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIFzzCCA7egAwIBAgIUYjc7hD7/UJ0/VPADfNfp/WpOwRowDQYJKoZIhvcNAQEL
+BQAwbzELMAkGA1UEBhMCU0UxEjAQBgNVBAgMCVN0b2NraG9sbTESMBAGA1UEBwwJ
+U3RvY2tob2xtMRIwEAYDVQQKDAlNeU9yZ05hbWUxETAPBgNVBAsMCE15Um9vdENB
+MREwDwYDVQQDDAhNeVJvb3RDQTAeFw0yMzAxMTIxMzA4MTRaFw00MzAxMDcxMzA4
+MTRaMG8xCzAJBgNVBAYTAlNFMRIwEAYDVQQIDAlTdG9ja2hvbG0xEjAQBgNVBAcM
+CVN0b2NraG9sbTESMBAGA1UECgwJTXlPcmdOYW1lMREwDwYDVQQLDAhNeVJvb3RD
+QTERMA8GA1UEAwwITXlSb290Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
+AoICAQCnBwSOYVJw47IoMHMXTVDtOYvUt3rqsurEhFcB4O8xmf2mmwr6m7s8A5Ft
+AvAehg1GvnXT3t/KiyU7BK+acTwcErGyZwS2wvdB0lpHWSpOn/u5y+4ZETvQefcj
+ZTdDOM9VN5nutpitgNb+1yL8sqSexfVbY7DnYYvFjOVBYoP/SGvM9jVjCad+0WL3
+FhuD+L8QAxzCieX3n9UMymlFwINQuEc+TDjuNcEqt+0J5EgS1fwzxb2RCVL0TNv4
+9a71hFGCNRj20AeZm99hbdufm7+0AFO7ocV5q43rLrWFUoBzqKPYIjga/cv/UdWZ
+c5RLRXw3JDSrCqkf/mOlaEhNPlmWRF9MSus5Da3wuwgGCaVzmrf30rWR5aHHcscG
+e+AOgJ4HayvBUQeb6ZlRXc0YlACiLToMKxuyxDyUcDfVEXpUIsDILF8dkiVQxEU3
+j9g6qjXiqPVdNiwpqXfBKObj8vNCzORnoHYs8cCgib3RgDVWeqkDmlSwlZE7CvQh
+U4Loj4l7813xxzYEKkVaT1JdXPWu42CG/b4Y/+f4V+3rkJkYzUwndX6kZNksIBai
+phmtvKt+CTdP1eAbT+C9AWWF3PT31+BIhuT0u9tR8BVSkXdQB8dG4M/AAJcTo640
+0mdYYOXT153gEKHJuUBm750ZTy+r6NjNvpw8VrMAakJwHqnIdQIDAQABo2MwYTAd
+BgNVHQ4EFgQUP3SR9TmlzmXjxMe7QDKP1I2ke6EwHwYDVR0jBBgwFoAUP3SR9Tml
+zmXjxMe7QDKP1I2ke6EwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYw
+DQYJKoZIhvcNAQELBQADggIBAFMFv4C+I0+xOAb9v6G/IOpfPBZ1ez31EXKJJBra
+lulP4nRHQMeb310JS8BIeQ3dl+7+PkSxPABZSwc3jkxdSMvhc+Z4MQtTgos+Qsjs
+gH7sTqwWeeQ0lHYxWmkXijrh5OPRZwTKzYQlkcn85BCUXl2KDuNEdiqPbDTao+lc
+lA0/UAvC6NCyFKq/jqf4CmW5Kx6yG1v1LaE+IXn7cbIXj+DaehocVXi0wsXqj03Q
+DDUHuLHZP+LBsg4e91/0Jy2ekNRTYJifSqr+9ufHl0ZX1pFDZyf396IgZ5CQZ0PJ
+nRxZHlCfsxWxmxxdy3FQSE6YwXhdTjjoAa1ApZcKkkt1beJa6/oRLze/ux5x+5q+
+4QczufHd6rjoKBi6BM3FgFQ8As5iNohHXlMHd/xITo1Go3CWw2j9TGH5vzksOElK
+B0mcwwt2zwNEjvfytc+tI5jcfGN3tiT5fVHS8hw9dWKevypLL+55Ua9G8ZgDHasT
+XFRJHgmnbyFcaAe26D2dSKmhC9u2mHBH+MaI8dj3e7wNBfpxNgp41aFIk+QTmiFW
+VXFED6DHQ/Mxq93ACalHdYg18PlIYClbT6Pf2xXBnn33YPhn5xzoTZ+cDH/RpaQp
+s0UUTSJT1UTXgtXPnZWQfvKlMjJEIiVFiLEC0sgZRlWuZDRAY0CdZJJxvQp59lqu
+cbTm
+-----END CERTIFICATE-----

+ 52 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/client.key

@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCmfZmBAOZJ8xjP
+YkpyQxTGZ40vIwOuylwSow12idWN6jcW9g5aIip+B2oKrfzR7PYsxbDodcj/KOpQ
+GwCFAujSYgYviiOsmATQ1meNocnnWjAsybw+dSXK/ZjfrVgIaJF7RHaLiDtq5TI4
+b4KjUFyh5NILIc+zfZqoNU6khUF0bcOBAG2BFaBzRf+a/hgZXEPyEnoqFK5J5k+D
+DSlKXDbOTEHhXG4QFT1hZataxptD1nTEFRYuzfmh/g4RDvWtawm9YU3j/V0Un7t/
+Taj0fAXNi30TzKOVaVcDrkVtDFHe2hX3lOJd53I5NpS7asaq+aTNytz+I3Bf/a4v
+khEgrKpjBSXXm/+Vw5NzsXNwKddSUGywmIbV2YBYnK+0DwhOXLsTPh3pv6931NVx
+pifW0nM4Ur6XCDHOPVX/jIZZ819bzAlZZ3BgMTz7pqT9906lmNRQBgSgr+Zaw9gj
+VhLg1VDfwF85eanhbzk5ITnffR+s2conZr2g+LEDsq2dJv/sEbYuHBNBkDthn439
+MgNq1nr3PV0hn8pNcgS5ZFUw+fN8403RY9TYLssB/FFYREDCax0j75qL3E7LbZK8
+JfsP8uh1e3PdR64TgtoYoTKuwtIqelmh+ryAWFjaXLPoP/AqYk1VcRCevOXUKw6L
+iskdukplk9cy2cPLcm+EP+2Js3B28QIDAQABAoICABxBnVOcZjk/QaLy1N07HtPE
+f9zz5Zxc+k7sbuzDHGQzT8m9FXb9LPaKRhhNaqbrP2WeYLW3RdduZ4QUbRxl/8Mz
+AUdAu+i/PTP/a4BJaOWztBDp5SG5iqI+s5skxZfZvXUtC6yHQMRV5VXYMRUMHsiY
+OADNKn3VT7IEKBZ6ij8bIO7sNmmN1NczllvFC6yEMQDs22B4dZMTvENq8KrO5ztQ
+jG7V29Utcact1Oz5X6EeDN+5j3P+n8M7RcJl5lLaI4NJeCl9VvaY3H7Q3J+vy+FU
+bvQ1Cz9gqzSz91L4YA3BODC2i0uyK/vjVE9Roimi6HJH34VfWONlv9IRiYgg3eLd
+xrWe/qZkxcfrHmgyH0a6fxwpT58T3d6WH0I/HwSbJuVvm2AhLy+7zXdLNRLrlE+n
+UfrJDgTwiTPlJA5JzSVGKVBSOVQs9G52aZ0IAvgN9uHHFhhqeJ3naax1q/JtRfDo
+O0w5Ga2KjAJDcAQj/Cq5+LMSI1Bxl46db17EFnA//X3Oxhv93CvsTULPiOJ7fdYC
+3X7YCJ33a7w4B8+FxmiTYLe+aR6CC8fsu4qYccCctPUje1MzUkw6gvbWSyxkbmW7
+kGTWKx4E/SL4cc+DjoC1h37RtqghDDxtYhA42wWiocDXoKPlWJoIkG1UUO5f6/2N
+cKPzQx1f23UTvIRkMYe1AoIBAQDR94YzLncfuY4DhHpqJRjv8xXfOif+ARWicnma
+CwePpv80YoQvc7B9rbPA9qZ5EG9eQF62FkTrvCwbAhA5L11aJsXxnSvZREQcdteO
+kQPnKXAJbHYh5yto/HhezdtIMmoZCGpHLmsiK20QnRyA0InKsFCKBpi20gFzOKMx
+DwuQEoANHIwUscHnansM958eKAolujfjjOeFiK+j4Vd6P0neV8EQTl6A0+R/l5td
+l69wySW7tB4xfOon5Y0D+AfGMH3alZs3ymAjBNKZIk+2hKvhDRa7IqwlckwQq6by
+Ku25LKeRVt3wOkfJitSDgiEsNA5oJQ90A4ny6hIOAvLWir6tAoIBAQDK/fPVaT7r
+7tNjzaMgeQ/VKGUauCMbPC7ST2cEvZMp9YFhdKbl/TwhC8lpJqrsKhXyKNz20FOL
+7m8XjHu4mdSs6zaPvkMnUboge9pcnIKeS5nRVsW0CRuSc4A3qhrvBp9av77gIjnr
+XJ6RyFihDji1P6RVoylyyR8k/qiZupMg7UK3vbuTpJqARObfaaprOwqVItkJX2vf
+XF7qfBCnik1jlZKWZq+9dbhz8KP4KWpKINrwIuvlAQnTJpc15beHxMEt73hxAY3A
+n3Iydtm5zsBcOLyLLgySUOsp0zlcAv0iHP3ShsFP2WeQLKR9Qapc58kkJ1lmlu71
+QdahwonpXjXVAoIBAEQnfYc1iPNiTsezg+zad9rDZBEeloaroXMmh3RKKj0l7ub5
+J4Ejo2FYNeXn6ieX/x5v9I5UcjC21vY5WDzHtBykQ1JnOyl+MEGxDc04IzUwzS4x
+57KfkAa3FPdpCMnJm4jeo2jRl3Ly96cR6IOjrWZ+jtYOyBln15KoCsjM4mr0pl4b
+Kxk4jgFpHeIaqqqmQoz2gle5kBlXQfQHHFcRHhAvGfsKBUD6Bsyn0IWzy/3nPPlN
+wRM9QeCLcZedNiDN8rw2HbkhVs1nLlkIuyk6rXQSxJMf8RMCo9Axd7JZ3uphpU7X
+DJmCwXSZPNwnLE9l4ltJ1FdLIscX1Z54tIyRYs0CggEBAIVPgnMFS21myy0gP6Fz
+4BH9FWkWxPd97sHvo5hZZ+yGbxGxqmoghPyu4PdNjbLLcN44N+Vfq36aeBrfB+GU
+JTfqwUpliXSpF7N9o0pu/tk2jS4N7ojt8k2bzPjBni6cCstuYcyQrbkEep8DFDGx
+RUzDHwmevfnEW8/P7qoG/dkB+G7zC91KnKzgkz7mBiWmAK0w1ZhyMkXeQ/d6wvVE
+vs5HzJ05kvC5/wklYIn5qPRF34MVbBZZODqTfXrIAmAHt1aTjmWov49hJ348z4BX
+Z70pBanh9B+jRM2TCniC/fsJTyiTlyD5hioJJ32bQmcBUfeMYAof1Y78ThityiSY
+2oECggEAYdkz6z+1hIMI2nIMtei1n5bLV4bWmS1nkZ3pBSMkbS7VJFAxZ53xJi0S
+StSs/bka+akvnYEoFAGhVtiaz4497qnUiquf/aBs4TUHfNGn22/LN5b8vs51ugil
+RXejaJjPLqL6jmXz5T4+TJGcH5kL6NDtYkT3IEtv5uWkQkBs0Z1Juf34nVjMbozC
+bohyOyCMOLt7HqcUpUtevSK7SXmyU4yd2UyRqFMFPi4RJjxQWFZmNFC5S1PsZBh+
+OOMNAJ1F2h2fC7KdNVBpdoNsOAPxdCNxbwGKiNHwnukvF9uvaDIw3jqKJU3g/Z6j
+rkE8Bz5a/iwO+QwdO5Q2cp5+0nm41A==
+-----END PRIVATE KEY-----

+ 38 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/client.pem

@@ -0,0 +1,38 @@
+-----BEGIN CERTIFICATE-----
+MIIGmjCCBIKgAwIBAgICEAYwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCU0Ux
+EjAQBgNVBAgMCVN0b2NraG9sbTESMBAGA1UECgwJTXlPcmdOYW1lMRkwFwYDVQQL
+DBBNeUludGVybWVkaWF0ZUNBMRkwFwYDVQQDDBBNeUludGVybWVkaWF0ZUNBMB4X
+DTIzMDMwNjE5NTA0N1oXDTMzMDYxMTE5NTA0N1owezELMAkGA1UEBhMCU0UxEjAQ
+BgNVBAgMCVN0b2NraG9sbTESMBAGA1UEBwwJU3RvY2tob2xtMRIwEAYDVQQKDAlN
+eU9yZ05hbWUxGTAXBgNVBAsMEE15SW50ZXJtZWRpYXRlQ0ExFTATBgNVBAMMDG9j
+c3AuY2xpZW50MjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKZ9mYEA
+5knzGM9iSnJDFMZnjS8jA67KXBKjDXaJ1Y3qNxb2DloiKn4Hagqt/NHs9izFsOh1
+yP8o6lAbAIUC6NJiBi+KI6yYBNDWZ42hyedaMCzJvD51Jcr9mN+tWAhokXtEdouI
+O2rlMjhvgqNQXKHk0gshz7N9mqg1TqSFQXRtw4EAbYEVoHNF/5r+GBlcQ/ISeioU
+rknmT4MNKUpcNs5MQeFcbhAVPWFlq1rGm0PWdMQVFi7N+aH+DhEO9a1rCb1hTeP9
+XRSfu39NqPR8Bc2LfRPMo5VpVwOuRW0MUd7aFfeU4l3ncjk2lLtqxqr5pM3K3P4j
+cF/9ri+SESCsqmMFJdeb/5XDk3Oxc3Ap11JQbLCYhtXZgFicr7QPCE5cuxM+Hem/
+r3fU1XGmJ9bSczhSvpcIMc49Vf+MhlnzX1vMCVlncGAxPPumpP33TqWY1FAGBKCv
+5lrD2CNWEuDVUN/AXzl5qeFvOTkhOd99H6zZyidmvaD4sQOyrZ0m/+wRti4cE0GQ
+O2Gfjf0yA2rWevc9XSGfyk1yBLlkVTD583zjTdFj1NguywH8UVhEQMJrHSPvmovc
+Tsttkrwl+w/y6HV7c91HrhOC2hihMq7C0ip6WaH6vIBYWNpcs+g/8CpiTVVxEJ68
+5dQrDouKyR26SmWT1zLZw8tyb4Q/7YmzcHbxAgMBAAGjggE2MIIBMjAJBgNVHRME
+AjAAMBEGCWCGSAGG+EIBAQQEAwIFoDAzBglghkgBhvhCAQ0EJhYkT3BlblNTTCBH
+ZW5lcmF0ZWQgQ2xpZW50IENlcnRpZmljYXRlMB0GA1UdDgQWBBSJ/yia067wCafe
+kDCgk+e8PJTCUDAfBgNVHSMEGDAWgBRMcIY7FVKJurUPkqqusTFBE75z8zAOBgNV
+HQ8BAf8EBAMCBeAwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMEMDsGA1Ud
+HwQ0MDIwMKAuoCyGKmh0dHA6Ly9sb2NhbGhvc3Q6OTg3OC9pbnRlcm1lZGlhdGUu
+Y3JsLnBlbTAxBggrBgEFBQcBAQQlMCMwIQYIKwYBBQUHMAGGFWh0dHA6Ly9sb2Nh
+bGhvc3Q6OTg3NzANBgkqhkiG9w0BAQsFAAOCAgEAN2XfYgbrjxC6OWh9UoMLQaDD
+59JPxAUBxlRtWzTWqxY2jfT+OwJfDP4e+ef2G1YEG+qyt57ddlm/EwX9IvAvG0D4
+wd4tfItG88IJWKDM3wpT5KYrUsu+PlQTFmGmaWlORK/mRKlmfjbP5CIAcUedvCS9
+j9PkCrbbkklAmp0ULLSLUkYajmfFOkQ+VdGhQ6nAamTeyh2Z2S4dVjsKc8yBViMo
+/V6HP56rOvUqiVTcvhZtH7QDptMSTzuJ+AsmreYjwIiTGzYS/i8QVAFuPfXJKEOB
+jD5WhUaP/8Snbuft4MxssPAph8okcmxLfb55nw+soNc2oS1wWwKMe7igRelq8vtg
+bu00QSEGiY1eq/vFgZh0+Wohy/YeYzhO4Jq40FFpKiVbkLzexpNH/Afj2QrHuZ7y
+259uGGfv5tGA+TW6PsckCQknEb5V4V35ZZlbWVRKpuADeNPoDuoYPtc5eOomIkmw
+rFz/gPZWSA+4pYEgXgqcaM8+KP0i53eTbWqwy5DVgXiuaTYWU4m1FTsIZ+/nGIqW
+Dsgqd/D6jivf9Yvm+VFYTZsxIfq5sMdjxSuMBo0nZrzFDpqc6m6fVVoHv5R9Yliw
+MbxgmFQ84CKLy7iNKGSGVN2SIr1obMQ0e/t3NiCHib3WKzmZFoNoFCtVzAgsxGmF
+Q6rY83JdIPPW4LqZNcE=
+-----END CERTIFICATE-----

+ 6 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/index.txt

@@ -0,0 +1,6 @@
+V	330419130816Z		1000	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=localhost
+V	330419130816Z		1001	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=MyClient
+R	330419130816Z	230112130816Z	1002	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=client-revoked
+V	330419130816Z		1003	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=ocsp.server
+V	330419130816Z		1004	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=ocsp.client
+V	330425123656Z		1005	unknown	/C=SE/ST=Stockholm/L=Stockholm/O=MyOrgName/OU=MyIntermediateCA/CN=client-no-dist-points

+ 52 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/ocsp-issuer.key

@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+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
+xBO4CmF79pAcAXvDoRB619Flx5/G
+-----END PRIVATE KEY-----

+ 34 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/ocsp-issuer.pem

@@ -0,0 +1,34 @@
+-----BEGIN CERTIFICATE-----
+MIIF+zCCA+OgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwbzELMAkGA1UEBhMCU0Ux
+EjAQBgNVBAgMCVN0b2NraG9sbTESMBAGA1UEBwwJU3RvY2tob2xtMRIwEAYDVQQK
+DAlNeU9yZ05hbWUxETAPBgNVBAsMCE15Um9vdENBMREwDwYDVQQDDAhNeVJvb3RD
+QTAeFw0yMzAxMTIxMzA4MTZaFw0zMzAxMDkxMzA4MTZaMGsxCzAJBgNVBAYTAlNF
+MRIwEAYDVQQIDAlTdG9ja2hvbG0xEjAQBgNVBAoMCU15T3JnTmFtZTEZMBcGA1UE
+CwwQTXlJbnRlcm1lZGlhdGVDQTEZMBcGA1UEAwwQTXlJbnRlcm1lZGlhdGVDQTCC
+AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALQG7dMeU/y9HDNHzhydR0bm
+wN9UGplqJOJPwqJRaZZcrn9umgJ9SU2il2ceEVxMDwzBWCRKJO5/H9A9k13SqsXM
+2c2c9xXfIF1kb820lCm1Uow5hZ/auDjxliNk9kNJDigCRi3QoIs/dVeWzFsgEC2l
+gxRqauN2eNFb6/yXY788YALHBsCRV2NFOFXxtPsvLXpD9Q/8EqYsSMuLARRdHVNU
+ryaEF5lhShpcuz0TlIuTy2TiuXJUtJ+p7a4Z7friZ6JsrmQWsVQBj44F8TJRHWzW
+C7vm9c+dzEX9eqbr5iPL+L4ctMW9Lz6ePcYfIXne6CElusRUf8G+xM1uwovF9bpV
++9IqY7tAu9G1iY9iNtJgNNDKOCcOGKcZCx6Cg1XYOEKReNnUMazvYeqRrrjV5WQ0
+vOcD5zcBRNTXCddCLa7U0guXP9mQrfuk4NTH1Bt77JieTJ8cfDXHwtaKf6aGbmZP
+wl1Xi/GuXNUP/xeog78RKyFwBmjt2JKwvWzMpfmH4mEkG9moh2alva+aEz6LIJuP
+16g6s0Q6c793/OvUtpNcewHw4Vjn39LD9o6VLp854G4n8dVpUWSbWS+sXD1ZE69H
+g/sMNMyq+09ufkbewY8xoCm/rQ1pqDZAVMWsstJEaYu7b/eb7R+RGOj1YECCV/Yp
+EZPdDotbSNRkIi2d/a1NAgMBAAGjgaQwgaEwHQYDVR0OBBYEFExwhjsVUom6tQ+S
+qq6xMUETvnPzMB8GA1UdIwQYMBaAFD90kfU5pc5l48THu0Ayj9SNpHuhMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgGGMDsGA1UdHwQ0MDIwMKAuoCyG
+Kmh0dHA6Ly9sb2NhbGhvc3Q6OTg3OC9pbnRlcm1lZGlhdGUuY3JsLnBlbTANBgkq
+hkiG9w0BAQsFAAOCAgEAK6NgdWQYtPNKQNBGjsgtgqTRh+k30iqSO6Y3yE1KGABO
+EuQdVqkC2qUIbCB0M0qoV0ab50KNLfU6cbshggW4LDpcMpoQpI05fukNh1jm3ZuZ
+0xsB7vlmlsv00tpqmfIl/zykPDynHKOmFh/hJP/KetMy4+wDv4/+xP31UdEj5XvG
+HvMtuqOS23A+H6WPU7ol7KzKBnU2zz/xekvPbUD3JqV+ynP5bgbIZHAndd0o9T8e
+NFX23Us4cTenU2/ZlOq694bRzGaK+n3Ksz995Nbtzv5fbUgqmf7Mcq4iHGRVtV11
+MRyBrsXZp2vbF63c4hrf2Zd6SWRoaDKRhP2DMhajpH9zZASSTlfejg/ZRO2s+Clh
+YrSTkeMAdnRt6i/q4QRcOTCfsX75RFM5v67njvTXsSaSTnAwaPi78tRtf+WSh0EP
+VVPzy++BszBVlJ1VAf7soWZHCjZxZ8ZPqVTy5okoHwWQ09WmYe8GfulDh1oj0wbK
+3FjN7bODWHJN+bFf5aQfK+tumYKoPG8RXL6QxpEzjFWjxhIMJHHMKfDWnAV1o1+7
+/1/aDzq7MzEYBbrgQR7oE5ZHtyqhCf9LUgw0Kr7/8QWuNAdeDCJzjXRROU0hJczp
+dOyfRlLbHmLLmGOnROlx6LsGNQ17zuz6SPi7ei8/ylhykawDOAGkM1+xFakmQhM=
+-----END CERTIFICATE-----

+ 14 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/openssl_listeners.conf

@@ -0,0 +1,14 @@
+listeners.ssl.default {
+  bind = "0.0.0.0:8883"
+  max_connections = 512000
+  ssl_options {
+    keyfile = "{{ test_data_dir }}/server.key"
+    certfile = "{{ test_data_dir }}/server.pem"
+    cacertfile = "{{ test_data_dir }}/ca.pem"
+    ocsp {
+      enable_ocsp_stapling = true
+      issuer_pem = "{{ test_data_dir }}/ocsp-issuer.pem"
+      responder_url = "http://127.0.0.1:9877"
+    }
+  }
+}

+ 28 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/server.key

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCnVPRWgP59GU15
+HddFwPZflFfcSkeuWU8tgKQhZcNoBli4lIfemuoV/hkGRVFexAiAw3/u5wvOaMaN
+V8n9KxxgAUNLh5YaknpnNdhfQDyM0S5UJIbVeLzAQWxkBXpI3uBfW4WPSULRnVyR
+psLEfl1qOklGOyuZfRbkkkkVwtJEmGEH0kz0fy6xenn3R3/mTeIbj+5TNqiBXWn1
+/qgTiNf2Ni7SE6Nk2lP4V8iofcBIrsp6KtEWdipGEJZeXCg/X0g/qVt15tF1l00M
+uEWRHt1qGBELJJTcNzQvdqHAPz0AfQRjTtXyocw5+pFth8Q8a7gyjrjv5nhnpAKQ
+msrt3vyNAgMBAAECggEABnWvIQ/Fw0qQxRYz00uJt1LguW5cqgxklBsdOvTUwFVO
+Y4HIZP2R/9tZV/ahF4l10pK5g52DxSoiUB6Ne6qIY+RolqfbUZdKBmX7vmGadM02
+fqUSV3dbwghEiO/1Mo74FnZQB6IKZFEw26aWakN+k7VAUufB3SEJGzXSgHaO63ru
+dFGSiYI8U+q+YnhUJjCnmI12fycNfy451TdUQtGZb6pNmm5HRUF6hpAV8Le9LojP
+Ql9eacPpsrzU15X5ElCQZ/f9iNh1bplcISuhrULgKUKOvAVrBlEK67uRVy6g98xA
+c/rgNLkbL/jZEsAc3/vHAyFgd3lABfwpBGLHej3QgQKBgQDFNYmfBNQr89HC5Zc+
+M6jXcAT/R+0GNczBTfC4iyNemwqsumSSRelNZ748UefKuS3F6Mvb2CBqE2LbB61G
+hrnCffG2pARjZ491SefRwghhWWVGLP1p8KliLgOGBehA1REgJb+XULncjuHZuh4O
+LVn3HVnWGxeBGg+yKa6Z4YQi3QKBgQDZN0O8ZcZY74lRJ0UjscD9mJ1yHlsssZag
+njkX/f0GR/iVpfaIxQNC3gvWUy2LsU0He9sidcB0cfej0j/qZObQyFsCB0+utOgy
++hX7gokV2pes27WICbNWE2lJL4QZRJgvf82OaEy57kfDrm+eK1XaSZTZ10P82C9u
+gAmMnontcQKBgGu29lhY9tqa7jOZ26Yp6Uri8JfO3XPK5u+edqEVvlfqL0Zw+IW8
+kdWpmIqx4f0kcA/tO4v03J+TvycLZmVjKQtGZ0PvCkaRRhY2K9yyMomZnmtaH4BB
+5wKtR1do2pauyg/ZDnDDswD5OfsGYWw08TK8YVlEqu3lIjWZ9rguKVIxAoGAZYUk
+zVqr10ks3pcCA2rCjkPT4lA5wKvHgI4ylPoKVfMxRY/pp4acvZXV5ne9o7pcDBFh
+G7v5FPNnEFPlt4EtN4tMragJH9hBZgHoYEJkG6islweg0lHmVWaBIMlqbfzXO+v5
+gINSyNuLAvP2CvCqEXmubhnkFrpbgMOqsuQuBqECgYB3ss2PDhBF+5qoWgqymFof
+1ovRPuQ9sPjWBn5IrCdoYITDnbBzBZERx7GLs6A/PUlWgST7jkb1PY/TxYSUfXzJ
+SNd47q0mCQ+IUdqUbHgpK9b1ncwLMsnexpYZdHJWRLgnUhOx7OMjJc/4iLCAFCoN
+3KJ7/V1keo7GBHOwnsFcCA==
+-----END PRIVATE KEY-----

+ 35 - 0
apps/emqx/test/emqx_ocsp_cache_SUITE_data/server.pem

@@ -0,0 +1,35 @@
+-----BEGIN CERTIFICATE-----
+MIIGCTCCA/GgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCU0Ux
+EjAQBgNVBAgMCVN0b2NraG9sbTESMBAGA1UECgwJTXlPcmdOYW1lMRkwFwYDVQQL
+DBBNeUludGVybWVkaWF0ZUNBMRkwFwYDVQQDDBBNeUludGVybWVkaWF0ZUNBMB4X
+DTIzMDExMjEzMDgxNloXDTMzMDQxOTEzMDgxNloweDELMAkGA1UEBhMCU0UxEjAQ
+BgNVBAgMCVN0b2NraG9sbTESMBAGA1UEBwwJU3RvY2tob2xtMRIwEAYDVQQKDAlN
+eU9yZ05hbWUxGTAXBgNVBAsMEE15SW50ZXJtZWRpYXRlQ0ExEjAQBgNVBAMMCWxv
+Y2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKdU9FaA/n0Z
+TXkd10XA9l+UV9xKR65ZTy2ApCFlw2gGWLiUh96a6hX+GQZFUV7ECIDDf+7nC85o
+xo1Xyf0rHGABQ0uHlhqSemc12F9APIzRLlQkhtV4vMBBbGQFekje4F9bhY9JQtGd
+XJGmwsR+XWo6SUY7K5l9FuSSSRXC0kSYYQfSTPR/LrF6efdHf+ZN4huP7lM2qIFd
+afX+qBOI1/Y2LtITo2TaU/hXyKh9wEiuynoq0RZ2KkYQll5cKD9fSD+pW3Xm0XWX
+TQy4RZEe3WoYEQsklNw3NC92ocA/PQB9BGNO1fKhzDn6kW2HxDxruDKOuO/meGek
+ApCayu3e/I0CAwEAAaOCAagwggGkMAkGA1UdEwQCMAAwEQYJYIZIAYb4QgEBBAQD
+AgZAMDMGCWCGSAGG+EIBDQQmFiRPcGVuU1NMIEdlbmVyYXRlZCBTZXJ2ZXIgQ2Vy
+dGlmaWNhdGUwHQYDVR0OBBYEFGy5LQPzIelruJl7mL0mtUXM57XhMIGaBgNVHSME
+gZIwgY+AFExwhjsVUom6tQ+Sqq6xMUETvnPzoXOkcTBvMQswCQYDVQQGEwJTRTES
+MBAGA1UECAwJU3RvY2tob2xtMRIwEAYDVQQHDAlTdG9ja2hvbG0xEjAQBgNVBAoM
+CU15T3JnTmFtZTERMA8GA1UECwwITXlSb290Q0ExETAPBgNVBAMMCE15Um9vdENB
+ggIQADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwOwYDVR0f
+BDQwMjAwoC6gLIYqaHR0cDovL2xvY2FsaG9zdDo5ODc4L2ludGVybWVkaWF0ZS5j
+cmwucGVtMDEGCCsGAQUFBwEBBCUwIzAhBggrBgEFBQcwAYYVaHR0cDovL2xvY2Fs
+aG9zdDo5ODc3MA0GCSqGSIb3DQEBCwUAA4ICAQCX3EQgiCVqLhnCNd0pmptxXPxo
+l1KyZkpdrFa/NgSqRhkuZSAkszwBDDS/gzkHFKEUhmqs6/UZwN4+Rr3LzrHonBiN
+aQ6GeNNXZ/3xAQfUCwjjGmz9Sgw6kaX19Gnk2CjI6xP7T+O5UmsMI9hHUepC9nWa
+XX2a0hsO/KOVu5ZZckI16Ek/jxs2/HEN0epYdvjKFAaVmzZZ5PATNjrPQXvPmq2r
+x++La+3bXZsrH8P2FhPpM5t/IxKKW/Tlpgz92c2jVSIHF5khSA/MFDC+dk80OFmm
+v4ZTPIMuZ//Q+wo0f9P48rsL9D27qS7CA+8pn9wu+cfnBDSt7JD5Yipa1gHz71fy
+YTa9qRxIAPpzW2v7TFZE8eSKFUY9ipCeM2BbdmCQGmq4+v36b5TZoyjH4k0UVWGo
+Gclos2cic5Vxi8E6hb7b7yZpjEfn/5lbCiGMfAnI6aoOyrWg6keaRA33kaLUEZiK
+OgFNbPkjiTV0ZQyLXf7uK9YFhpVzJ0dv0CFNse8rZb7A7PLn8VrV/ZFnJ9rPoawn
+t7ZGxC0d5BRSEyEeEgsQdxuY4m8OkE18zwhCkt2Qs3uosOWlIrYmqSEa0i/sPSQP
+jiwB4nEdBrf8ZygzuYjT5T9YRSwhVox4spS/Av8Ells5JnkuKAhCVv9gHxYwbj0c
+CzyLJgE1z9Tq63m+gQ==
+-----END CERTIFICATE-----

+ 40 - 0
apps/emqx/test/emqx_schema_tests.erl

@@ -473,3 +473,43 @@ password_converter_test() ->
     ?assertEqual(<<"123">>, emqx_schema:password_converter(<<"123">>, #{})),
     ?assertThrow("must_quote", emqx_schema:password_converter(foobar, #{})),
     ok.
+
+url_type_test_() ->
+    [
+        ?_assertEqual(
+            {ok, <<"http://some.server/">>},
+            typerefl:from_string(emqx_schema:url(), <<"http://some.server/">>)
+        ),
+        ?_assertEqual(
+            {ok, <<"http://192.168.0.1/">>},
+            typerefl:from_string(emqx_schema:url(), <<"http://192.168.0.1">>)
+        ),
+        ?_assertEqual(
+            {ok, <<"http://some.server/">>},
+            typerefl:from_string(emqx_schema:url(), "http://some.server/")
+        ),
+        ?_assertEqual(
+            {ok, <<"http://some.server/">>},
+            typerefl:from_string(emqx_schema:url(), <<"http://some.server">>)
+        ),
+        ?_assertEqual(
+            {ok, <<"http://some.server:9090/">>},
+            typerefl:from_string(emqx_schema:url(), <<"http://some.server:9090">>)
+        ),
+        ?_assertEqual(
+            {ok, <<"https://some.server:9090/">>},
+            typerefl:from_string(emqx_schema:url(), <<"https://some.server:9090">>)
+        ),
+        ?_assertEqual(
+            {ok, <<"https://some.server:9090/path?q=uery">>},
+            typerefl:from_string(emqx_schema:url(), <<"https://some.server:9090/path?q=uery">>)
+        ),
+        ?_assertEqual(
+            {error, {unsupported_scheme, <<"postgres">>}},
+            typerefl:from_string(emqx_schema:url(), <<"postgres://some.server:9090">>)
+        ),
+        ?_assertEqual(
+            {error, empty_host_not_allowed},
+            typerefl:from_string(emqx_schema:url(), <<"">>)
+        )
+    ].

+ 42 - 13
apps/emqx/test/emqx_tls_lib_tests.erl

@@ -117,7 +117,7 @@ ssl_files_failure_test_() ->
             %% empty string
             ?assertMatch(
                 {error, #{
-                    reason := invalid_file_path_or_pem_string, which_options := [<<"keyfile">>]
+                    reason := invalid_file_path_or_pem_string, which_options := [[<<"keyfile">>]]
                 }},
                 emqx_tls_lib:ensure_ssl_files("/tmp", #{
                     <<"keyfile">> => <<>>,
@@ -128,7 +128,7 @@ ssl_files_failure_test_() ->
             %% not valid unicode
             ?assertMatch(
                 {error, #{
-                    reason := invalid_file_path_or_pem_string, which_options := [<<"keyfile">>]
+                    reason := invalid_file_path_or_pem_string, which_options := [[<<"keyfile">>]]
                 }},
                 emqx_tls_lib:ensure_ssl_files("/tmp", #{
                     <<"keyfile">> => <<255, 255>>,
@@ -136,6 +136,18 @@ ssl_files_failure_test_() ->
                     <<"cacertfile">> => bin(test_key())
                 })
             ),
+            ?assertMatch(
+                {error, #{
+                    reason := invalid_file_path_or_pem_string,
+                    which_options := [[<<"ocsp">>, <<"issuer_pem">>]]
+                }},
+                emqx_tls_lib:ensure_ssl_files("/tmp", #{
+                    <<"keyfile">> => bin(test_key()),
+                    <<"certfile">> => bin(test_key()),
+                    <<"cacertfile">> => bin(test_key()),
+                    <<"ocsp">> => #{<<"issuer_pem">> => <<255, 255>>}
+                })
+            ),
             %% not printable
             ?assertMatch(
                 {error, #{reason := invalid_file_path_or_pem_string}},
@@ -155,7 +167,8 @@ ssl_files_failure_test_() ->
                         #{
                             <<"cacertfile">> => bin(TmpFile),
                             <<"keyfile">> => bin(TmpFile),
-                            <<"certfile">> => bin(TmpFile)
+                            <<"certfile">> => bin(TmpFile),
+                            <<"ocsp">> => #{<<"issuer_pem">> => bin(TmpFile)}
                         }
                     )
                 )
@@ -170,22 +183,29 @@ ssl_files_save_delete_test() ->
     SSL0 = #{
         <<"keyfile">> => Key,
         <<"certfile">> => Key,
-        <<"cacertfile">> => Key
+        <<"cacertfile">> => Key,
+        <<"ocsp">> => #{<<"issuer_pem">> => Key}
     },
     Dir = filename:join(["/tmp", "ssl-test-dir"]),
     {ok, SSL} = emqx_tls_lib:ensure_ssl_files(Dir, SSL0),
-    File = maps:get(<<"keyfile">>, SSL),
-    ?assertMatch(<<"/tmp/ssl-test-dir/key-", _:16/binary>>, File),
-    ?assertEqual({ok, bin(test_key())}, file:read_file(File)),
+    FileKey = maps:get(<<"keyfile">>, SSL),
+    ?assertMatch(<<"/tmp/ssl-test-dir/key-", _:16/binary>>, FileKey),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileKey)),
+    FileIssuerPem = emqx_map_lib:deep_get([<<"ocsp">>, <<"issuer_pem">>], SSL),
+    ?assertMatch(<<"/tmp/ssl-test-dir/ocsp_issuer_pem-", _:16/binary>>, FileIssuerPem),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileIssuerPem)),
     %% no old file to delete
     ok = emqx_tls_lib:delete_ssl_files(Dir, SSL, undefined),
-    ?assertEqual({ok, bin(test_key())}, file:read_file(File)),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileKey)),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileIssuerPem)),
     %% old and new identical, no delete
     ok = emqx_tls_lib:delete_ssl_files(Dir, SSL, SSL),
-    ?assertEqual({ok, bin(test_key())}, file:read_file(File)),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileKey)),
+    ?assertEqual({ok, bin(test_key())}, file:read_file(FileIssuerPem)),
     %% new is gone, delete old
     ok = emqx_tls_lib:delete_ssl_files(Dir, undefined, SSL),
-    ?assertEqual({error, enoent}, file:read_file(File)),
+    ?assertEqual({error, enoent}, file:read_file(FileKey)),
+    ?assertEqual({error, enoent}, file:read_file(FileIssuerPem)),
     %% test idempotence
     ok = emqx_tls_lib:delete_ssl_files(Dir, undefined, SSL),
     ok.
@@ -198,7 +218,8 @@ ssl_files_handle_non_generated_file_test() ->
     SSL0 = #{
         <<"keyfile">> => TmpKeyFile,
         <<"certfile">> => TmpKeyFile,
-        <<"cacertfile">> => TmpKeyFile
+        <<"cacertfile">> => TmpKeyFile,
+        <<"ocsp">> => #{<<"issuer_pem">> => TmpKeyFile}
     },
     Dir = filename:join(["/tmp", "ssl-test-dir-00"]),
     {ok, SSL2} = emqx_tls_lib:ensure_ssl_files(Dir, SSL0),
@@ -216,24 +237,32 @@ ssl_file_replace_test() ->
     SSL0 = #{
         <<"keyfile">> => Key1,
         <<"certfile">> => Key1,
-        <<"cacertfile">> => Key1
+        <<"cacertfile">> => Key1,
+        <<"ocsp">> => #{<<"issuer_pem">> => Key1}
     },
     SSL1 = #{
         <<"keyfile">> => Key2,
         <<"certfile">> => Key2,
-        <<"cacertfile">> => Key2
+        <<"cacertfile">> => Key2,
+        <<"ocsp">> => #{<<"issuer_pem">> => Key2}
     },
     Dir = filename:join(["/tmp", "ssl-test-dir2"]),
     {ok, SSL2} = emqx_tls_lib:ensure_ssl_files(Dir, SSL0),
     {ok, SSL3} = emqx_tls_lib:ensure_ssl_files(Dir, SSL1),
     File1 = maps:get(<<"keyfile">>, SSL2),
     File2 = maps:get(<<"keyfile">>, SSL3),
+    IssuerPem1 = emqx_map_lib:deep_get([<<"ocsp">>, <<"issuer_pem">>], SSL2),
+    IssuerPem2 = emqx_map_lib:deep_get([<<"ocsp">>, <<"issuer_pem">>], SSL3),
     ?assert(filelib:is_regular(File1)),
     ?assert(filelib:is_regular(File2)),
+    ?assert(filelib:is_regular(IssuerPem1)),
+    ?assert(filelib:is_regular(IssuerPem2)),
     %% delete old file (File1, in SSL2)
     ok = emqx_tls_lib:delete_ssl_files(Dir, SSL3, SSL2),
     ?assertNot(filelib:is_regular(File1)),
     ?assert(filelib:is_regular(File2)),
+    ?assertNot(filelib:is_regular(IssuerPem1)),
+    ?assert(filelib:is_regular(IssuerPem2)),
     ok.
 
 bin(X) -> iolist_to_binary(X).

+ 1 - 1
apps/emqx_authz/test/emqx_authz_file_SUITE.erl

@@ -55,7 +55,7 @@ init_per_suite(Config) ->
 
 end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 init_per_testcase(_TestCase, Config) ->
     ok = emqx_authz_test_lib:reset_authorizers(),

+ 1 - 1
apps/emqx_authz/test/emqx_authz_http_SUITE.erl

@@ -52,7 +52,7 @@ init_per_suite(Config) ->
 end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
     ok = stop_apps([emqx_resource, cowboy]),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 set_special_configs(emqx_authz) ->
     ok = emqx_authz_test_lib:reset_authorizers();

+ 1 - 1
apps/emqx_authz/test/emqx_authz_mnesia_SUITE.erl

@@ -36,7 +36,7 @@ init_per_suite(Config) ->
 
 end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 init_per_testcase(_TestCase, Config) ->
     ok = emqx_authz_test_lib:reset_authorizers(),

+ 1 - 1
apps/emqx_authz/test/emqx_authz_mongodb_SUITE.erl

@@ -50,7 +50,7 @@ init_per_suite(Config) ->
 end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
     ok = stop_apps([emqx_resource]),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 set_special_configs(emqx_authz) ->
     ok = emqx_authz_test_lib:reset_authorizers();

+ 1 - 1
apps/emqx_authz/test/emqx_authz_mysql_SUITE.erl

@@ -57,7 +57,7 @@ end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
     ok = emqx_resource:remove_local(?MYSQL_RESOURCE),
     ok = stop_apps([emqx_resource]),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 init_per_testcase(_TestCase, Config) ->
     ok = emqx_authz_test_lib:reset_authorizers(),

+ 1 - 1
apps/emqx_authz/test/emqx_authz_postgresql_SUITE.erl

@@ -57,7 +57,7 @@ end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
     ok = emqx_resource:remove_local(?PGSQL_RESOURCE),
     ok = stop_apps([emqx_resource]),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 init_per_testcase(_TestCase, Config) ->
     ok = emqx_authz_test_lib:reset_authorizers(),

+ 1 - 1
apps/emqx_authz/test/emqx_authz_redis_SUITE.erl

@@ -58,7 +58,7 @@ end_per_suite(_Config) ->
     ok = emqx_authz_test_lib:restore_authorizers(),
     ok = emqx_resource:remove_local(?REDIS_RESOURCE),
     ok = stop_apps([emqx_resource]),
-    ok = emqx_common_test_helpers:stop_apps([emqx_authz]).
+    ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_authz]).
 
 init_per_testcase(_TestCase, Config) ->
     ok = emqx_authz_test_lib:reset_authorizers(),

+ 2 - 0
apps/emqx_bridge/test/emqx_bridge_webhook_SUITE.erl

@@ -42,6 +42,8 @@ init_per_suite(_Config) ->
     [].
 
 end_per_suite(_Config) ->
+    ok = emqx_config:put([bridges], #{}),
+    ok = emqx_config:put_raw([bridges], #{}),
     ok = emqx_common_test_helpers:stop_apps([emqx_conf, emqx_bridge]),
     ok = emqx_connector_test_helpers:stop_apps([emqx_resource]),
     _ = application:stop(emqx_connector),

+ 1 - 1
apps/emqx_dashboard/src/emqx_dashboard_listener.erl

@@ -163,7 +163,7 @@ diff_listeners(Type, Stop, Start) -> {#{Type => Stop}, #{Type => Start}}.
 
 ensure_ssl_cert(#{<<"listeners">> := #{<<"https">> := #{<<"enable">> := true}}} = Conf) ->
     Https = emqx_map_lib:deep_get([<<"listeners">>, <<"https">>], Conf, undefined),
-    Opts = #{required_keys => [<<"keyfile">>, <<"certfile">>, <<"cacertfile">>]},
+    Opts = #{required_keys => [[<<"keyfile">>], [<<"certfile">>], [<<"cacertfile">>]]},
     case emqx_tls_lib:ensure_ssl_files(?DIR, Https, Opts) of
         {ok, undefined} ->
             {error, <<"ssl_cert_not_found">>};

+ 12 - 3
apps/emqx_exhook/test/emqx_exhook_SUITE.erl

@@ -24,13 +24,13 @@
 -include_lib("eunit/include/eunit.hrl").
 -include_lib("common_test/include/ct.hrl").
 -include_lib("emqx/include/emqx_hooks.hrl").
+-include_lib("emqx_conf/include/emqx_conf.hrl").
 -include_lib("snabbkaffe/include/snabbkaffe.hrl").
 
 -define(DEFAULT_CLUSTER_NAME_ATOM, emqxcl).
 
 -define(OTHER_CLUSTER_NAME_ATOM, test_emqx_cluster).
 -define(OTHER_CLUSTER_NAME_STRING, "test_emqx_cluster").
--define(CLUSTER_RPC_SHARD, emqx_cluster_rpc_shard).
 
 -define(CONF_DEFAULT, <<
     "\n"
@@ -54,6 +54,8 @@
     "}\n"
 >>).
 
+-import(emqx_common_test_helpers, [on_exit/1]).
+
 %%--------------------------------------------------------------------
 %% Setups
 %%--------------------------------------------------------------------
@@ -89,7 +91,7 @@ init_per_testcase(_, Config) ->
     timer:sleep(200),
     Config.
 
-end_per_testcase(_, Config) ->
+end_per_testcase(_, _Config) ->
     case erlang:whereis(node()) of
         undefined ->
             ok;
@@ -97,7 +99,8 @@ end_per_testcase(_, Config) ->
             erlang:unlink(P),
             erlang:exit(P, kill)
     end,
-    Config.
+    emqx_common_test_helpers:call_janitor(),
+    ok.
 
 load_cfg(Cfg) ->
     ok = emqx_common_test_helpers:load_config(emqx_exhook_schema, Cfg).
@@ -300,6 +303,12 @@ t_cluster_name(_) ->
 
     emqx_common_test_helpers:stop_apps([emqx, emqx_exhook]),
     emqx_common_test_helpers:start_apps([emqx, emqx_exhook], SetEnvFun),
+    on_exit(fun() ->
+        emqx_common_test_helpers:stop_apps([emqx, emqx_exhook]),
+        load_cfg(?CONF_DEFAULT),
+        emqx_common_test_helpers:start_apps([emqx_exhook]),
+        mria:wait_for_tables([?CLUSTER_MFA, ?CLUSTER_COMMIT])
+    end),
 
     ?assertEqual(?OTHER_CLUSTER_NAME_STRING, emqx_sys:cluster_name()),
 

+ 1 - 1
apps/emqx_gateway/test/emqx_gateway_authn_SUITE.erl

@@ -77,7 +77,7 @@ init_per_suite(Config) ->
 end_per_suite(Config) ->
     emqx_gateway_auth_ct:stop(),
     emqx_config:erase(gateway),
-    emqx_mgmt_api_test_util:end_suite([cowboy, emqx_authn, emqx_gateway]),
+    emqx_mgmt_api_test_util:end_suite([cowboy, emqx_conf, emqx_authn, emqx_gateway]),
     Config.
 
 init_per_testcase(_Case, Config) ->

+ 6 - 3
apps/emqx_management/test/emqx_mgmt_api_test_util.erl

@@ -24,12 +24,15 @@ init_suite() ->
     init_suite([]).
 
 init_suite(Apps) ->
-    init_suite(Apps, fun set_special_configs/1).
+    init_suite(Apps, fun set_special_configs/1, #{}).
 
-init_suite(Apps, SetConfigs) ->
+init_suite(Apps, SetConfigs) when is_function(SetConfigs) ->
+    init_suite(Apps, SetConfigs, #{}).
+
+init_suite(Apps, SetConfigs, Opts) ->
     mria:start(),
     application:load(emqx_management),
-    emqx_common_test_helpers:start_apps(Apps ++ [emqx_dashboard], SetConfigs),
+    emqx_common_test_helpers:start_apps(Apps ++ [emqx_dashboard], SetConfigs, Opts),
     emqx_common_test_http:create_default_app().
 
 end_suite() ->

+ 25 - 18
apps/emqx_resource/test/emqx_resource_SUITE.erl

@@ -295,8 +295,15 @@ t_batch_query_counter(_) ->
             ok
         end,
         fun(Trace) ->
-            QueryTrace = ?of_kind(call_batch_query, Trace),
-            ?assertMatch([#{batch := BatchReq} | _] when length(BatchReq) > 1, QueryTrace)
+            QueryTrace = [
+                Event
+             || Event = #{
+                    ?snk_kind := call_batch_query,
+                    batch := BatchReq
+                } <- Trace,
+                length(BatchReq) > 1
+            ],
+            ?assertMatch([_ | _], QueryTrace)
         end
     ),
     {ok, NMsgs} = emqx_resource:query(?ID, get_counter),
@@ -648,19 +655,18 @@ t_query_counter_async_inflight_batch(_) ->
                 5_000
             ),
         fun(Trace) ->
-            QueryTrace = ?of_kind(call_batch_query_async, Trace),
-            ?assertMatch(
-                [
-                    #{
-                        batch := [
-                            {query, _, {inc_counter, 1}, _, _},
-                            {query, _, {inc_counter, 1}, _, _}
-                        ]
-                    }
-                    | _
-                ],
-                QueryTrace
-            )
+            QueryTrace = [
+                Event
+             || Event = #{
+                    ?snk_kind := call_batch_query_async,
+                    batch := [
+                        {query, _, {inc_counter, 1}, _, _},
+                        {query, _, {inc_counter, 1}, _, _}
+                    ]
+                } <-
+                    Trace
+            ],
+            ?assertMatch([_ | _], QueryTrace)
         end
     ),
     tap_metrics(?LINE),
@@ -1275,10 +1281,11 @@ t_retry_batch(_Config) ->
             %% each time should be the original batch (no duplicate
             %% elements or reordering).
             ExpectedSeenPayloads = lists:flatten(lists:duplicate(4, Payloads)),
-            ?assertEqual(
-                ExpectedSeenPayloads,
-                ?projection(n, ?of_kind(connector_demo_batch_inc_individual, Trace))
+            Trace1 = lists:sublist(
+                ?projection(n, ?of_kind(connector_demo_batch_inc_individual, Trace)),
+                length(ExpectedSeenPayloads)
             ),
+            ?assertEqual(ExpectedSeenPayloads, Trace1),
             ?assertMatch(
                 [#{n := ExpectedCount}],
                 ?of_kind(connector_demo_inc_counter, Trace)

+ 1 - 1
apps/emqx_retainer/rebar.config

@@ -27,7 +27,7 @@
 {profiles, [
     {test, [
         {deps, [
-            {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.5.0"}}}
+            {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.8.5"}}}
         ]}
     ]}
 ]}.

+ 1 - 0
changes/ce/feat-10128.en.md

@@ -0,0 +1 @@
+Add support for OCSP stapling and CRL check for SSL MQTT listeners.

+ 1 - 0
changes/ce/feat-10128.zh.md

@@ -0,0 +1 @@
+为 SSL MQTT 监听器增加对 OCSP Stapling 的支持。

+ 1 - 1
mix.exs

@@ -61,7 +61,7 @@ defmodule EMQXUmbrella.MixProject do
       {:ecpool, github: "emqx/ecpool", tag: "0.5.3", override: true},
       {:replayq, github: "emqx/replayq", tag: "0.3.7", override: true},
       {:pbkdf2, github: "emqx/erlang-pbkdf2", tag: "2.0.4", override: true},
-      {:emqtt, github: "emqx/emqtt", tag: "1.8.2", override: true},
+      {:emqtt, github: "emqx/emqtt", tag: "1.8.5", override: true},
       {:rulesql, github: "emqx/rulesql", tag: "0.1.4"},
       {:observer_cli, "1.7.1"},
       {:system_monitor, github: "ieQu1/system_monitor", tag: "3.0.3"},

+ 1 - 1
rebar.config

@@ -63,7 +63,7 @@
     , {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.3"}}}
     , {replayq, {git, "https://github.com/emqx/replayq.git", {tag, "0.3.7"}}}
     , {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}}
-    , {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.8.2"}}}
+    , {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.8.5"}}}
     , {rulesql, {git, "https://github.com/emqx/rulesql", {tag, "0.1.4"}}}
     , {observer_cli, "1.7.1"} % NOTE: depends on recon 2.5.x
     , {system_monitor, {git, "https://github.com/ieQu1/system_monitor", {tag, "3.0.3"}}}

+ 2 - 0
scripts/spellcheck/dicts/emqx.txt

@@ -12,6 +12,7 @@ CMD
 CN
 CONNACK
 CoAP
+CRLs
 Cygwin
 DES
 DN
@@ -41,6 +42,7 @@ Makefile
 MitM
 Multicast
 NIF
+OCSP
 OTP
 PEM
 PINGREQ