diff --git a/apps/emqx/src/emqx_schema.erl b/apps/emqx/src/emqx_schema.erl index 1779457e1..dfeae6d64 100644 --- a/apps/emqx/src/emqx_schema.erl +++ b/apps/emqx/src/emqx_schema.erl @@ -28,6 +28,7 @@ -include("emqx_access_control.hrl"). -include_lib("typerefl/include/types.hrl"). -include_lib("hocon/include/hoconsc.hrl"). +-include_lib("logger.hrl"). -type duration() :: integer(). -type duration_s() :: integer(). @@ -3290,6 +3291,11 @@ naive_env_interpolation("$" ++ Maybe = Original) -> {ok, Path} -> filename:join([Path, Tail]); error -> + ?SLOG(warning, #{ + msg => "failed_to_resolve_env_variable", + env => Env, + original => Original + }), Original end; naive_env_interpolation(Other) -> diff --git a/apps/emqx/src/emqx_tls_lib.erl b/apps/emqx/src/emqx_tls_lib.erl index 2683d2a9d..db0996e56 100644 --- a/apps/emqx/src/emqx_tls_lib.erl +++ b/apps/emqx/src/emqx_tls_lib.erl @@ -620,9 +620,11 @@ ensure_bin(A) when is_atom(A) -> atom_to_binary(A, utf8). ensure_ssl_file_key(_SSL, []) -> ok; ensure_ssl_file_key(SSL, RequiredKeyPaths) -> - NotFoundRef = make_ref(), Filter = fun(KeyPath) -> - NotFoundRef =:= emqx_utils_maps:deep_get(KeyPath, SSL, NotFoundRef) + case emqx_utils_maps:deep_find(KeyPath, SSL) of + {not_found, _, _} -> true; + _ -> false + end end, case lists:filter(Filter, RequiredKeyPaths) of [] -> ok; diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl index 13fd18267..aec811e5d 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard.erl @@ -95,7 +95,7 @@ start_listeners(Listeners) -> end end, {[], []}, - listeners(Listeners) + listeners(ensure_ssl_cert(Listeners)) ), case ErrListeners of [] -> @@ -140,18 +140,18 @@ apps() -> listeners(Listeners) -> lists:filtermap( - fun({Protocol, Conf}) -> - maps:get(enable, Conf) andalso - begin - {Conf1, Bind} = ip_port(Conf), - {true, { - listener_name(Protocol), - Protocol, - Bind, - ranch_opts(Conf1), - proto_opts(Conf1) - }} - end + fun + ({Protocol, Conf = #{enable := true}}) -> + {Conf1, Bind} = ip_port(Conf), + {true, { + listener_name(Protocol), + Protocol, + Bind, + ranch_opts(Conf1), + proto_opts(Conf1) + }}; + ({_Protocol, #{enable := false}}) -> + false end, maps:to_list(Listeners) ). @@ -191,8 +191,8 @@ ranch_opts(Options) -> end, RanchOpts#{socket_opts => InetOpts ++ SocketOpts}. -proto_opts(Options) -> - maps:with([proxy_header], Options). +proto_opts(#{proxy_header := ProxyHeader}) -> + #{proxy_header => ProxyHeader}. filter_false(_K, false, S) -> S; filter_false(K, V, S) -> [{K, V} | S]. @@ -224,7 +224,7 @@ return_unauthorized(Code, Message) -> {401, #{ <<"WWW-Authenticate">> => - <<"Basic Realm=\"minirest-server\"">> + <<"Basic Realm=\"emqx-dashboard\"">> }, #{code => Code, message => Message}}. @@ -247,3 +247,9 @@ api_key_authorize(Req, Key, Secret) -> <<"Check api_key/api_secret">> ) end. + +ensure_ssl_cert(Listeners = #{https := Https0}) -> + Https1 = emqx_tls_lib:to_server_opts(tls, Https0), + Listeners#{https => maps:from_list(Https1)}; +ensure_ssl_cert(Listeners) -> + Listeners. diff --git a/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl index 1f14b02c0..783c00dad 100644 --- a/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl +++ b/apps/emqx_dashboard/test/emqx_dashboard_SUITE.erl @@ -36,8 +36,6 @@ -define(HOST, "http://127.0.0.1:18083"). -%% -define(API_VERSION, "v5"). - -define(BASE_PATH, "/api/v5"). -define(APP_DASHBOARD, emqx_dashboard). diff --git a/apps/emqx_dashboard/test/emqx_dashboard_https_SUITE.erl b/apps/emqx_dashboard/test/emqx_dashboard_https_SUITE.erl new file mode 100644 index 000000000..fefaeb7f1 --- /dev/null +++ b/apps/emqx_dashboard/test/emqx_dashboard_https_SUITE.erl @@ -0,0 +1,214 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020-2023 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_dashboard_https_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include("emqx_dashboard.hrl"). + +-define(NAME, 'https:dashboard'). +-define(HOST, "https://127.0.0.1:18084"). +-define(BASE_PATH, "/api/v5"). +-define(OVERVIEWS, [ + "alarms", + "banned", + "stats", + "metrics", + "listeners", + "clients", + "subscriptions" +]). + +all() -> + emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> Config. +end_per_suite(_Config) -> emqx_mgmt_api_test_util:end_suite([emqx_management]). + +init_per_testcase(_TestCase, Config) -> Config. +end_per_testcase(_TestCase, _Config) -> emqx_mgmt_api_test_util:end_suite([emqx_management]). + +t_default_ssl_cert(_Config) -> + Conf = #{dashboard => #{listeners => #{https => #{bind => 18084, enable => true}}}}, + validate_https(Conf, 512, default_ssl_cert(), verify_none), + ok. + +t_normal_ssl_cert(_Config) -> + MaxConnection = 1000, + Conf = #{ + dashboard => #{ + listeners => #{ + https => #{ + bind => 18084, + enable => true, + cacertfile => naive_env_interpolation(<<"${EMQX_ETC_DIR}/certs/cacert.pem">>), + certfile => naive_env_interpolation(<<"${EMQX_ETC_DIR}/certs/cert.pem">>), + keyfile => naive_env_interpolation(<<"${EMQX_ETC_DIR}/certs/key.pem">>), + max_connections => MaxConnection + } + } + } + }, + validate_https(Conf, MaxConnection, default_ssl_cert(), verify_none), + ok. + +t_verify_cacertfile(_Config) -> + MaxConnection = 1024, + DefaultSSLCert = default_ssl_cert(), + SSLCert = DefaultSSLCert#{cacertfile => <<"">>}, + %% default #{verify => verify_none} + Conf = #{ + dashboard => #{ + listeners => #{ + https => #{ + bind => 18084, + enable => true, + cacertfile => <<"">>, + max_connections => MaxConnection + } + } + } + }, + validate_https(Conf, MaxConnection, SSLCert, verify_none), + %% verify_peer but cacertfile is empty + VerifyPeerConf1 = emqx_utils_maps:deep_put( + [dashboard, listeners, https, verify], + Conf, + verify_peer + ), + emqx_common_test_helpers:load_config(emqx_dashboard_schema, VerifyPeerConf1), + ?assertMatch({error, [?NAME]}, emqx_dashboard:start_listeners()), + %% verify_peer and cacertfile is ok. + VerifyPeerConf2 = emqx_utils_maps:deep_put( + [dashboard, listeners, https, cacertfile], + VerifyPeerConf1, + naive_env_interpolation(<<"${EMQX_ETC_DIR}/certs/cacert.pem">>) + ), + validate_https(VerifyPeerConf2, MaxConnection, DefaultSSLCert, verify_peer), + ok. + +t_bad_certfile(_Config) -> + Conf = #{ + dashboard => #{ + listeners => #{ + https => #{ + bind => 18084, + enable => true, + certfile => <<"${EMQX_ETC_DIR}/certs/not_found_cert.pem">> + } + } + } + }, + emqx_common_test_helpers:load_config(emqx_dashboard_schema, Conf), + ?assertMatch({error, [?NAME]}, emqx_dashboard:start_listeners()), + ok. + +validate_https(Conf, MaxConnection, SSLCert, Verify) -> + emqx_common_test_helpers:load_config(emqx_dashboard_schema, Conf), + emqx_mgmt_api_test_util:init_suite([emqx_management], fun(X) -> X end), + assert_ranch_options(MaxConnection, SSLCert, Verify), + assert_https_request(), + emqx_mgmt_api_test_util:end_suite([emqx_management]). + +assert_ranch_options(MaxConnections0, SSLCert, Verify) -> + Middlewares = [emqx_dashboard_middleware, cowboy_router, cowboy_handler], + [ + ?NAME, + ranch_ssl, + #{ + max_connections := MaxConnections, + num_acceptors := _, + socket_opts := SocketOpts + }, + cowboy_tls, + #{ + env := #{ + dispatch := {persistent_term, ?NAME}, + options := #{ + name := ?NAME, + protocol := https, + protocol_options := #{proxy_header := false}, + security := [#{basicAuth := []}, #{bearerAuth := []}], + swagger_global_spec := _ + } + }, + middlewares := Middlewares, + proxy_header := false + } + ] = ranch_server:get_listener_start_args(?NAME), + ?assertEqual(MaxConnections0, MaxConnections), + ?assert(lists:member(inet, SocketOpts), SocketOpts), + #{ + backlog := 1024, + ciphers := Ciphers, + port := 18084, + send_timeout := 10000, + verify := Verify, + versions := Versions + } = SocketMaps = maps:from_list(SocketOpts -- [inet]), + %% without tlsv1.1 tlsv1 + ?assertMatch(['tlsv1.3', 'tlsv1.2'], Versions), + ?assert(Ciphers =/= []), + maps:foreach( + fun(K, ConfVal) -> + case maps:find(K, SocketMaps) of + {ok, File} -> ?assertEqual(naive_env_interpolation(ConfVal), File); + error -> ?assertEqual(<<"">>, ConfVal) + end + end, + SSLCert + ), + ?assertMatch( + #{ + env := #{dispatch := {persistent_term, ?NAME}}, + middlewares := Middlewares, + proxy_header := false + }, + ranch:get_protocol_options(?NAME) + ), + ok. + +assert_https_request() -> + Headers = emqx_dashboard_SUITE:auth_header_(), + lists:foreach( + fun(Path) -> + ApiPath = api_path([Path]), + ?assertMatch( + {ok, _}, + emqx_dashboard_SUITE:request_dashboard(get, ApiPath, Headers) + ) + end, + ?OVERVIEWS + ). + +api_path(Parts) -> + ?HOST ++ filename:join([?BASE_PATH | Parts]). + +naive_env_interpolation(Str0) -> + Str1 = emqx_schema:naive_env_interpolation(Str0), + %% assert all envs are replaced + ?assertNot(lists:member($$, Str1)), + Str1. + +default_ssl_cert() -> + #{ + cacertfile => <<"${EMQX_ETC_DIR}/certs/cacert.pem">>, + certfile => <<"${EMQX_ETC_DIR}/certs/cert.pem">>, + keyfile => <<"${EMQX_ETC_DIR}/certs/key.pem">> + }.