diff --git a/lib-ee/emqx_license/i18n/emqx_license_http_api.conf b/lib-ee/emqx_license/i18n/emqx_license_http_api.conf new file mode 100644 index 000000000..6b2c7a687 --- /dev/null +++ b/lib-ee/emqx_license/i18n/emqx_license_http_api.conf @@ -0,0 +1,23 @@ +emqx_license_http_api { + desc_license_info_api { + desc { + en: "Get license info" + zh: "获取许可证信息" + } + label: { + en: "License info" + zh: "许可证信息" + } + } + + desc_license_upload_api { + desc { + en: "Upload a license file or key" + zh: "上传许可证文件或钥匙" + } + label: { + en: "Update license" + zh: "更新许可证" + } + } +} diff --git a/lib-ee/emqx_license/src/emqx_license_http_api.erl b/lib-ee/emqx_license/src/emqx_license_http_api.erl new file mode 100644 index 000000000..b204583ba --- /dev/null +++ b/lib-ee/emqx_license/src/emqx_license_http_api.erl @@ -0,0 +1,142 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_license_http_api). + +-behaviour(minirest_api). + +-include_lib("hocon/include/hoconsc.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-export([ + namespace/0, + api_spec/0, + paths/0, + schema/1 +]). + +-export([ + '/license'/2, + '/license/upload'/2 +]). + +-define(BAD_REQUEST, 'BAD_REQUEST'). +-define(NOT_FOUND, 'NOT_FOUND'). + +namespace() -> "license_http_api". + +api_spec() -> + emqx_dashboard_swagger:spec(?MODULE, #{check_schema => false}). + +paths() -> + [ + "/license", + "/license/upload" + ]. + +schema("/license") -> + #{ + 'operationId' => '/license', + get => #{ + tags => [<<"license">>], + summary => <<"Get license info">>, + description => ?DESC("desc_license_info_api"), + responses => #{ + 200 => emqx_dashboard_swagger:schema_with_examples( + map(), + #{ + sample_license_info => #{ + value => #{ + customer => "Foo", + customer_type => 10, + deployment => "bar-deployment", + email => "contact@foo.com", + expiry => false, + expiry_at => "2295-10-27", + max_connections => 10, + start_at => "2022-01-11", + type => "trial" + } + } + } + ) + } + } + }; +schema("/license/upload") -> + #{ + 'operationId' => '/license/upload', + post => #{ + tags => [<<"license">>], + summary => <<"Upload license">>, + description => ?DESC("desc_license_upload_api"), + 'requestBody' => emqx_dashboard_swagger:schema_with_examples( + emqx_license_schema:license_type(), + #{ + license_key => #{ + summary => <<"License key string">>, + value => #{ + <<"key">> => <<"xxx">>, + <<"connection_low_watermark">> => "75%", + <<"connection_high_watermark">> => "80%" + } + }, + license_file => #{ + summary => <<"Path to a license file">>, + value => #{ + <<"file">> => <<"/path/to/license">>, + <<"connection_low_watermark">> => "75%", + <<"connection_high_watermark">> => "80%" + } + } + } + ), + responses => #{ + 200 => <<"ok">>, + 400 => emqx_dashboard_swagger:error_codes([?BAD_REQUEST], <<"bad request">>), + 404 => emqx_dashboard_swagger:error_codes([?NOT_FOUND], <<"file not found">>) + } + } + }. + +'/license'(get, _Params) -> + License = maps:from_list(emqx_license_checker:dump()), + {200, License}. + +'/license/upload'(post, #{body := #{<<"file">> := Filepath}}) -> + case emqx_license:update_file(Filepath) of + {error, enoent} -> + ?SLOG(error, #{ + msg => "license_file_not_found", + path => Filepath + }), + {404, <<"file not found">>}; + {error, Error} -> + ?SLOG(error, #{ + msg => "bad_license_file", + reason => Error, + path => Filepath + }), + {400, <<"bad request">>}; + {ok, _} -> + ?SLOG(info, #{ + msg => "updated_license_file", + path => Filepath + }), + {200, <<"ok">>} + end; +'/license/upload'(post, #{body := #{<<"key">> := Key}}) -> + case emqx_license:update_key(Key) of + {error, Error} -> + ?SLOG(error, #{ + msg => "bad_license_key", + reason => Error + }), + {400, <<"bad request">>}; + {ok, _} -> + ?SLOG(info, #{msg => "updated_license_key"}), + {200, <<"ok">>} + end; +'/license/upload'(post, _Params) -> + {400, <<"bad request">>}. diff --git a/lib-ee/emqx_license/test/emqx_license_SUITE.erl b/lib-ee/emqx_license/test/emqx_license_SUITE.erl index a648595d2..08b3cb692 100644 --- a/lib-ee/emqx_license/test/emqx_license_SUITE.erl +++ b/lib-ee/emqx_license/test/emqx_license_SUITE.erl @@ -142,7 +142,6 @@ setup_test(TestCase, Config) when RawConfig = #{<<"type">> => file, <<"file">> => LicensePath}, emqx_config:put_raw([<<"license">>], RawConfig), ok = meck:new(emqx_license, [non_strict, passthrough, no_history, no_link]), - %% meck:expect(emqx_license, read_license, fun() -> {ok, License} end), meck:expect( emqx_license_parser, parse, diff --git a/lib-ee/emqx_license/test/emqx_license_http_api_SUITE.erl b/lib-ee/emqx_license/test/emqx_license_http_api_SUITE.erl new file mode 100644 index 000000000..06bf35867 --- /dev/null +++ b/lib-ee/emqx_license/test/emqx_license_http_api_SUITE.erl @@ -0,0 +1,210 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2022 EMQ Technologies Co., Ltd. All Rights Reserved. +%%-------------------------------------------------------------------- + +-module(emqx_license_http_api_SUITE). + +-compile(nowarn_export_all). +-compile(export_all). + +-include_lib("emqx/include/emqx_mqtt.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +%%------------------------------------------------------------------------------ +%% CT boilerplate +%%------------------------------------------------------------------------------ + +all() -> + emqx_common_test_helpers:all(?MODULE). + +init_per_suite(Config) -> + _ = application:load(emqx_conf), + emqx_config:save_schema_mod_and_names(emqx_license_schema), + ok = meck:new(emqx_license_parser, [non_strict, passthrough, no_history, no_link]), + ok = meck:expect( + emqx_license_parser, + parse, + fun(X) -> + emqx_license_parser:parse( + X, + emqx_license_test_lib:public_key_pem() + ) + end + ), + emqx_common_test_helpers:start_apps([emqx_license, emqx_dashboard], fun set_special_configs/1), + Config. + +end_per_suite(_) -> + emqx_common_test_helpers:stop_apps([emqx_license, emqx_dashboard]), + ok = meck:unload([emqx_license_parser]), + Config = #{type => file, file => emqx_license_test_lib:default_license()}, + emqx_config:put([license], Config), + RawConfig = #{<<"type">> => file, <<"file">> => emqx_license_test_lib:default_license()}, + emqx_config:put_raw([<<"license">>], RawConfig), + ok. + +set_special_configs(emqx_dashboard) -> + emqx_dashboard_api_test_helpers:set_default_config(<<"license_admin">>); +set_special_configs(emqx_license) -> + LicenseKey = emqx_license_test_lib:make_license(#{max_connections => "100"}), + Config = #{type => key, key => LicenseKey}, + emqx_config:put([license], Config), + RawConfig = #{<<"type">> => key, <<"key">> => LicenseKey}, + emqx_config:put_raw([<<"license">>], RawConfig); +set_special_configs(_) -> + ok. + +init_per_testcase(_TestCase, Config) -> + {ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000), + Config. + +end_per_testcase(_TestCase, _Config) -> + {ok, _} = reset_license(), + ok. + +%%------------------------------------------------------------------------------ +%% Helper fns +%%------------------------------------------------------------------------------ + +request(Method, Uri, Body) -> + emqx_dashboard_api_test_helpers:request(<<"license_admin">>, Method, Uri, Body). + +uri(Segments) -> + emqx_dashboard_api_test_helpers:uri(Segments). + +get_license() -> + maps:from_list(emqx_license_checker:dump()). + +default_license() -> + emqx_license_test_lib:make_license(#{max_connections => "100"}). + +reset_license() -> + emqx_license:update_key(default_license()). + +assert_untouched_license() -> + ?assertMatch( + #{max_connections := 100}, + get_license() + ). + +%%------------------------------------------------------------------------------ +%% Testcases +%%------------------------------------------------------------------------------ + +t_license_info(_Config) -> + Res = request(get, uri(["license"]), []), + ?assertMatch({ok, 200, _}, Res), + {ok, 200, Payload} = Res, + ?assertEqual( + #{ + <<"customer">> => <<"Foo">>, + <<"customer_type">> => 10, + <<"deployment">> => <<"bar-deployment">>, + <<"email">> => <<"contact@foo.com">>, + <<"expiry">> => false, + <<"expiry_at">> => <<"2295-10-27">>, + <<"max_connections">> => 100, + <<"start_at">> => <<"2022-01-11">>, + <<"type">> => <<"trial">> + }, + emqx_json:decode(Payload, [return_maps]) + ), + ok. + +t_license_upload_file_success(_Config) -> + NewKey = emqx_license_test_lib:make_license(#{max_connections => "999"}), + Path = "/tmp/new.lic", + ok = file:write_file(Path, NewKey), + try + ?assertEqual( + {ok, 200, <<"ok">>}, + request( + post, + uri(["license", "upload"]), + #{file => Path} + ) + ), + ?assertMatch( + #{max_connections := 999}, + get_license() + ), + ok + after + ok = file:delete(Path), + ok + end. + +t_license_upload_file_not_found(_Config) -> + ?assertEqual( + {ok, 404, <<"file not found">>}, + request( + post, + uri(["license", "upload"]), + #{file => "/tmp/inexistent.lic"} + ) + ), + assert_untouched_license(), + ok. + +t_license_upload_file_reading_error(_Config) -> + %% eisdir + Path = "/tmp/", + ?assertEqual( + {ok, 400, <<"bad request">>}, + request( + post, + uri(["license", "upload"]), + #{file => Path} + ) + ), + assert_untouched_license(), + ok. + +t_license_upload_file_bad_license(_Config) -> + Path = "/tmp/bad.lic", + ok = file:write_file(Path, <<"bad key">>), + try + ?assertEqual( + {ok, 400, <<"bad request">>}, + request( + post, + uri(["license", "upload"]), + #{file => Path} + ) + ), + assert_untouched_license(), + ok + after + ok = file:delete(Path), + ok + end. + +t_license_upload_key_success(_Config) -> + NewKey = emqx_license_test_lib:make_license(#{max_connections => "999"}), + ?assertEqual( + {ok, 200, <<"ok">>}, + request( + post, + uri(["license", "upload"]), + #{key => NewKey} + ) + ), + ?assertMatch( + #{max_connections := 999}, + get_license() + ), + ok. + +t_license_upload_key_bad_key(_Config) -> + BadKey = <<"bad key">>, + ?assertEqual( + {ok, 400, <<"bad request">>}, + request( + post, + uri(["license", "upload"]), + #{key => BadKey} + ) + ), + assert_untouched_license(), + ok. diff --git a/lib-ee/emqx_license/test/emqx_license_test_lib.erl b/lib-ee/emqx_license/test/emqx_license_test_lib.erl index d3f2b5bd7..af3912f75 100644 --- a/lib-ee/emqx_license/test/emqx_license_test_lib.erl +++ b/lib-ee/emqx_license/test/emqx_license_test_lib.erl @@ -47,6 +47,32 @@ test_key(Filename, Format) -> public_key:pem_entry_decode(PemEntry) end. +make_license(Values0 = #{}) -> + Defaults = #{ + license_format => "220111", + license_type => "0", + customer_type => "10", + name => "Foo", + email => "contact@foo.com", + deployment => "bar-deployment", + start_date => "20220111", + days => "100000", + max_connections => "10" + }, + Values1 = maps:merge(Defaults, Values0), + Keys = [ + license_format, + license_type, + customer_type, + name, + email, + deployment, + start_date, + days, + max_connections + ], + Values = lists:map(fun(K) -> maps:get(K, Values1) end, Keys), + make_license(Values); make_license(Values) -> Key = private_key(), Text = string:join(Values, "\n"), diff --git a/scripts/merge-i18n.escript b/scripts/merge-i18n.escript index 9f8ac91ff..e98631cfc 100755 --- a/scripts/merge-i18n.escript +++ b/scripts/merge-i18n.escript @@ -4,10 +4,12 @@ main(_) -> BaseConf = <<"">>, - Cfgs = get_all_cfgs("apps/"), - Conf = [merge(BaseConf, Cfgs), + Cfgs0 = get_all_cfgs("apps/"), + Cfgs1 = get_all_cfgs("lib-ee/"), + Conf0 = merge(BaseConf, Cfgs0), + Conf = [merge(Conf0, Cfgs1), io_lib:nl() - ], + ], ok = file:write_file("apps/emqx_dashboard/priv/i18n.conf", Conf). merge(BaseConf, Cfgs) ->