diff --git a/apps/emqx_license/src/emqx_license_checker.erl b/apps/emqx_license/src/emqx_license_checker.erl index da777ff84..198814fb9 100644 --- a/apps/emqx_license/src/emqx_license_checker.erl +++ b/apps/emqx_license/src/emqx_license_checker.erl @@ -5,12 +5,15 @@ -module(emqx_license_checker). -include("emqx_license.hrl"). +-include_lib("emqx/include/logger.hrl"). -include_lib("snabbkaffe/include/snabbkaffe.hrl"). -behaviour(gen_server). --define(CHECK_INTERVAL, 5000). --define(EXPIRY_ALARM_CHECK_INTERVAL, 24 * 60 * 60). +-define(CHECK_INTERVAL, timer:seconds(5)). +-define(REFRESH_INTERVAL, timer:minutes(2)). +-define(EXPIRY_ALARM_CHECK_INTERVAL, timer:hours(24)). + -define(OK(EXPR), try _ = begin @@ -56,7 +59,7 @@ start_link(LicenseFetcher) -> start_link(LicenseFetcher, CheckInterval) -> gen_server:start_link({local, ?MODULE}, ?MODULE, [LicenseFetcher, CheckInterval], []). --spec update(emqx_license_parser:license()) -> ok. +-spec update(emqx_license_parser:license()) -> map(). update(License) -> gen_server:call(?MODULE, {update, License}, infinity). @@ -94,15 +97,18 @@ init([LicenseFetcher, CheckInterval]) -> check_license_interval => CheckInterval, license => License }), - State = ensure_check_expiry_timer(State0), + State1 = ensure_refresh_timer(State0), + State = ensure_check_expiry_timer(State1), {ok, State}; {error, Reason} -> {stop, Reason} end. -handle_call({update, License}, _From, State) -> +handle_call({update, License}, _From, #{license := Old} = State) -> ok = expiry_early_alarm(License), - {reply, check_license(License), State#{license => License}}; + State1 = ensure_refresh_timer(State), + ok = log_new_license(Old, License), + {reply, check_license(License), State1#{license => License}}; handle_call(dump, _From, #{license := License} = State) -> {reply, emqx_license_parser:dump(License), State}; handle_call(purge, _From, State) -> @@ -123,6 +129,10 @@ handle_info(check_expiry_alarm, #{license := License} = State) -> ok = expiry_early_alarm(License), NewState = ensure_check_expiry_timer(State), {noreply, NewState}; +handle_info(refresh, State0) -> + State1 = refresh(State0), + NewState = ensure_refresh_timer(State1), + {noreply, NewState}; handle_info(_Msg, State) -> {noreply, State}. @@ -130,22 +140,59 @@ handle_info(_Msg, State) -> %% Private functions %%------------------------------------------------------------------------------ +refresh(#{license := #{source := <<"file://", _/binary>> = Source} = License} = State) -> + case emqx_license_parser:parse(Source) of + {ok, License} -> + ?tp(emqx_license_refresh_no_change, #{}), + %% no change + State; + {ok, NewLicense} -> + ok = log_new_license(License, NewLicense), + %% ensure alarm is set or cleared + ok = expiry_early_alarm(NewLicense), + ?tp(emqx_license_refresh_changed, #{new_license => NewLicense}), + State#{license => NewLicense}; + {error, Reason} -> + ?tp( + error, + emqx_license_refresh_failed, + Reason#{continue_with_license => emqx_license_parser:summary(License)} + ), + State + end; +refresh(State) -> + State. + +log_new_license(Old, New) -> + ?SLOG(info, #{ + msg => "new_license_loaded", + old_license => emqx_license_parser:summary(Old), + new_license => emqx_license_parser:summary(New) + }). + ensure_check_license_timer(#{check_license_interval := CheckInterval} = State) -> - cancel_timer(State, timer), - State#{timer => erlang:send_after(CheckInterval, self(), check_license)}. + ok = cancel_timer(State, check_timer), + State#{check_timer => erlang:send_after(CheckInterval, self(), check_license)}. ensure_check_expiry_timer(State) -> - cancel_timer(State, expiry_alarm_timer), + ok = cancel_timer(State, expiry_alarm_timer), Ref = erlang:send_after(?EXPIRY_ALARM_CHECK_INTERVAL, self(), check_expiry_alarm), State#{expiry_alarm_timer => Ref}. +%% refresh is to work with file:// license keys. +ensure_refresh_timer(State) -> + ok = cancel_timer(State, refresh_timer), + Ref = erlang:send_after(?REFRESH_INTERVAL, self(), refresh), + State#{refresh_timer => Ref}. + cancel_timer(State, Key) -> - _ = - case maps:find(Key, State) of - {ok, Ref} when is_reference(Ref) -> erlang:cancel_timer(Ref); - _ -> ok - end, - ok. + case maps:find(Key, State) of + {ok, Ref} when is_reference(Ref) -> + _ = erlang:cancel_timer(Ref), + ok; + _ -> + ok + end. check_license(License) -> DaysLeft = days_left(License), diff --git a/apps/emqx_license/src/emqx_license_http_api.erl b/apps/emqx_license/src/emqx_license_http_api.erl index 8f563300b..439632c75 100644 --- a/apps/emqx_license/src/emqx_license_http_api.erl +++ b/apps/emqx_license/src/emqx_license_http_api.erl @@ -54,7 +54,6 @@ schema("/license") -> ) } }, - %% TODO(5.x): It's a update action, should use PUT instead post => #{ tags => ?LICENSE_TAGS, summary => <<"Update license key">>, diff --git a/apps/emqx_license/src/emqx_license_parser.erl b/apps/emqx_license/src/emqx_license_parser.erl index 05625902a..88304a6db 100644 --- a/apps/emqx_license/src/emqx_license_parser.erl +++ b/apps/emqx_license/src/emqx_license_parser.erl @@ -32,7 +32,14 @@ -type license_type() :: ?OFFICIAL | ?TRIAL. --type license() :: #{module := module(), data := license_data()}. +-type license() :: #{ + %% the parser module which parsed the license + module := module(), + %% the parse result + data := license_data(), + %% the source of the license, e.g. "file://path/to/license/file" or "******" for license key + source := binary() +}. -export_type([ license_data/0, @@ -45,12 +52,19 @@ parse/1, parse/2, dump/1, + summary/1, customer_type/1, license_type/1, expiry_date/1, max_connections/1 ]). +%% for testing purpose +-export([ + default/0, + pubkey/0 +]). + %%-------------------------------------------------------------------- %% Behaviour %%-------------------------------------------------------------------- @@ -59,6 +73,9 @@ -callback dump(license_data()) -> list({atom(), term()}). +%% provide a summary map for logging purposes +-callback summary(license_data()) -> map(). + -callback customer_type(license_data()) -> customer_type(). -callback license_type(license_data()) -> license_type(). @@ -71,19 +88,37 @@ %% API %%-------------------------------------------------------------------- --ifdef(TEST). --spec parse(string() | binary()) -> {ok, license()} | {error, term()}. -parse(Content) -> - PubKey = persistent_term:get(emqx_license_test_pubkey, ?PUBKEY), - parse(Content, PubKey). --else. --spec parse(string() | binary()) -> {ok, license()} | {error, term()}. -parse(Content) -> - parse(Content, ?PUBKEY). --endif. +pubkey() -> ?PUBKEY. +default() -> emqx_license_schema:default_license(). -parse(Content, Pem) -> - [PemEntry] = public_key:pem_decode(Pem), +%% @doc Parse license key. +%% If the license key is prefixed with "file://path/to/license/file", +%% then the license key is read from the file. +-spec parse(default | string() | binary()) -> {ok, license()} | {error, map()}. +parse(Content) -> + parse(to_bin(Content), ?MODULE:pubkey()). + +parse(<<"default">>, PubKey) -> + parse(?MODULE:default(), PubKey); +parse(<<"file://", Path/binary>> = FileKey, PubKey) -> + case file:read_file(Path) of + {ok, Content} -> + case parse(Content, PubKey) of + {ok, License} -> + {ok, License#{source => FileKey}}; + {error, Reason} -> + {error, Reason#{ + license_file => Path + }} + end; + {error, Reason} -> + {error, #{ + license_file => Path, + read_error => Reason + }} + end; +parse(Content, PubKey) -> + [PemEntry] = public_key:pem_decode(PubKey), Key = public_key:pem_entry_decode(PemEntry), do_parse(iolist_to_binary(Content), Key, ?LICENSE_PARSE_MODULES, []). @@ -91,6 +126,10 @@ parse(Content, Pem) -> dump(#{module := Module, data := LicenseData}) -> Module:dump(LicenseData). +-spec summary(license()) -> map(). +summary(#{module := Module, data := Data}) -> + Module:summary(Data). + -spec customer_type(license()) -> customer_type(). customer_type(#{module := Module, data := LicenseData}) -> Module:customer_type(LicenseData). @@ -112,14 +151,21 @@ max_connections(#{module := Module, data := LicenseData}) -> %%-------------------------------------------------------------------- do_parse(_Content, _Key, [], Errors) -> - {error, lists:reverse(Errors)}; + {error, #{parse_results => lists:reverse(Errors)}}; do_parse(Content, Key, [Module | Modules], Errors) -> try Module:parse(Content, Key) of {ok, LicenseData} -> - {ok, #{module => Module, data => LicenseData}}; + {ok, #{module => Module, data => LicenseData, source => <<"******">>}}; {error, Error} -> - do_parse(Content, Key, Modules, [{Module, Error} | Errors]) + do_parse(Content, Key, Modules, [#{module => Module, error => Error} | Errors]) catch _Class:Error:Stacktrace -> - do_parse(Content, Key, Modules, [{Module, {Error, Stacktrace}} | Errors]) + do_parse(Content, Key, Modules, [ + #{module => Module, error => Error, stacktrace => Stacktrace} | Errors + ]) end. + +to_bin(A) when is_atom(A) -> + atom_to_binary(A); +to_bin(L) -> + iolist_to_binary(L). diff --git a/apps/emqx_license/src/emqx_license_parser_v20220101.erl b/apps/emqx_license/src/emqx_license_parser_v20220101.erl index 4b2d6dccd..decdc4822 100644 --- a/apps/emqx_license/src/emqx_license_parser_v20220101.erl +++ b/apps/emqx_license/src/emqx_license_parser_v20220101.erl @@ -21,6 +21,7 @@ -export([ parse/2, dump/1, + summary/1, customer_type/1, license_type/1, expiry_date/1, @@ -69,6 +70,21 @@ dump( {expiry, Expiry} ]. +summary( + #{ + deployment := Deployment, + date_start := DateStart, + max_connections := MaxConns + } = License +) -> + DateExpiry = expiry_date(License), + #{ + deployment => Deployment, + max_connections => MaxConns, + start_at => format_date(DateStart), + expiry_at => format_date(DateExpiry) + }. + customer_type(#{customer_type := CType}) -> CType. license_type(#{type := Type}) -> Type. @@ -85,17 +101,27 @@ max_connections(#{max_connections := MaxConns}) -> %% Private functions %%------------------------------------------------------------------------------ -do_parse(Content) -> +do_parse(Content0) -> try - [EncodedPayload, EncodedSignature] = binary:split(Content, <<".">>), - Payload = base64:decode(EncodedPayload), - Signature = base64:decode(EncodedSignature), - {ok, {Payload, Signature}} + Content = normalize(Content0), + do_parse2(Content) catch _:_ -> {error, bad_license_format} end. +do_parse2(<<>>) -> + {error, empty_string}; +do_parse2(Content) -> + [EncodedPayload, EncodedSignature] = binary:split(Content, <<".">>), + Payload = base64:decode(EncodedPayload), + Signature = base64:decode(EncodedSignature), + {ok, {Payload, Signature}}. + +%% drop whitespaces and newlines (CRLF) +normalize(Bin) -> + <<<> || <> <= Bin, C =/= $\s andalso C =/= $\n andalso C =/= $\r>>. + verify_signature(Payload, Signature, Key) -> public_key:verify(Payload, ?DIGEST_TYPE, Signature, Key). @@ -182,7 +208,7 @@ collect_fields(Fields) -> {FieldValues, []} -> {ok, maps:from_list(FieldValues)}; {_, Errors} -> - {error, lists:reverse(Errors)} + {error, maps:from_list(Errors)} end. format_date({Year, Month, Day}) -> diff --git a/apps/emqx_license/src/emqx_license_schema.erl b/apps/emqx_license/src/emqx_license_schema.erl index f2b91811e..4d62f9be4 100644 --- a/apps/emqx_license/src/emqx_license_schema.erl +++ b/apps/emqx_license/src/emqx_license_schema.erl @@ -38,8 +38,8 @@ tags() -> fields(key_license) -> [ {key, #{ - type => binary(), - default => default_license(), + type => hoconsc:union([default, binary()]), + default => <<"default">>, %% so it's not logged sensitive => true, required => true, diff --git a/apps/emqx_license/test/emqx_license_SUITE.erl b/apps/emqx_license/test/emqx_license_SUITE.erl index 69adabe76..4818ad9e6 100644 --- a/apps/emqx_license/test/emqx_license_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_SUITE.erl @@ -16,12 +16,14 @@ all() -> emqx_common_test_helpers:all(?MODULE). init_per_suite(Config) -> + emqx_license_test_lib:mock_parser(), _ = application:load(emqx_conf), emqx_config:save_schema_mod_and_names(emqx_license_schema), emqx_common_test_helpers:start_apps([emqx_license], fun set_special_configs/1), Config. end_per_suite(_) -> + emqx_license_test_lib:unmock_parser(), emqx_common_test_helpers:stop_apps([emqx_license]), ok. @@ -103,17 +105,7 @@ setup_test(TestCase, Config) when ), ok; (emqx_license) -> - LicensePath = filename:join(emqx_license:license_dir(), "emqx.lic"), - filelib:ensure_dir(LicensePath), - ok = file:write_file(LicensePath, LicenseKey), - LicConfig = #{type => file, file => LicensePath}, - emqx_config:put([license], LicConfig), - RawConfig = #{<<"type">> => file, <<"file">> => LicensePath}, - emqx_config:put_raw([<<"license">>], RawConfig), - ok = persistent_term:put( - emqx_license_test_pubkey, - emqx_license_test_lib:public_key_pem() - ), + set_special_configs(emqx_license), ok; (_) -> ok @@ -129,9 +121,9 @@ teardown_test(_TestCase, _Config) -> ok. set_special_configs(emqx_license) -> - Config = #{key => emqx_license_test_lib:default_license()}, + Config = #{key => default}, emqx_config:put([license], Config), - RawConfig = #{<<"key">> => emqx_license_test_lib:default_license()}, + RawConfig = #{<<"key">> => <<"default">>}, emqx_config:put_raw([<<"license">>], RawConfig); set_special_configs(_) -> ok. @@ -146,11 +138,11 @@ assert_on_nodes(Nodes, RunFun, CheckFun) -> t_update_value(_Config) -> ?assertMatch( - {error, [_ | _]}, + {error, #{parse_results := [_ | _]}}, emqx_license:update_key("invalid.license") ), - LicenseValue = emqx_license_test_lib:default_license(), + LicenseValue = emqx_license_test_lib:default_test_license(), ?assertMatch( {ok, #{}}, diff --git a/apps/emqx_license/test/emqx_license_checker_SUITE.erl b/apps/emqx_license/test/emqx_license_checker_SUITE.erl index a4ef1af6e..5733a09ce 100644 --- a/apps/emqx_license/test/emqx_license_checker_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_checker_SUITE.erl @@ -14,12 +14,14 @@ all() -> emqx_common_test_helpers:all(?MODULE). -init_per_suite(Config) -> +init_per_suite(CtConfig) -> _ = application:load(emqx_conf), + emqx_license_test_lib:mock_parser(), ok = emqx_common_test_helpers:start_apps([emqx_license], fun set_special_configs/1), - Config. + CtConfig. end_per_suite(_) -> + emqx_license_test_lib:unmock_parser(), ok = emqx_common_test_helpers:stop_apps([emqx_license]). init_per_testcase(t_default_limits, Config) -> @@ -35,7 +37,7 @@ end_per_testcase(_Case, _Config) -> ok. set_special_configs(emqx_license) -> - Config = #{key => emqx_license_test_lib:default_license()}, + Config = #{key => emqx_license_test_lib:default_test_license()}, emqx_config:put([license], Config); set_special_configs(_) -> ok. @@ -100,7 +102,7 @@ t_update(_Config) -> emqx_license_checker:limits() ). -t_update_by_timer(_Config) -> +t_check_by_timer(_Config) -> ?check_trace( begin ?wait_async_action( @@ -228,10 +230,111 @@ t_unknown_calls(_Config) -> some_msg = erlang:send(emqx_license_checker, some_msg), ?assertEqual(unknown, gen_server:call(emqx_license_checker, some_request)). +t_refresh_no_change(Config) when is_list(Config) -> + {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111), + #{} = emqx_license_checker:update(License), + ?check_trace( + begin + ?wait_async_action( + begin + erlang:send( + emqx_license_checker, + refresh + ) + end, + #{?snk_kind := emqx_license_refresh_no_change}, + 1000 + ) + end, + fun(Trace) -> + ?assertMatch([_ | _], ?of_kind(emqx_license_refresh_no_change, Trace)) + end + ). + +t_refresh_change(Config) when is_list(Config) -> + {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111), + #{} = emqx_license_checker:update(License), + {ok, License2} = write_test_license(Config, ?FUNCTION_NAME, 2, 222), + ?check_trace( + begin + ?wait_async_action( + begin + erlang:send( + emqx_license_checker, + refresh + ) + end, + #{?snk_kind := emqx_license_refresh_changed}, + 1000 + ) + end, + fun(Trace) -> + ?assertMatch( + [#{new_license := License2} | _], ?of_kind(emqx_license_refresh_changed, Trace) + ) + end + ). + +t_refresh_failure(Config) when is_list(Config) -> + Filename = test_license_file_name(Config, ?FUNCTION_NAME), + {ok, License} = write_test_license(Config, ?FUNCTION_NAME, 1, 111), + Summary = emqx_license_parser:summary(License), + #{} = emqx_license_checker:update(License), + ok = file:write_file(Filename, <<"invalid license">>), + ?check_trace( + begin + ?wait_async_action( + begin + erlang:send( + emqx_license_checker, + refresh + ) + end, + #{?snk_kind := emqx_license_refresh_failed}, + 1000 + ) + end, + fun(Trace) -> + ?assertMatch( + [#{continue_with_license := Summary} | _], + ?of_kind(emqx_license_refresh_failed, Trace) + ) + end + ). + %%------------------------------------------------------------------------------ %% Tests %%------------------------------------------------------------------------------ +write_test_license(Config, Name, ExpireInDays, Connections) -> + {NowDate, _} = calendar:universal_time(), + DateTomorrow = calendar:gregorian_days_to_date( + calendar:date_to_gregorian_days(NowDate) + ExpireInDays + ), + Fields = [ + "220111", + "1", + "0", + "Foo", + "contact@foo.com", + "bar", + format_date(DateTomorrow), + "1", + integer_to_list(Connections) + ], + FileName = test_license_file_name(Config, Name), + ok = write_license_file(FileName, Fields), + emqx_license_parser:parse(<<"file://", FileName/binary>>). + +test_license_file_name(Config, Name) -> + Dir = ?config(data_dir, Config), + iolist_to_binary(filename:join(Dir, atom_to_list(Name) ++ ".lic")). + +write_license_file(FileName, Fields) -> + EncodedLicense = emqx_license_test_lib:make_license(Fields), + ok = filelib:ensure_dir(FileName), + ok = file:write_file(FileName, EncodedLicense). + mk_license(Fields) -> EncodedLicense = emqx_license_test_lib:make_license(Fields), {ok, License} = emqx_license_parser:parse( diff --git a/apps/emqx_license/test/emqx_license_cli_SUITE.erl b/apps/emqx_license/test/emqx_license_cli_SUITE.erl index 1c6282262..ed6593aac 100644 --- a/apps/emqx_license/test/emqx_license_cli_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_cli_SUITE.erl @@ -24,15 +24,12 @@ end_per_suite(_) -> ok. init_per_testcase(_Case, Config) -> - ok = persistent_term:put( - emqx_license_test_pubkey, - emqx_license_test_lib:public_key_pem() - ), + emqx_license_test_lib:mock_parser(), {ok, _} = emqx_cluster_rpc:start_link(node(), emqx_cluster_rpc, 1000), Config. end_per_testcase(_Case, _Config) -> - persistent_term:erase(emqx_license_test_pubkey), + emqx_license_test_lib:unmock_parser(), ok. set_special_configs(emqx_license) -> diff --git a/apps/emqx_license/test/emqx_license_http_api_SUITE.erl b/apps/emqx_license/test/emqx_license_http_api_SUITE.erl index 4ee0c8c8e..3aa54feef 100644 --- a/apps/emqx_license/test/emqx_license_http_api_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_http_api_SUITE.erl @@ -19,6 +19,7 @@ all() -> emqx_common_test_helpers:all(?MODULE). init_per_suite(Config) -> + emqx_license_test_lib:mock_parser(), _ = application:load(emqx_conf), emqx_config:save_schema_mod_and_names(emqx_license_schema), emqx_common_test_helpers:start_apps([emqx_license, emqx_dashboard], fun set_special_configs/1), @@ -31,7 +32,7 @@ end_per_suite(_) -> emqx_config:put([license], Config), RawConfig = #{<<"key">> => LicenseKey}, emqx_config:put_raw([<<"license">>], RawConfig), - persistent_term:erase(emqx_license_test_pubkey), + emqx_license_test_lib:unmock_parser(), ok. set_special_configs(emqx_dashboard) -> @@ -48,10 +49,6 @@ set_special_configs(emqx_license) -> <<"connection_high_watermark">> => <<"80%">> }, emqx_config:put_raw([<<"license">>], RawConfig), - ok = persistent_term:put( - emqx_license_test_pubkey, - emqx_license_test_lib:public_key_pem() - ), ok; set_special_configs(_) -> ok. @@ -113,6 +110,19 @@ t_license_info(_Config) -> ), ok. +t_set_default_license(_Config) -> + NewKey = <<"default">>, + Res = request( + post, + uri(["license"]), + #{key => NewKey} + ), + ?assertMatch({ok, 200, _}, Res), + {ok, 200, Payload} = Res, + %% assert that it's not the string "default" returned + ?assertMatch(#{<<"customer">> := _}, emqx_utils_json:decode(Payload, [return_maps])), + ok. + t_license_upload_key_success(_Config) -> NewKey = emqx_license_test_lib:make_license(#{max_connections => "999"}), Res = request( diff --git a/apps/emqx_license/test/emqx_license_parser_SUITE.erl b/apps/emqx_license/test/emqx_license_parser_SUITE.erl index f2f24dc54..0315a8a0b 100644 --- a/apps/emqx_license/test/emqx_license_parser_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_parser_SUITE.erl @@ -40,6 +40,7 @@ set_special_configs(_) -> %%------------------------------------------------------------------------------ t_parse(_Config) -> + Parser = emqx_license_parser_v20220101, ?assertMatch({ok, _}, emqx_license_parser:parse(sample_license(), public_key_pem())), %% invalid version @@ -61,10 +62,7 @@ t_parse(_Config) -> ), ?assertMatch({error, _}, Res1), {error, Err1} = Res1, - ?assertEqual( - invalid_version, - proplists:get_value(emqx_license_parser_v20220101, Err1) - ), + ?assertMatch(#{error := invalid_version}, find_error(Parser, Err1)), %% invalid field number Res2 = emqx_license_parser:parse( @@ -87,9 +85,9 @@ t_parse(_Config) -> ), ?assertMatch({error, _}, Res2), {error, Err2} = Res2, - ?assertEqual( - unexpected_number_of_fields, - proplists:get_value(emqx_license_parser_v20220101, Err2) + ?assertMatch( + #{error := unexpected_number_of_fields}, + find_error(Parser, Err2) ), Res3 = emqx_license_parser:parse( @@ -110,14 +108,17 @@ t_parse(_Config) -> ), ?assertMatch({error, _}, Res3), {error, Err3} = Res3, - ?assertEqual( - [ - {type, invalid_license_type}, - {customer_type, invalid_customer_type}, - {date_start, invalid_date}, - {days, invalid_int_value} - ], - proplists:get_value(emqx_license_parser_v20220101, Err3) + ?assertMatch( + #{ + error := + #{ + type := invalid_license_type, + customer_type := invalid_customer_type, + date_start := invalid_date, + days := invalid_int_value + } + }, + find_error(Parser, Err3) ), Res4 = emqx_license_parser:parse( @@ -139,14 +140,17 @@ t_parse(_Config) -> ?assertMatch({error, _}, Res4), {error, Err4} = Res4, - ?assertEqual( - [ - {type, invalid_license_type}, - {customer_type, invalid_customer_type}, - {date_start, invalid_date}, - {days, invalid_int_value} - ], - proplists:get_value(emqx_license_parser_v20220101, Err4) + ?assertMatch( + #{ + error := + #{ + type := invalid_license_type, + customer_type := invalid_customer_type, + date_start := invalid_date, + days := invalid_int_value + } + }, + find_error(Parser, Err4) ), %% invalid signature @@ -189,14 +193,14 @@ t_parse(_Config) -> ), ?assertMatch({error, _}, Res5), {error, Err5} = Res5, - ?assertEqual( - invalid_signature, - proplists:get_value(emqx_license_parser_v20220101, Err5) + ?assertMatch( + #{error := invalid_signature}, + find_error(Parser, Err5) ), %% totally invalid strings as license ?assertMatch( - {error, [_ | _]}, + {error, #{parse_results := [#{error := bad_license_format}]}}, emqx_license_parser:parse( <<"badlicense">>, public_key_pem() @@ -204,7 +208,7 @@ t_parse(_Config) -> ), ?assertMatch( - {error, [_ | _]}, + {error, #{parse_results := [#{error := bad_license_format}]}}, emqx_license_parser:parse( <<"bad.license">>, public_key_pem() @@ -249,6 +253,20 @@ t_expiry_date(_Config) -> ?assertEqual({2295, 10, 27}, emqx_license_parser:expiry_date(License)). +t_empty_string(_Config) -> + ?assertMatch( + {error, #{ + parse_results := [ + #{ + error := empty_string, + module := emqx_license_parser_v20220101 + } + | _ + ] + }}, + emqx_license_parser:parse(<<>>) + ). + %%------------------------------------------------------------------------------ %% Helpers %%------------------------------------------------------------------------------ @@ -270,3 +288,10 @@ sample_license() -> "10" ] ). + +find_error(Module, #{parse_results := Results}) -> + find_error(Module, Results); +find_error(Module, [#{module := Module} = Result | _Results]) -> + Result; +find_error(Module, [_Result | Results]) -> + find_error(Module, Results). diff --git a/apps/emqx_license/test/emqx_license_test_lib.erl b/apps/emqx_license/test/emqx_license_test_lib.erl index 644b6959d..c2f6c01e6 100644 --- a/apps/emqx_license/test/emqx_license_test_lib.erl +++ b/apps/emqx_license/test/emqx_license_test_lib.erl @@ -7,17 +7,6 @@ -compile(nowarn_export_all). -compile(export_all). --define(DEFAULT_LICENSE_VALUES, [ - "220111", - "0", - "10", - "Foo", - "contact@foo.com", - "20220111", - "100000", - "10" -]). - private_key() -> test_key("pvt.key"). @@ -76,5 +65,18 @@ make_license(Values) -> EncodedSignature = base64:encode(Signature), iolist_to_binary([EncodedText, ".", EncodedSignature]). +default_test_license() -> + make_license(#{}). + default_license() -> emqx_license_schema:default_license(). + +mock_parser() -> + meck:new(emqx_license_parser, [non_strict, passthrough, no_history, no_link]), + meck:expect(emqx_license_parser, pubkey, fun() -> public_key_pem() end), + meck:expect(emqx_license_parser, default, fun() -> default_test_license() end), + ok. + +unmock_parser() -> + meck:unload(emqx_license_parser), + ok. diff --git a/changes/ee/feat-12016.en.md b/changes/ee/feat-12016.en.md new file mode 100644 index 000000000..36c5115f6 --- /dev/null +++ b/changes/ee/feat-12016.en.md @@ -0,0 +1,4 @@ +Enhanced license key management. + +EMQX can now load the license key from a specified file. This is enabled by setting the `license.key` configuration to a file path, which should be prefixed with `"file://"`. +Also added the ability to revert to the default trial license by setting `license.key = default`. This option simplifies the process of returning to the trial license if needed. diff --git a/rel/i18n/emqx_license_schema.hocon b/rel/i18n/emqx_license_schema.hocon index 51387ed39..e280af257 100644 --- a/rel/i18n/emqx_license_schema.hocon +++ b/rel/i18n/emqx_license_schema.hocon @@ -25,7 +25,16 @@ connection_low_watermark_field_deprecated.label: """deprecated use /license/setting instead""" key_field.desc: -"""License string""" +"""This configuration parameter is designated for the license key and supports below input formats: + +- Direct Key: Enter the secret key directly as a string value. +- File Path: Specify the path to a file that contains the secret key. Ensure the path starts with file://. +- "default": Use string value "default" to apply the default trial license. + +Note: An invalid license key or an incorrect file path may prevent EMQX from starting successfully. +If a file path is used, EMQX attempts to reload the license key from the file every 2 minutes. +Any failure in reloading the license file will be recorded as an error level log message, +and EMQX continues to apply the license loaded previously.""" key_field.label: """License string"""