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_parser.erl b/apps/emqx_license/src/emqx_license_parser.erl index 05625902a..9a52d24fb 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,6 +52,7 @@ parse/1, parse/2, dump/1, + summary/1, customer_type/1, license_type/1, expiry_date/1, @@ -59,6 +67,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(). @@ -72,18 +83,37 @@ %%-------------------------------------------------------------------- -ifdef(TEST). --spec parse(string() | binary()) -> {ok, license()} | {error, term()}. -parse(Content) -> - PubKey = persistent_term:get(emqx_license_test_pubkey, ?PUBKEY), - parse(Content, PubKey). +pubkey() -> persistent_term:get(emqx_license_test_pubkey, ?PUBKEY). -else. --spec parse(string() | binary()) -> {ok, license()} | {error, term()}. -parse(Content) -> - parse(Content, ?PUBKEY). +pubkey() -> ?PUBKEY. -endif. -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(string() | binary()) -> {ok, license()} | {error, map()}. +parse(Content) -> + parse(iolist_to_binary(Content), 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 +121,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 +146,16 @@ 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. diff --git a/apps/emqx_license/src/emqx_license_parser_v20220101.erl b/apps/emqx_license/src/emqx_license_parser_v20220101.erl index faf003cf9..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. @@ -87,22 +103,24 @@ max_connections(#{max_connections := MaxConns}) -> do_parse(Content0) -> try - Content = trim(Content0), - [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. -trim(Bin) -> - trim(trim(Bin, $\n), $\r). +do_parse2(<<>>) -> + {error, empty_string}; +do_parse2(Content) -> + [EncodedPayload, EncodedSignature] = binary:split(Content, <<".">>), + Payload = base64:decode(EncodedPayload), + Signature = base64:decode(EncodedSignature), + {ok, {Payload, Signature}}. -trim(Bin, C) -> - [OneValue] = lists:filter(fun(X) -> X =/= <<>> end, binary:split(Bin, <>, [global])), - OneValue. +%% 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). @@ -190,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/test/emqx_license_SUITE.erl b/apps/emqx_license/test/emqx_license_SUITE.erl index 69adabe76..8a06fb540 100644 --- a/apps/emqx_license/test/emqx_license_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_SUITE.erl @@ -146,7 +146,7 @@ assert_on_nodes(Nodes, RunFun, CheckFun) -> t_update_value(_Config) -> ?assertMatch( - {error, [_ | _]}, + {error, #{parse_results := [_ | _]}}, emqx_license:update_key("invalid.license") ), diff --git a/apps/emqx_license/test/emqx_license_checker_SUITE.erl b/apps/emqx_license/test/emqx_license_checker_SUITE.erl index a4ef1af6e..ce9945dd5 100644 --- a/apps/emqx_license/test/emqx_license_checker_SUITE.erl +++ b/apps/emqx_license/test/emqx_license_checker_SUITE.erl @@ -14,12 +14,17 @@ all() -> emqx_common_test_helpers:all(?MODULE). -init_per_suite(Config) -> +init_per_suite(CtConfig) -> _ = application:load(emqx_conf), + ok = persistent_term:put( + emqx_license_test_pubkey, + emqx_license_test_lib:public_key_pem() + ), ok = emqx_common_test_helpers:start_apps([emqx_license], fun set_special_configs/1), - Config. + CtConfig. end_per_suite(_) -> + persistent_term:erase(emqx_license_test_pubkey), ok = emqx_common_test_helpers:stop_apps([emqx_license]). init_per_testcase(t_default_limits, Config) -> @@ -35,7 +40,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 +105,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 +233,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_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..de5e597ea 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,8 @@ make_license(Values) -> EncodedSignature = base64:encode(Signature), iolist_to_binary([EncodedText, ".", EncodedSignature]). +default_test_license() -> + make_license(#{}). + default_license() -> emqx_license_schema:default_license(). diff --git a/rel/i18n/emqx_license_schema.hocon b/rel/i18n/emqx_license_schema.hocon index 51387ed39..d17f717d3 100644 --- a/rel/i18n/emqx_license_schema.hocon +++ b/rel/i18n/emqx_license_schema.hocon @@ -25,7 +25,15 @@ 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 two 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://. + +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 every 2 minutes. +Any failure in reloading the license key will be recorded as an error level log message, +without causing system downtime.""" key_field.label: """License string"""