diff --git a/ejabberd.yml.example b/ejabberd.yml.example index ffc6a26c7..05501400b 100644 --- a/ejabberd.yml.example +++ b/ejabberd.yml.example @@ -655,6 +655,38 @@ language: "en" ## ## captcha_limit: 5 +###. ==== +###' ACME +## +## In order to use the acme certificate acquiring through "Let's Encrypt" +## an http listener has to be configured to listen to port 80 so that +## the authorization challenges posed by "Let's Encrypt" can be solved. +## +## A simple way of doing this would be to add the following in the listen +## configuration field: +## - +## port: 80 +## ip: "::" +## module: ejabberd_http + +acme: + + ## A contact mail that the ACME Certificate Authority can contact in case of + ## an authorization issue, such as a server-initiated certificate revocation. + ## It is not mandatory to provide an email address but it is highly suggested. + contact: "mailto:example-admin@example.com" + + + ## The ACME Certificate Authority URL. + ## This could either be: + ## - https://acme-v01.api.letsencrypt.org - (Default) for the production CA + ## - https://acme-staging.api.letsencrypt.org - for the staging CA + ## - http://localhost:4000 - for a local version of the CA + ca_url: "https://acme-v01.api.letsencrypt.org" + +## The directory in which certificates will be saved +cert_dir: "/usr/local/var/lib/ejabberd/" + ###. ======= ###' MODULES diff --git a/include/ejabberd_acme.hrl b/include/ejabberd_acme.hrl new file mode 100644 index 000000000..f48a6d8b9 --- /dev/null +++ b/include/ejabberd_acme.hrl @@ -0,0 +1,53 @@ + +-record(challenge, { + type = <<"http-01">> :: bitstring(), + status = pending :: pending | valid | invalid, + uri = "" :: url(), + token = <<"">> :: bitstring() + }). + +-record(data_acc, { + id :: list(), + ca_url :: url(), + key :: jose_jwk:key() + }). +-type data_acc() :: #data_acc{}. + +-record(data_cert, { + domain :: bitstring(), + pem :: pem(), + path :: string() + }). +-type data_cert() :: #data_cert{}. + +%% +%% Types +%% + +%% Acme configuration +-type acme_config() :: [{ca_url, url()} | {contact, bitstring()}]. + +%% The main data type that ejabberd_acme keeps +-type acme_data() :: proplist(). + +%% The list of certificates kept in data +-type data_certs() :: proplist(bitstring(), data_cert()). + +%% The certificate saved in pem format +-type pem() :: bitstring(). + +-type nonce() :: string(). +-type url() :: string(). +-type proplist() :: [{_, _}]. +-type proplist(X,Y) :: [{X,Y}]. +-type dirs() :: #{string() => url()}. +-type jws() :: map(). +-type handle_resp_fun() :: fun(({ok, proplist(), proplist()}) -> {ok, _, nonce()}). + +-type acme_challenge() :: #challenge{}. + +%% Options +-type account_opt() :: string(). +-type verbose_opt() :: string(). +-type domains_opt() :: string(). + diff --git a/rebar.config b/rebar.config index 323605f2a..359225843 100644 --- a/rebar.config +++ b/rebar.config @@ -30,6 +30,7 @@ {jiffy, ".*", {git, "https://github.com/davisp/jiffy", {tag, "0.14.8"}}}, {p1_oauth2, ".*", {git, "https://github.com/processone/p1_oauth2", {tag, "0.6.2"}}}, {luerl, ".*", {git, "https://github.com/rvirding/luerl", {tag, "v0.2"}}}, + {jose, ".*", {git, "git://github.com/potatosalad/erlang-jose.git", {tag, "1.8.4"}}}, {fs, ".*", {git, "https://github.com/synrc/fs.git", {tag, "2.12.0"}}}, {if_var_true, stun, {stun, ".*", {git, "https://github.com/processone/stun", {tag, "1.0.15"}}}}, {if_var_true, sip, {esip, ".*", {git, "https://github.com/processone/esip", {tag, "1.0.16"}}}}, diff --git a/src/acme_challenge.erl b/src/acme_challenge.erl new file mode 100644 index 000000000..f4fde4e73 --- /dev/null +++ b/src/acme_challenge.erl @@ -0,0 +1,145 @@ +-module(acme_challenge). + +-export ([key_authorization/2, + solve_challenge/3, + process/2, + register_hooks/1, + unregister_hooks/1, + acme_handler/3 + ]). +%% Challenge Types +%% ================ +%% 1. http-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.2 +%% 2. dns-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.3 +%% 3. tls-sni-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.4 +%% 4. (?) oob-01: https://tools.ietf.org/html/draft-ietf-acme-acme-05#section-7.5 + +-include("ejabberd.hrl"). +-include("logger.hrl"). +-include("xmpp.hrl"). +-include("ejabberd_http.hrl"). +-include("ejabberd_acme.hrl"). + +%% This is the default endpoint for the http challenge +%% This hooks is called from ejabberd_http +acme_handler(Handlers, _Host, Request) -> + case Request#request.path of + [<<".well-known">>|_] -> + [{[<<".well-known">>],acme_challenge}|Handlers]; + _ -> + Handlers + end. + +%% TODO: Maybe validate request here?? +process(LocalPath, _Request) -> + Result = ets_get_key_authorization(LocalPath), + {200, + [{<<"Content-Type">>, <<"text/plain">>}], + Result}. + +register_hooks(_Domain) -> + ?INFO_MSG("Registering hook for ACME HTTP headers", []), + ejabberd_hooks:add(http_request_handlers, ?MODULE, acme_handler, 50). + +unregister_hooks(_Domain) -> + ?INFO_MSG("Unregistering hook for ACME HTTP headers", []), + ejabberd_hooks:delete(http_request_handlers, ?MODULE, acme_handler, 50). + +-spec key_authorization(bitstring(), jose_jwk:key()) -> bitstring(). +key_authorization(Token, Key) -> + Thumbprint = jose_jwk:thumbprint(Key), + KeyAuthorization = erlang:iolist_to_binary([Token, <<".">>, Thumbprint]), + KeyAuthorization. + +-spec parse_challenge({proplist()}) -> {ok, acme_challenge()} | {error, _}. +parse_challenge(Challenge0) -> + try + {Challenge} = Challenge0, + {<<"type">>,Type} = proplists:lookup(<<"type">>, Challenge), + {<<"status">>,Status} = proplists:lookup(<<"status">>, Challenge), + {<<"uri">>,Uri} = proplists:lookup(<<"uri">>, Challenge), + {<<"token">>,Token} = proplists:lookup(<<"token">>, Challenge), + Res = + #challenge{ + type = Type, + status = list_to_atom(bitstring_to_list(Status)), + uri = bitstring_to_list(Uri), + token = Token + }, + {ok, Res} + catch + _:Error -> + {error, Error} + end. + + + +-spec solve_challenge(bitstring(), [{proplist()}], _) -> + {ok, url(), bitstring()} | {error, _}. +solve_challenge(ChallengeType, Challenges, Options) -> + ParsedChallenges = [parse_challenge(Chall) || Chall <- Challenges], + case lists:any(fun is_error/1, ParsedChallenges) of + true -> + ?ERROR_MSG("Error parsing challenges: ~p~n", [Challenges]), + {error, parse_challenge}; + false -> + case [C || {ok, C} <- ParsedChallenges, is_challenge_type(ChallengeType, C)] of + [Challenge] -> + solve_challenge1(Challenge, Options); + _ -> + ?ERROR_MSG("Challenge ~p not found in challenges: ~p~n", [ChallengeType, Challenges]), + {error, not_found} + end + end. + +-spec solve_challenge1(acme_challenge(), {jose_jwk:key(), string()}) -> + {ok, url(), bitstring()} | {error, _}. +solve_challenge1(Chal = #challenge{type = <<"http-01">>, token=Tkn}, Key) -> + KeyAuthz = key_authorization(Tkn, Key), + %% save_key_authorization(Chal, Tkn, KeyAuthz, HttpDir); + ets_put_key_authorization(Tkn, KeyAuthz), + {ok, Chal#challenge.uri, KeyAuthz}; +solve_challenge1(Challenge, _Key) -> + ?ERROR_MSG("Unkown Challenge Type: ~p", [Challenge]), + {error, unknown_challenge}. + + +-spec ets_put_key_authorization(bitstring(), bitstring()) -> ok. +ets_put_key_authorization(Tkn, KeyAuthz) -> + Tab = ets_get_acme_table(), + Key = [<<"acme-challenge">>, Tkn], + ets:insert(Tab, {Key, KeyAuthz}), + ok. + +-spec ets_get_key_authorization([bitstring()]) -> bitstring(). +ets_get_key_authorization(Key) -> + Tab = ets_get_acme_table(), + case ets:lookup(Tab, Key) of + [{Key, KeyAuthz}] -> + ets:delete(Tab, Key), + KeyAuthz; + _ -> + ?ERROR_MSG("Unable to serve key authorization in: ~p", [Key]), + <<"">> + end. + +-spec ets_get_acme_table() -> atom(). +ets_get_acme_table() -> + case ets:info(acme) of + undefined -> + ets:new(acme, [named_table, public]); + _ -> + acme + end. + +%% Useful functions + +is_challenge_type(DesiredType, #challenge{type = Type}) when DesiredType =:= Type -> + true; +is_challenge_type(_DesiredType, #challenge{type = _Type}) -> + false. + +-spec is_error({'error', _}) -> 'true'; + ({'ok', _}) -> 'false'. +is_error({error, _}) -> true; +is_error(_) -> false. diff --git a/src/ejabberd_acme.erl b/src/ejabberd_acme.erl new file mode 100644 index 000000000..e9636f1e5 --- /dev/null +++ b/src/ejabberd_acme.erl @@ -0,0 +1,1166 @@ +-module (ejabberd_acme). + +-export([%% Ejabberdctl Commands + get_certificates/1, + renew_certificates/0, + list_certificates/1, + revoke_certificate/1, + %% Command Options Validity + is_valid_account_opt/1, + is_valid_verbose_opt/1, + is_valid_domain_opt/1, + is_valid_revoke_cert/1, + %% Called by ejabberd_pkix + certificate_exists/1, + %% Key Related + generate_key/0, + to_public/1 + ]). + +-include("ejabberd.hrl"). +-include("logger.hrl"). +-include("xmpp.hrl"). + +-include("ejabberd_acme.hrl"). +-include_lib("public_key/include/public_key.hrl"). + +-export([opt_type/1]). + +-behavior(ejabberd_config). + +%% +%% Default ACME configuration +%% + +-define(DEFAULT_CONFIG_CONTACT, <<"mailto:example-admin@example.com">>). +-define(DEFAULT_CONFIG_CA_URL, "https://acme-v01.api.letsencrypt.org"). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Command Functions +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +%% +%% Check Validity of command options +%% + +-spec is_valid_account_opt(string()) -> boolean(). +is_valid_account_opt("old-account") -> true; +is_valid_account_opt("new-account") -> true; +is_valid_account_opt(_) -> false. + +-spec is_valid_verbose_opt(string()) -> boolean(). +is_valid_verbose_opt("plain") -> true; +is_valid_verbose_opt("verbose") -> true; +is_valid_verbose_opt(_) -> false. + +%% TODO: Make this check more complicated +-spec is_valid_domain_opt(string()) -> boolean(). +is_valid_domain_opt("all") -> true; +is_valid_domain_opt(DomainString) -> + case parse_domain_string(DomainString) of + [] -> + false; + _SeparatedDomains -> + true + end. + +-spec is_valid_revoke_cert(string()) -> boolean(). +is_valid_revoke_cert(DomainOrFile) -> + lists:prefix("file:", DomainOrFile) orelse + lists:prefix("domain:", DomainOrFile). + + + +%% +%% Get Certificate +%% + +-spec get_certificates(domains_opt()) -> string() | {'error', _}. +get_certificates(Domains) -> + try + CAUrl = get_config_ca_url(), + get_certificates0(CAUrl, Domains) + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, get_certificates} + end. + +-spec get_certificates0(url(), domains_opt()) -> string(). +get_certificates0(CAUrl, Domains) -> + %% Check if an account exists or create another one + {ok, _AccId, PrivateKey} = retrieve_or_create_account(CAUrl), + + get_certificates1(CAUrl, Domains, PrivateKey). + +-spec retrieve_or_create_account(url()) -> {'ok', string(), jose_jwk:key()}. +retrieve_or_create_account(CAUrl) -> + case read_account_persistent() of + none -> + create_save_new_account(CAUrl); + + {ok, AccId, CAUrl, PrivateKey} -> + {ok, AccId, PrivateKey}; + {ok, _AccId, _, _PrivateKey} -> + create_save_new_account(CAUrl) + end. + + +-spec get_certificates1(url(), domains_opt(), jose_jwk:key()) -> string(). +get_certificates1(CAUrl, "all", PrivateKey) -> + Hosts = get_config_hosts(), + get_certificates2(CAUrl, PrivateKey, Hosts); +get_certificates1(CAUrl, DomainString, PrivateKey) -> + Domains = parse_domain_string(DomainString), + Hosts = [list_to_bitstring(D) || D <- Domains], + get_certificates2(CAUrl, PrivateKey, Hosts). + +-spec get_certificates2(url(), jose_jwk:key(), [bitstring()]) -> string(). +get_certificates2(CAUrl, PrivateKey, Hosts) -> + %% Get a certificate for each host + PemCertKeys = [get_certificate(CAUrl, Host, PrivateKey) || Host <- Hosts], + + %% Save Certificates + SavedCerts = [save_certificate(Cert) || Cert <- PemCertKeys], + + %% Format the result to send back to ejabberdctl + format_get_certificates_result(SavedCerts). + +-spec format_get_certificates_result([{'ok', bitstring(), _} | + {'error', bitstring(), _}]) -> + string(). +format_get_certificates_result(Certs) -> + Cond = lists:all(fun(Cert) -> + not is_error(Cert) + end, Certs), + %% FormattedCerts = string:join([format_get_certificate(C) || C <- Certs], "\n"), + FormattedCerts = str:join([format_get_certificate(C) || C <- Certs], $\n), + case Cond of + true -> + Result = io_lib:format("Success:~n~s", [FormattedCerts]), + lists:flatten(Result); + _ -> + Result = io_lib:format("Error with one or more certificates~n~s", [FormattedCerts]), + lists:flatten(Result) + end. + +-spec format_get_certificate({'ok', bitstring(), _} | + {'error', bitstring(), _}) -> + string(). +format_get_certificate({ok, Domain, saved}) -> + io_lib:format(" Certificate for domain: \"~s\" acquired and saved", [Domain]); +format_get_certificate({ok, Domain, not_found}) -> + io_lib:format(" Certificate for domain: \"~s\" not found, so it was not renewed", [Domain]); +format_get_certificate({ok, Domain, exists}) -> + io_lib:format(" Certificate for domain: \"~s\" is not close to expiring", [Domain]); +format_get_certificate({error, Domain, Reason}) -> + io_lib:format(" Error for domain: \"~s\", with reason: \'~s\'", [Domain, Reason]). + +-spec get_certificate(url(), bitstring(), jose_jwk:key()) -> + {'ok', bitstring(), pem()} | + {'error', bitstring(), _}. +get_certificate(CAUrl, DomainName, PrivateKey) -> + try + AllSubDomains = find_all_sub_domains(DomainName), + lists:foreach( + fun(Domain) -> + {ok, _Authz} = create_new_authorization(CAUrl, Domain, PrivateKey) + end, [DomainName|AllSubDomains]), + create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey) + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, DomainName, get_certificate} + end. + +-spec create_save_new_account(url()) -> {'ok', string(), jose_jwk:key()} | no_return(). +create_save_new_account(CAUrl) -> + %% Get contact from configuration file + Contact = get_config_contact(), + + %% Generate a Key + PrivateKey = generate_key(), + + %% Create a new account + {ok, Id} = create_new_account(CAUrl, Contact, PrivateKey), + + %% Write Persistent Data + ok = write_account_persistent({Id, CAUrl, PrivateKey}), + + {ok, Id, PrivateKey}. + +%% TODO: +%% Find a way to ask the user if he accepts the TOS +-spec create_new_account(url(), bitstring(), jose_jwk:key()) -> {'ok', string()} | + no_return(). +create_new_account(CAUrl, Contact, PrivateKey) -> + try + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), + Req0 = [{ <<"contact">>, [Contact]}], + {ok, {TOS, Account}, Nonce1} = + ejabberd_acme_comm:new_account(Dirs, PrivateKey, Req0, Nonce0), + {<<"id">>, AccIdInt} = lists:keyfind(<<"id">>, 1, Account), + AccId = integer_to_list(AccIdInt), + Req1 = [{ <<"agreement">>, list_to_bitstring(TOS)}], + {ok, _Account2, _Nonce2} = + ejabberd_acme_comm:update_account({CAUrl, AccId}, PrivateKey, Req1, Nonce1), + {ok, AccId} + catch + E:R -> + ?ERROR_MSG("Error: ~p creating an account for contact: ~p", + [{E,R}, Contact]), + throw({error,create_new_account}) + end. + +-spec create_new_authorization(url(), bitstring(), jose_jwk:key()) -> + {'ok', proplist()} | no_return(). +create_new_authorization(CAUrl, DomainName, PrivateKey) -> + acme_challenge:register_hooks(DomainName), + try + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), + Req0 = [{<<"identifier">>, + {[{<<"type">>, <<"dns">>}, + {<<"value">>, DomainName}]}}, + {<<"existing">>, <<"accept">>}], + {ok, {AuthzUrl, Authz}, Nonce1} = + ejabberd_acme_comm:new_authz(Dirs, PrivateKey, Req0, Nonce0), + {ok, AuthzId} = location_to_id(AuthzUrl), + + Challenges = get_challenges(Authz), + {ok, ChallengeUrl, KeyAuthz} = + acme_challenge:solve_challenge(<<"http-01">>, Challenges, PrivateKey), + {ok, ChallengeId} = location_to_id(ChallengeUrl), + Req3 = [{<<"type">>, <<"http-01">>},{<<"keyAuthorization">>, KeyAuthz}], + {ok, _SolvedChallenge, _Nonce2} = ejabberd_acme_comm:complete_challenge( + {CAUrl, AuthzId, ChallengeId}, PrivateKey, Req3, Nonce1), + + {ok, AuthzValid, _Nonce} = ejabberd_acme_comm:get_authz_until_valid({CAUrl, AuthzId}), + {ok, AuthzValid} + catch + E:R -> + ?ERROR_MSG("Error: ~p getting an authorization for domain: ~p~n", + [{E,R}, DomainName]), + throw({error, DomainName, authorization}) + after + acme_challenge:unregister_hooks(DomainName) + end. + +-spec create_new_certificate(url(), {bitstring(), [bitstring()]}, jose_jwk:key()) -> + {ok, bitstring(), pem()}. +create_new_certificate(CAUrl, {DomainName, AllSubDomains}, PrivateKey) -> + try + {ok, Dirs, Nonce0} = ejabberd_acme_comm:directory(CAUrl), + CSRSubject = [{commonName, bitstring_to_list(DomainName)}], + SANs = [{dNSName, SAN} || SAN <- AllSubDomains], + {CSR, CSRKey} = make_csr(CSRSubject, SANs), + {NotBefore, NotAfter} = not_before_not_after(), + Req = + [{<<"csr">>, CSR}, + {<<"notBefore">>, NotBefore}, + {<<"NotAfter">>, NotAfter} + ], + {ok, {_CertUrl, Certificate}, _Nonce1} = + ejabberd_acme_comm:new_cert(Dirs, PrivateKey, Req, Nonce0), + + DecodedCert = public_key:pkix_decode_cert(list_to_binary(Certificate), plain), + PemEntryCert = public_key:pem_entry_encode('Certificate', DecodedCert), + + {_, CSRKeyKey} = jose_jwk:to_key(CSRKey), + PemEntryKey = public_key:pem_entry_encode('ECPrivateKey', CSRKeyKey), + + PemCertKey = public_key:pem_encode([PemEntryKey, PemEntryCert]), + + {ok, DomainName, PemCertKey} + catch + E:R -> + ?ERROR_MSG("Error: ~p getting an authorization for domain: ~p~n", + [{E,R}, DomainName]), + throw({error, DomainName, certificate}) + end. + +-spec ensure_account_exists(url()) -> {ok, string(), jose_jwk:key()}. +ensure_account_exists(CAUrl) -> + case read_account_persistent() of + none -> + ?ERROR_MSG("No existing account", []), + throw({error, no_old_account}); + {ok, AccId, CAUrl, PrivateKey} -> + {ok, AccId, PrivateKey}; + {ok, _AccId, OtherCAUrl, _PrivateKey} -> + ?ERROR_MSG("Account is connected to another CA: ~s", [OtherCAUrl]), + throw({error, account_in_other_CA}) + end. + + +%% +%% Renew Certificates +%% +-spec renew_certificates() -> string() | {'error', _}. +renew_certificates() -> + try + CAUrl = get_config_ca_url(), + renew_certificates0(CAUrl) + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, get_certificates} + end. + +-spec renew_certificates0(url()) -> string(). +renew_certificates0(CAUrl) -> + %% Get the current account + {ok, _AccId, PrivateKey} = ensure_account_exists(CAUrl), + + %% Find all hosts that we have certificates for + Certs = read_certificates_persistent(), + + %% Get a certificate for each host + PemCertKeys = [renew_certificate(CAUrl, Cert, PrivateKey) || Cert <- Certs], + + %% Save Certificates + SavedCerts = [save_renewed_certificate(Cert) || Cert <- PemCertKeys], + + %% Format the result to send back to ejabberdctl + format_get_certificates_result(SavedCerts). + +-spec renew_certificate(url(), {bitstring(), data_cert()}, jose_jwk:key()) -> + {'ok', bitstring(), _} | + {'error', bitstring(), _}. +renew_certificate(CAUrl, {DomainName, _} = Cert, PrivateKey) -> + case cert_to_expire(Cert) of + true -> + get_certificate(CAUrl, DomainName, PrivateKey); + false -> + {ok, DomainName, no_expire} + end. + + +-spec cert_to_expire({bitstring(), data_cert()}) -> boolean(). +cert_to_expire({_DomainName, #data_cert{pem = Pem}}) -> + Certificate = pem_to_certificate(Pem), + Validity = get_utc_validity(Certificate), + + %% 30 days before expiration + close_to_expire(Validity, 30). + +-spec close_to_expire(string(), integer()) -> boolean(). +close_to_expire(Validity, Days) -> + {ValidDate, _ValidTime} = utc_string_to_datetime(Validity), + ValidDays = calendar:date_to_gregorian_days(ValidDate), + + {CurrentDate, _CurrentTime} = calendar:universal_time(), + CurrentDays = calendar:date_to_gregorian_days(CurrentDate), + CurrentDays > ValidDays - Days. + + + +%% +%% List Certificates +%% +-spec list_certificates(verbose_opt()) -> [string()] | [any()] | {error, _}. +list_certificates(Verbose) -> + try + list_certificates0(Verbose) + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, list_certificates} + end. + +-spec list_certificates0(verbose_opt()) -> [string()] | [any()]. +list_certificates0(Verbose) -> + Certs = read_certificates_persistent(), + [format_certificate(DataCert, Verbose) || {_Key, DataCert} <- Certs]. + +%% TODO: Make this cleaner and more robust +-spec format_certificate(data_cert(), verbose_opt()) -> string(). +format_certificate(DataCert, Verbose) -> + #data_cert{ + domain = DomainName, + pem = PemCert, + path = Path + } = DataCert, + + try + Certificate = pem_to_certificate(PemCert), + + %% Find the commonName + _CommonName = get_commonName(Certificate), + + %% Find the notAfter date + NotAfter = get_notAfter(Certificate), + + %% Find the subjectAltNames + SANs = get_subjectAltNames(Certificate), + + case Verbose of + "plain" -> + format_certificate_plain(DomainName, SANs, NotAfter, Path); + "verbose" -> + format_certificate_verbose(DomainName, SANs, NotAfter, PemCert) + end + catch + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + fail_format_certificate(DomainName) + end. + +-spec format_certificate_plain(bitstring(), [string()], {expired | ok, string()}, string()) + -> string(). +format_certificate_plain(DomainName, SANs, NotAfter, Path) -> + Result = lists:flatten(io_lib:format( + " Domain: ~s~n" + "~s" + " ~s~n" + " Path: ~s", + [DomainName, + lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]), + format_validity(NotAfter), Path])), + Result. + +-spec format_certificate_verbose(bitstring(), [string()], {expired | ok, string()}, bitstring()) + -> string(). +format_certificate_verbose(DomainName, SANs, NotAfter, PemCert) -> + Result = lists:flatten(io_lib:format( + " Domain: ~s~n" + "~s" + " ~s~n" + " Certificate In PEM format: ~n~s", + [DomainName, + lists:flatten([io_lib:format(" SAN: ~s~n", [SAN]) || SAN <- SANs]), + format_validity(NotAfter), PemCert])), + Result. + +-spec format_validity({'expired' | 'ok', string()}) -> string(). +format_validity({expired, NotAfter}) -> + io_lib:format("Expired at: ~s UTC", [NotAfter]); +format_validity({ok, NotAfter}) -> + io_lib:format("Valid until: ~s UTC", [NotAfter]). + +-spec fail_format_certificate(bitstring()) -> string(). +fail_format_certificate(DomainName) -> + Result = lists:flatten(io_lib:format( + " Domain: ~s~n" + " Failed to format Certificate", + [DomainName])), + Result. + +-spec get_commonName(#'Certificate'{}) -> string(). +get_commonName(#'Certificate'{tbsCertificate = TbsCertificate}) -> + #'TBSCertificate'{ + subject = {rdnSequence, SubjectList} + } = TbsCertificate, + + %% TODO: Not the best way to find the commonName + ShallowSubjectList = [Attribute || [Attribute] <- SubjectList], + {_, _, CommonName} = lists:keyfind(attribute_oid(commonName), 2, ShallowSubjectList), + + %% TODO: Remove the length-encoding from the commonName before returning it + CommonName. + +-spec get_notAfter(#'Certificate'{}) -> {expired | ok, string()}. +get_notAfter(Certificate) -> + UtcTime = get_utc_validity(Certificate), + %% TODO: Find a library function to decode utc time + [Y1,Y2,MO1,MO2,D1,D2,H1,H2,MI1,MI2,S1,S2,$Z] = UtcTime, + YEAR = case list_to_integer([Y1,Y2]) >= 50 of + true -> "19" ++ [Y1,Y2]; + _ -> "20" ++ [Y1,Y2] + end, + NotAfter = lists:flatten(io_lib:format("~s-~s-~s ~s:~s:~s", + [YEAR, [MO1,MO2], [D1,D2], + [H1,H2], [MI1,MI2], [S1,S2]])), + + case close_to_expire(UtcTime, 0) of + true -> + {expired, NotAfter}; + false -> + {ok, NotAfter} + end. + +-spec get_subjectAltNames(#'Certificate'{}) -> [string()]. +get_subjectAltNames(#'Certificate'{tbsCertificate = TbsCertificate}) -> + #'TBSCertificate'{ + extensions = Exts + } = TbsCertificate, + + EncodedSANs = [Val || #'Extension'{extnID = Oid, extnValue = Val} <- Exts, + Oid =:= attribute_oid(subjectAltName)], + + lists:flatmap( + fun(EncSAN) -> + SANs0 = public_key:der_decode('SubjectAltName', EncSAN), + [Name || {dNSName, Name} <- SANs0] + end, EncodedSANs). + + + +-spec get_utc_validity(#'Certificate'{}) -> string(). +get_utc_validity(#'Certificate'{tbsCertificate = TbsCertificate}) -> + #'TBSCertificate'{ + validity = Validity + } = TbsCertificate, + + #'Validity'{notAfter = {utcTime, UtcTime}} = Validity, + UtcTime. + +%% +%% Revoke Certificate +%% + +-spec revoke_certificate(string()) -> {ok, deleted} | {error, _}. +revoke_certificate(DomainOrFile) -> + try + CAUrl = get_config_ca_url(), + revoke_certificate0(CAUrl, DomainOrFile) + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, revoke_certificate} + end. + +-spec revoke_certificate0(url(), string()) -> {ok, deleted}. +revoke_certificate0(CAUrl, DomainOrFile) -> + ParsedCert = parse_revoke_cert_argument(DomainOrFile), + revoke_certificate1(CAUrl, ParsedCert). + +-spec revoke_certificate1(url(), {domain, bitstring()} | {file, file:filename()}) -> + {ok, deleted}. +revoke_certificate1(CAUrl, {domain, Domain}) -> + case domain_certificate_exists(Domain) of + {Domain, Cert = #data_cert{pem=PemCert}} -> + ok = revoke_certificate2(CAUrl, PemCert), + ok = remove_certificate_persistent(Cert), + {ok, deleted}; + false -> + ?ERROR_MSG("Certificate for domain: ~p not found", [Domain]), + throw({error, not_found}) + end; +revoke_certificate1(CAUrl, {file, File}) -> + case file:read_file(File) of + {ok, Pem} -> + ok = revoke_certificate2(CAUrl, Pem), + {ok, deleted}; + {error, Reason} -> + ?ERROR_MSG("Error: ~p reading pem certificate-key file: ~p", [Reason, File]), + throw({error, Reason}) + end. + + +-spec revoke_certificate2(url(), pem()) -> ok. +revoke_certificate2(CAUrl, PemEncodedCert) -> + {Certificate, CertPrivateKey} = prepare_certificate_revoke(PemEncodedCert), + + {ok, Dirs, Nonce} = ejabberd_acme_comm:directory(CAUrl), + + Req = [{<<"certificate">>, Certificate}], + {ok, [], _Nonce1} = ejabberd_acme_comm:revoke_cert(Dirs, CertPrivateKey, Req, Nonce), + ok. + +-spec parse_revoke_cert_argument(string()) -> {domain, bitstring()} | {file, file:filename()}. +parse_revoke_cert_argument([$f, $i, $l, $e, $:|File]) -> + {file, File}; +parse_revoke_cert_argument([$d, $o, $m, $a, $i, $n, $: | Domain]) -> + {domain, list_to_bitstring(Domain)}. + +-spec prepare_certificate_revoke(pem()) -> {bitstring(), jose_jwk:key()}. +prepare_certificate_revoke(PemEncodedCert) -> + PemList = public_key:pem_decode(PemEncodedCert), + PemCertEnc = lists:keyfind('Certificate', 1, PemList), + PemCert = public_key:pem_entry_decode(PemCertEnc), + DerCert = public_key:der_encode('Certificate', PemCert), + Base64Cert = base64url:encode(DerCert), + + {ok, Key} = find_private_key_in_pem(PemEncodedCert), + {Base64Cert, Key}. + +-spec domain_certificate_exists(bitstring()) -> {bitstring(), data_cert()} | false. +domain_certificate_exists(Domain) -> + Certs = read_certificates_persistent(), + lists:keyfind(Domain, 1, Certs). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Called by ejabberd_pkix to check +%% if a certificate exists for a +%% specific host +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec certificate_exists(bitstring()) -> {true, file:filename()} | false. +certificate_exists(Host) -> + Certificates = read_certificates_persistent(), + case lists:keyfind(Host, 1 , Certificates) of + false -> + false; + {Host, #data_cert{path=Path}} -> + {true, Path} + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Certificate Request Functions +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +%% For now we accept only generating a key of +%% specific type for signing the csr + +-spec make_csr(proplist(), [{dNSName, bitstring()}]) + -> {binary(), jose_jwk:key()}. +make_csr(Attributes, SANs) -> + Key = generate_key(), + {_, KeyKey} = jose_jwk:to_key(Key), + KeyPub = to_public(Key), + try + SubPKInfoAlgo = subject_pk_info_algo(KeyPub), + {ok, RawBinPubKey} = raw_binary_public_key(KeyPub), + SubPKInfo = subject_pk_info(SubPKInfoAlgo, RawBinPubKey), + {ok, Subject} = attributes_from_list(Attributes), + ExtensionRequest = extension_request(SANs), + CRI = certificate_request_info(SubPKInfo, Subject, ExtensionRequest), + {ok, EncodedCRI} = der_encode( + 'CertificationRequestInfo', + CRI), + SignedCRI = public_key:sign(EncodedCRI, 'sha256', KeyKey), + SignatureAlgo = signature_algo(Key, 'sha256'), + CSR = certification_request(CRI, SignatureAlgo, SignedCRI), + {ok, DerCSR} = der_encode( + 'CertificationRequest', + CSR), + Result = base64url:encode(DerCSR), + {Result, Key} + catch + _:{badmatch, {error, bad_public_key}} -> + {error, bad_public_key}; + _:{badmatch, {error, bad_attributes}} -> + {error, bad_public_key}; + _:{badmatch, {error, der_encode}} -> + {error, der_encode} + end. + + + +subject_pk_info_algo(_KeyPub) -> + #'SubjectPublicKeyInfoAlgorithm'{ + algorithm = ?'id-ecPublicKey', + parameters = {asn1_OPENTYPE,<<6,8,42,134,72,206,61,3,1,7>>} + }. + +subject_pk_info(Algo, RawBinPubKey) -> + #'SubjectPublicKeyInfo-PKCS-10'{ + algorithm = Algo, + subjectPublicKey = RawBinPubKey + }. + +extension(SANs) -> + #'Extension'{ + extnID = attribute_oid(subjectAltName), + critical = false, + extnValue = public_key:der_encode('SubjectAltName', SANs)}. + +extension_request(SANs) -> + #'AttributePKCS-10'{ + type = ?'pkcs-9-at-extensionRequest', + values = [{'asn1_OPENTYPE', + public_key:der_encode( + 'ExtensionRequest', + [extension(SANs)])}] + }. + +certificate_request_info(SubPKInfo, Subject, ExtensionRequest) -> + #'CertificationRequestInfo'{ + version = 0, + subject = Subject, + subjectPKInfo = SubPKInfo, + attributes = [ExtensionRequest] + }. + +signature_algo(_Key, _Hash) -> + #'CertificationRequest_signatureAlgorithm'{ + algorithm = ?'ecdsa-with-SHA256', + parameters = asn1_NOVALUE + }. + +certification_request(CRI, SignatureAlgo, SignedCRI) -> + #'CertificationRequest'{ + certificationRequestInfo = CRI, + signatureAlgorithm = SignatureAlgo, + signature = SignedCRI + }. + +raw_binary_public_key(KeyPub) -> + try + {_, RawPubKey} = jose_jwk:to_key(KeyPub), + {{_, RawBinPubKey}, _} = RawPubKey, + {ok, RawBinPubKey} + catch + _:_ -> + ?ERROR_MSG("Bad public key: ~p~n", [KeyPub]), + {error, bad_public_key} + end. + +der_encode(Type, Term) -> + try + {ok, public_key:der_encode(Type, Term)} + catch + _:_ -> + ?ERROR_MSG("Cannot DER encode: ~p, with asn1type: ~p", [Term, Type]), + {error, der_encode} + end. + +%% +%% Attributes Parser +%% + +attributes_from_list(Attrs) -> + ParsedAttrs = [attribute_parser_fun(Attr) || Attr <- Attrs], + case lists:any(fun is_error/1, ParsedAttrs) of + true -> + {error, bad_attributes}; + false -> + {ok, {rdnSequence, [[PAttr] || PAttr <- ParsedAttrs]}} + end. + +attribute_parser_fun({AttrName, AttrVal}) -> + try + #'AttributeTypeAndValue'{ + type = attribute_oid(AttrName), + %% TODO: Check if every attribute should be encoded as + %% common name. Actually it doesn't matter in + %% practice. Only in theory in order to have cleaner code. + value = public_key:der_encode('X520CommonName', {printableString, AttrVal}) + %% value = length_bitstring(list_to_bitstring(AttrVal)) + } + catch + _:_ -> + ?ERROR_MSG("Bad attribute: ~p~n", [{AttrName, AttrVal}]), + {error, bad_attributes} + end. + +-spec attribute_oid(atom()) -> tuple() | no_return(). +attribute_oid(commonName) -> ?'id-at-commonName'; +attribute_oid(countryName) -> ?'id-at-countryName'; +attribute_oid(stateOrProvinceName) -> ?'id-at-stateOrProvinceName'; +attribute_oid(localityName) -> ?'id-at-localityName'; +attribute_oid(organizationName) -> ?'id-at-organizationName'; +attribute_oid(subjectAltName) -> ?'id-ce-subjectAltName'; +attribute_oid(_) -> error(bad_attributes). + + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Useful funs +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec location_to_id(url()) -> {ok, string()} | {error, not_found}. +location_to_id(Url0) -> + Url = string:strip(Url0, right, $/), + case string:rchr(Url, $/) of + 0 -> + ?ERROR_MSG("Couldn't find id in url: ~p~n", [Url]), + {error, not_found}; + Ind -> + {ok, string:sub_string(Url, Ind+1)} + end. + +-spec get_challenges(proplist()) -> [{proplist()}]. +get_challenges(Body) -> + {<<"challenges">>, Challenges} = proplists:lookup(<<"challenges">>, Body), + Challenges. + +-spec not_before_not_after() -> {binary(), binary()}. +not_before_not_after() -> + {Date, Time} = calendar:universal_time(), + NotBefore = encode_calendar_datetime({Date, Time}), + %% The certificate will be valid for 90 Days after today + AfterDate = add_days_to_date(90, Date), + NotAfter = encode_calendar_datetime({AfterDate, Time}), + {NotBefore, NotAfter}. + +-spec to_public(jose_jwk:key()) -> jose_jwk:key(). +to_public(PrivateKey) -> + jose_jwk:to_public(PrivateKey). + +-spec pem_to_certificate(pem()) -> #'Certificate'{}. +pem_to_certificate(Pem) -> + PemList = public_key:pem_decode(Pem), + PemEntryCert = lists:keyfind('Certificate', 1, PemList), + Certificate = public_key:pem_entry_decode(PemEntryCert), + Certificate. + +-spec add_days_to_date(integer(), calendar:date()) -> calendar:date(). +add_days_to_date(Days, Date) -> + Date1 = calendar:date_to_gregorian_days(Date), + calendar:gregorian_days_to_date(Date1 + Days). + +-spec encode_calendar_datetime(calendar:datetime()) -> binary(). +encode_calendar_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) -> + list_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT" + "~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). + +%% TODO: Find a better and more robust way to parse the utc string +-spec utc_string_to_datetime(string()) -> calendar:datetime(). +utc_string_to_datetime(UtcString) -> + try + [Y1,Y2,MO1,MO2,D1,D2,H1,H2,MI1,MI2,S1,S2,$Z] = UtcString, + Year = list_to_integer("20" ++ [Y1,Y2]), + Month = list_to_integer([MO1, MO2]), + Day = list_to_integer([D1,D2]), + Hour = list_to_integer([H1,H2]), + Minute = list_to_integer([MI1,MI2]), + Second = list_to_integer([S1,S2]), + {{Year, Month, Day}, {Hour, Minute, Second}} + catch + _:_ -> + ?ERROR_MSG("Unable to parse UTC string", []), + throw({error, utc_string_to_datetime}) + end. + +-spec find_private_key_in_pem(pem()) -> {ok, jose_jwk:key()} | false. +find_private_key_in_pem(Pem) -> + PemList = public_key:pem_decode(Pem), + case find_private_key_in_pem1(private_key_types(), PemList) of + false -> + false; + PemKey -> + Key = public_key:pem_entry_decode(PemKey), + JoseKey = jose_jwk:from_key(Key), + {ok, JoseKey} + end. + + +-spec find_private_key_in_pem1([public_key:pki_asn1_type()], + [public_key:pem_entry()]) -> + public_key:pem_entry() | false. +find_private_key_in_pem1([], _PemList) -> + false; +find_private_key_in_pem1([Type|Types], PemList) -> + case lists:keyfind(Type, 1, PemList) of + false -> + find_private_key_in_pem1(Types, PemList); + Key -> + Key + end. + + +-spec parse_domain_string(string()) -> [string()]. +parse_domain_string(DomainString) -> + string:tokens(DomainString, ";"). + +-spec private_key_types() -> [public_key:pki_asn1_type()]. +private_key_types() -> + ['RSAPrivateKey', + 'DSAPrivateKey', + 'ECPrivateKey']. + +-spec find_all_sub_domains(bitstring()) -> [bitstring()]. +find_all_sub_domains(DomainName) -> + AllRoutes = ejabberd_router:get_all_routes(), + DomainLen = size(DomainName), + [Route || Route <- AllRoutes, + binary:longest_common_suffix([DomainName, Route]) + =:= DomainLen]. + + +-spec is_error(_) -> boolean(). +is_error({error, _}) -> true; +is_error({error, _, _}) -> true; +is_error(_) -> false. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Handle the persistent data structure +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +-spec data_empty() -> []. +data_empty() -> + []. + +%% +%% Account +%% + +-spec data_get_account(acme_data()) -> {ok, list(), url(), jose_jwk:key()} | none. +data_get_account(Data) -> + case lists:keyfind(account, 1, Data) of + {account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}} -> + {ok, AccId, CAUrl, PrivateKey}; + false -> + none + end. + +-spec data_set_account(acme_data(), {list(), url(), jose_jwk:key()}) -> acme_data(). +data_set_account(Data, {AccId, CAUrl, PrivateKey}) -> + NewAcc = {account, #data_acc{id = AccId, ca_url = CAUrl, key = PrivateKey}}, + lists:keystore(account, 1, Data, NewAcc). + +%% +%% Certificates +%% + +-spec data_get_certificates(acme_data()) -> data_certs(). +data_get_certificates(Data) -> + case lists:keyfind(certs, 1, Data) of + {certs, Certs} -> + Certs; + false -> + [] + end. + +-spec data_set_certificates(acme_data(), data_certs()) -> acme_data(). +data_set_certificates(Data, NewCerts) -> + lists:keystore(certs, 1, Data, {certs, NewCerts}). + +%% ATM we preserve one certificate for each domain +-spec data_add_certificate(acme_data(), data_cert()) -> acme_data(). +data_add_certificate(Data, DataCert = #data_cert{domain=Domain}) -> + Certs = data_get_certificates(Data), + NewCerts = lists:keystore(Domain, 1, Certs, {Domain, DataCert}), + data_set_certificates(Data, NewCerts). + +-spec data_remove_certificate(acme_data(), data_cert()) -> acme_data(). +data_remove_certificate(Data, _DataCert = #data_cert{domain=Domain}) -> + Certs = data_get_certificates(Data), + NewCerts = lists:keydelete(Domain, 1, Certs), + data_set_certificates(Data, NewCerts). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Handle Config and Persistence Files +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec persistent_file() -> file:filename(). +persistent_file() -> + MnesiaDir = mnesia:system_info(directory), + filename:join(MnesiaDir, "acme.DAT"). + +%% The persistent file should be read and written only by its owner +-spec persistent_file_mode() -> 384. +persistent_file_mode() -> + 8#400 + 8#200. + +-spec read_persistent() -> {ok, acme_data()} | no_return(). +read_persistent() -> + case file:read_file(persistent_file()) of + {ok, Binary} -> + {ok, binary_to_term(Binary)}; + {error, enoent} -> + create_persistent(), + {ok, data_empty()}; + {error, Reason} -> + ?ERROR_MSG("Error: ~p reading acme data file", [Reason]), + throw({error, Reason}) + end. + +-spec write_persistent(acme_data()) -> ok | no_return(). +write_persistent(Data) -> + Binary = term_to_binary(Data), + case file:write_file(persistent_file(), Binary) of + ok -> ok; + {error, Reason} -> + ?ERROR_MSG("Error: ~p writing acme data file", [Reason]), + throw({error, Reason}) + end. + +-spec create_persistent() -> ok | no_return(). +create_persistent() -> + Binary = term_to_binary(data_empty()), + case file:write_file(persistent_file(), Binary) of + ok -> + case file:change_mode(persistent_file(), persistent_file_mode()) of + ok -> ok; + {error, Reason} -> + ?ERROR_MSG("Error: ~p changing acme data file mode", [Reason]), + throw({error, Reason}) + end; + {error, Reason} -> + ?ERROR_MSG("Error: ~p creating acme data file", [Reason]), + throw({error, Reason}) + end. + +-spec write_account_persistent({list(), url(), jose_jwk:key()}) -> ok | no_return(). +write_account_persistent({AccId, CAUrl, PrivateKey}) -> + {ok, Data} = read_persistent(), + NewData = data_set_account(Data, {AccId, CAUrl, PrivateKey}), + ok = write_persistent(NewData). + +-spec read_account_persistent() -> {ok, list(), url(), jose_jwk:key()} | none. +read_account_persistent() -> + {ok, Data} = read_persistent(), + data_get_account(Data). + +-spec read_certificates_persistent() -> data_certs(). +read_certificates_persistent() -> + {ok, Data} = read_persistent(), + data_get_certificates(Data). + +-spec add_certificate_persistent(data_cert()) -> ok. +add_certificate_persistent(DataCert) -> + {ok, Data} = read_persistent(), + NewData = data_add_certificate(Data, DataCert), + ok = write_persistent(NewData). + +-spec remove_certificate_persistent(data_cert()) -> ok. +remove_certificate_persistent(DataCert) -> + {ok, Data} = read_persistent(), + NewData = data_remove_certificate(Data, DataCert), + ok = write_persistent(NewData). + +-spec save_certificate({ok, bitstring(), binary()} | {error, _, _}) -> + {ok, bitstring(), saved} | {error, bitstring(), _}. +save_certificate({error, _, _} = Error) -> + Error; +save_certificate({ok, DomainName, Cert}) -> + try + CertDir = get_config_cert_dir(), + DomainString = bitstring_to_list(DomainName), + CertificateFile = filename:join([CertDir, DomainString ++ "_cert.pem"]), + %% TODO: At some point do the following using a Transaction so + %% that there is no certificate saved if it cannot be added in + %% certificate persistent storage + write_cert(CertificateFile, Cert, DomainName), + DataCert = #data_cert{ + domain = DomainName, + pem = Cert, + path = CertificateFile + }, + add_certificate_persistent(DataCert), + ejabberd_pkix:add_certfile(CertificateFile), + {ok, DomainName, saved} + catch + throw:Throw -> + Throw; + E:R -> + ?ERROR_MSG("Unknown ~p:~p, ~p", [E, R, erlang:get_stacktrace()]), + {error, DomainName, saving} + end. + +-spec save_renewed_certificate({ok, bitstring(), _} | {error, _, _}) -> + {ok, bitstring(), _} | {error, bitstring(), _}. +save_renewed_certificate({error, _, _} = Error) -> + Error; +save_renewed_certificate({ok, _, no_expire} = Cert) -> + Cert; +save_renewed_certificate({ok, DomainName, Cert}) -> + save_certificate({ok, DomainName, Cert}). + +-spec write_cert(file:filename(), binary(), bitstring()) -> {ok, bitstring(), saved}. +write_cert(CertificateFile, Cert, DomainName) -> + case file:write_file(CertificateFile, Cert) of + ok -> + {ok, DomainName, saved}; + {error, Reason} -> + ?ERROR_MSG("Error: ~p saving certificate at file: ~p", + [Reason, CertificateFile]), + throw({error, DomainName, saving}) + end. + +-spec get_config_acme() -> acme_config(). +get_config_acme() -> + case ejabberd_config:get_option(acme, undefined) of + undefined -> + ?WARNING_MSG("No acme configuration has been specified", []), + %% throw({error, configuration}); + []; + Acme -> + Acme + end. + +-spec get_config_contact() -> bitstring(). +get_config_contact() -> + Acme = get_config_acme(), + case lists:keyfind(contact, 1, Acme) of + {contact, Contact} -> + Contact; + false -> + ?WARNING_MSG("No contact has been specified in configuration", []), + ?DEFAULT_CONFIG_CONTACT + %% throw({error, configuration_contact}) + end. + +-spec get_config_ca_url() -> url(). +get_config_ca_url() -> + Acme = get_config_acme(), + case lists:keyfind(ca_url, 1, Acme) of + {ca_url, CAUrl} -> + CAUrl; + false -> + ?ERROR_MSG("No CA url has been specified in configuration", []), + ?DEFAULT_CONFIG_CA_URL + %% throw({error, configuration_ca_url}) + end. + + +-spec get_config_hosts() -> [bitstring()]. +get_config_hosts() -> + case ejabberd_config:get_option(hosts, undefined) of + undefined -> + ?ERROR_MSG("No hosts have been specified in configuration", []), + throw({error, configuration_hosts}); + Hosts -> + Hosts + end. + +-spec get_config_cert_dir() -> file:filename(). +get_config_cert_dir() -> + case ejabberd_config:get_option(cert_dir, undefined) of + undefined -> + ?WARNING_MSG("No cert_dir configuration has been specified in configuration", []), + mnesia:system_info(directory); + %% throw({error, configuration}); + CertDir -> + CertDir + end. + + +generate_key() -> + jose_jwk:generate_key({ec, secp256r1}). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Option Parsing Code +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +parse_acme_opts(AcmeOpt) -> + [parse_acme_opt(Opt) || Opt <- AcmeOpt]. + + +parse_acme_opt({ca_url, CaUrl}) when is_bitstring(CaUrl) -> + {ca_url, binary_to_list(CaUrl)}; +parse_acme_opt({contact, Contact}) when is_bitstring(Contact) -> + {contact, Contact}. + +parse_cert_dir_opt(Opt) when is_bitstring(Opt) -> + true = filelib:is_dir(Opt), + Opt. + +-spec opt_type(acme) -> fun((acme_config()) -> (acme_config())); + (cert_dir) -> fun((bitstring()) -> (bitstring())); + (atom()) -> [atom()]. +opt_type(acme) -> + fun parse_acme_opts/1; +opt_type(cert_dir) -> + fun parse_cert_dir_opt/1; +opt_type(_) -> + [acme, cert_dir]. diff --git a/src/ejabberd_acme_comm.erl b/src/ejabberd_acme_comm.erl new file mode 100644 index 000000000..acd552f7e --- /dev/null +++ b/src/ejabberd_acme_comm.erl @@ -0,0 +1,393 @@ +-module(ejabberd_acme_comm). +-export([%% Directory + directory/1, + %% Account + new_account/4, + update_account/4, + get_account/3, + delete_account/3, + %% Authorization + new_authz/4, + get_authz/1, + complete_challenge/4, + %% Authorization polling + get_authz_until_valid/1, + %% Certificate + new_cert/4, + get_cert/1, + revoke_cert/4 + %% Not yet implemented + %% key_roll_over/5 + %% delete_authz/3 + ]). + +-include("ejabberd.hrl"). +-include("logger.hrl"). +-include("xmpp.hrl"). + +-include("ejabberd_acme.hrl"). +-include_lib("public_key/include/public_key.hrl"). + +-define(REQUEST_TIMEOUT, 5000). % 5 seconds. +-define(MAX_POLL_REQUESTS, 20). +-define(POLL_WAIT_TIME, 500). % 500 ms. + +%%% +%%% This module contains functions that implement all necessary http +%%% requests to the ACME Certificate Authority. Its purpose is to +%%% facilitate the acme client implementation by separating the +%%% handling/validating/parsing of all the needed http requests. +%%% + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Directory +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec directory(url()) -> {ok, dirs(), nonce()} | {error, _}. +directory(CAUrl) -> + Url = CAUrl ++ "/directory", + prepare_get_request(Url, fun get_dirs/1). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Account Handling +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec new_account(dirs(), jose_jwk:key(), proplist(), nonce()) -> + {ok, {url(), proplist()}, nonce()} | {error, _}. +new_account(Dirs, PrivateKey, Req, Nonce) -> + #{"new-reg" := Url} = Dirs, + EJson = {[{ <<"resource">>, <<"new-reg">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_tos/1). + +-spec update_account({url(), string()}, jose_jwk:key(), proplist(), nonce()) -> + {ok, proplist(), nonce()} | {error, _}. +update_account({CAUrl, AccId}, PrivateKey, Req, Nonce) -> + Url = CAUrl ++ "/acme/reg/" ++ AccId, + EJson = {[{ <<"resource">>, <<"reg">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1). + +-spec get_account({url(), string()}, jose_jwk:key(), nonce()) -> + {ok, {url(), proplist()}, nonce()} | {error, _}. +get_account({CAUrl, AccId}, PrivateKey, Nonce) -> + Url = CAUrl ++ "/acme/reg/" ++ AccId, + EJson = {[{<<"resource">>, <<"reg">>}]}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_tos/1). + +-spec delete_account({url(), string()}, jose_jwk:key(), nonce()) -> + {ok, proplist(), nonce()} | {error, _}. +delete_account({CAUrl, AccId}, PrivateKey, Nonce) -> + Url = CAUrl ++ "/acme/reg/" ++ AccId, + EJson = + {[{<<"resource">>, <<"reg">>}, + {<<"status">>, <<"deactivated">>}]}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Authorization Handling +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec new_authz(dirs(), jose_jwk:key(), proplist(), nonce()) -> + {ok, {url(), proplist()}, nonce()} | {error, _}. +new_authz(Dirs, PrivateKey, Req, Nonce) -> + #{"new-authz" := Url} = Dirs, + EJson = {[{<<"resource">>, <<"new-authz">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_location/1). + +-spec get_authz({url(), string()}) -> {ok, proplist(), nonce()} | {error, _}. +get_authz({CAUrl, AuthzId}) -> + Url = CAUrl ++ "/acme/authz/" ++ AuthzId, + prepare_get_request(Url, fun get_response/1). + +-spec complete_challenge({url(), string(), string()}, jose_jwk:key(), proplist(), nonce()) -> + {ok, proplist(), nonce()} | {error, _}. +complete_challenge({CAUrl, AuthzId, ChallId}, PrivateKey, Req, Nonce) -> + Url = CAUrl ++ "/acme/challenge/" ++ AuthzId ++ "/" ++ ChallId, + EJson = {[{<<"resource">>, <<"challenge">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Certificate Handling +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec new_cert(dirs(), jose_jwk:key(), proplist(), nonce()) -> + {ok, {url(), list()}, nonce()} | {error, _}. +new_cert(Dirs, PrivateKey, Req, Nonce) -> + #{"new-cert" := Url} = Dirs, + EJson = {[{<<"resource">>, <<"new-cert">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response_location/1, + "application/pkix-cert"). + +-spec get_cert({url(), string()}) -> {ok, list(), nonce()} | {error, _}. +get_cert({CAUrl, CertId}) -> + Url = CAUrl ++ "/acme/cert/" ++ CertId, + prepare_get_request(Url, fun get_response/1, "application/pkix-cert"). + +-spec revoke_cert(dirs(), jose_jwk:key(), proplist(), nonce()) -> + {ok, _, nonce()} | {error, _}. +revoke_cert(Dirs, PrivateKey, Req, Nonce) -> + #{"revoke-cert" := Url} = Dirs, + EJson = {[{<<"resource">>, <<"revoke-cert">>}] ++ Req}, + prepare_post_request(Url, PrivateKey, EJson, Nonce, fun get_response/1, + "application/pkix-cert"). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Handle Response Functions +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec get_dirs({ok, proplist(), proplist()}) -> {ok, map(), nonce()}. +get_dirs({ok, Head, Return}) -> + NewNonce = get_nonce(Head), + StrDirectories = [{bitstring_to_list(X), bitstring_to_list(Y)} || + {X, Y} <- Return, is_bitstring(X) andalso is_bitstring(Y)], + NewDirs = maps:from_list(StrDirectories), + {ok, NewDirs, NewNonce}. + +-spec get_response({ok, proplist(), proplist()}) -> {ok, proplist(), nonce()}. +get_response({ok, Head, Return}) -> + NewNonce = get_nonce(Head), + {ok, Return, NewNonce}. + +-spec get_response_tos({ok, proplist(), proplist()}) -> {ok, {url(), proplist()}, nonce()}. +get_response_tos({ok, Head, Return}) -> + TOSUrl = get_tos(Head), + NewNonce = get_nonce(Head), + {ok, {TOSUrl, Return}, NewNonce}. + +-spec get_response_location({ok, proplist(), proplist()}) -> {ok, {url(), proplist()}, nonce()}. +get_response_location({ok, Head, Return}) -> + Location = get_location(Head), + NewNonce = get_nonce(Head), + {ok, {Location, Return}, NewNonce}. + + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Authorization Polling +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec get_authz_until_valid({url(), string()}) -> {ok, proplist(), nonce()} | {error, _}. +get_authz_until_valid({CAUrl, AuthzId}) -> + get_authz_until_valid({CAUrl, AuthzId}, ?MAX_POLL_REQUESTS). + +-spec get_authz_until_valid({url(), string()}, non_neg_integer()) -> + {ok, proplist(), nonce()} | {error, _}. +get_authz_until_valid({_CAUrl, _AuthzId}, 0) -> + ?ERROR_MSG("Maximum request limit waiting for validation reached", []), + {error, max_request_limit}; +get_authz_until_valid({CAUrl, AuthzId}, N) -> + case get_authz({CAUrl, AuthzId}) of + {ok, Resp, Nonce} -> + case is_authz_valid(Resp) of + true -> + {ok, Resp, Nonce}; + false -> + timer:sleep(?POLL_WAIT_TIME), + get_authz_until_valid({CAUrl, AuthzId}, N-1) + end; + {error, _} = Err -> + Err + end. + +-spec is_authz_valid(proplist()) -> boolean(). +is_authz_valid(Authz) -> + case proplists:lookup(<<"status">>, Authz) of + {<<"status">>, <<"valid">>} -> + true; + _ -> + false + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Request Functions +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +%% TODO: Fix the duplicated code at the below 4 functions +-spec make_post_request(url(), bitstring(), string()) -> + {ok, proplist(), proplist()} | {error, _}. +make_post_request(Url, ReqBody, ResponseType) -> + Options = [], + HttpOptions = [{timeout, ?REQUEST_TIMEOUT}], + case httpc:request(post, + {Url, [], "application/jose+json", ReqBody}, HttpOptions, Options) of + {ok, {{_, Code, _}, Head, Body}} when Code >= 200, Code =< 299 -> + decode_response(Head, Body, ResponseType); + Error -> + failed_http_request(Error, Url) + end. + +-spec make_get_request(url(), string()) -> + {ok, proplist(), proplist()} | {error, _}. +make_get_request(Url, ResponseType) -> + Options = [], + HttpOptions = [{timeout, ?REQUEST_TIMEOUT}], + case httpc:request(get, {Url, []}, HttpOptions, Options) of + {ok, {{_, Code, _}, Head, Body}} when Code >= 200, Code =< 299 -> + decode_response(Head, Body, ResponseType); + Error -> + failed_http_request(Error, Url) + end. + +-spec prepare_post_request(url(), jose_jwk:key(), jiffy:json_value(), + nonce(), handle_resp_fun()) -> {ok, _, nonce()} | {error, _}. +prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun) -> + prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun, "application/jose+json"). + +-spec prepare_post_request(url(), jose_jwk:key(), jiffy:json_value(), + nonce(), handle_resp_fun(), string()) -> {ok, _, nonce()} | {error, _}. +prepare_post_request(Url, PrivateKey, EJson, Nonce, HandleRespFun, ResponseType) -> + case encode(EJson) of + {ok, ReqBody} -> + FinalBody = sign_encode_json_jose(PrivateKey, ReqBody, Nonce), + case make_post_request(Url, FinalBody, ResponseType) of + {ok, Head, Return} -> + HandleRespFun({ok, Head, Return}); + Error -> + Error + end; + {error, Reason} -> + ?ERROR_MSG("Error: ~p when encoding: ~p", [Reason, EJson]), + {error, Reason} + end. + +-spec prepare_get_request(url(), handle_resp_fun()) -> + {ok, _, nonce()} | {error, _}. +prepare_get_request(Url, HandleRespFun) -> + prepare_get_request(Url, HandleRespFun, "application/jose+json"). + +-spec prepare_get_request(url(), handle_resp_fun(), string()) -> + {ok, _, nonce()} | {error, _}. +prepare_get_request(Url, HandleRespFun, ResponseType) -> + case make_get_request(Url, ResponseType) of + {ok, Head, Return} -> + HandleRespFun({ok, Head, Return}); + Error -> + Error + end. + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Jose Json Functions +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec sign_json_jose(jose_jwk:key(), bitstring(), nonce()) -> {_, jws()}. +sign_json_jose(Key, Json, Nonce) -> + PubKey = ejabberd_acme:to_public(Key), + {_, BinaryPubKey} = jose_jwk:to_binary(PubKey), + PubKeyJson = jiffy:decode(BinaryPubKey), + %% TODO: Ensure this works for all cases + AlgMap = jose_jwk:signer(Key), + JwsMap = + #{ <<"jwk">> => PubKeyJson, + %% <<"b64">> => true, + <<"nonce">> => list_to_bitstring(Nonce) + }, + JwsObj0 = maps:merge(JwsMap, AlgMap), + JwsObj = jose_jws:from(JwsObj0), + jose_jws:sign(Key, Json, JwsObj). + +-spec sign_encode_json_jose(jose_jwk:key(), bitstring(), nonce()) -> bitstring(). +sign_encode_json_jose(Key, Json, Nonce) -> + {_, Signed} = sign_json_jose(Key, Json, Nonce), + %% This depends on jose library, so we can consider it safe + jiffy:encode(Signed). + + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Useful funs +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec get_nonce(proplist()) -> nonce() | 'none'. +get_nonce(Head) -> + case proplists:lookup("replay-nonce", Head) of + {"replay-nonce", Nonce} -> Nonce; + none -> none + end. + +-spec get_location(proplist()) -> url() | 'none'. +get_location(Head) -> + case proplists:lookup("location", Head) of + {"location", Location} -> Location; + none -> none + end. + +%% Very bad way to extract this +%% TODO: Find a better way +-spec get_tos(proplist()) -> url() | 'none'. +get_tos(Head) -> + try + [{_, Link}] = [{K, V} || {K, V} <- Head, + K =:= "link" andalso + lists:suffix("\"terms-of-service\"", V)], + [Link1, _] = string:tokens(Link, ";"), + Link2 = string:strip(Link1, left, $<), + string:strip(Link2, right, $>) + catch + _:_ -> + none + end. + +decode_response(Head, Body, "application/pkix-cert") -> + {ok, Head, Body}; +decode_response(Head, Body, "application/jose+json") -> + case decode(Body) of + {ok, Return} -> + {ok, Head, Return}; + {error, Reason} -> + ?ERROR_MSG("Problem decoding: ~s", [Body]), + {error, Reason} + end. + +encode(EJson) -> + try + {ok, jiffy:encode(EJson)} + catch + _:Reason -> + {error, Reason} + end. + +decode(Json) -> + try + {Result} = jiffy:decode(Json), + {ok, Result} + catch + _:Reason -> + {error, Reason} + end. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% +%% Handle Failed HTTP Requests +%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec failed_http_request({ok, _} | {error, _}, url()) -> {error, _}. +failed_http_request({ok, {{_, Code, Reason}, _Head, Body}}, Url) -> + ?ERROR_MSG("Got unexpected status code from <~s>: ~B, Body: ~s", + [Url, Code, Body]), + throw({error, {unexpected_code, Code, Reason}}); +failed_http_request({error, Reason}, Url) -> + ?ERROR_MSG("Error making a request to <~s>: ~p", + [Url, Reason]), + throw({error, Reason}). diff --git a/src/ejabberd_admin.erl b/src/ejabberd_admin.erl index 8b4af2857..368c7fe53 100644 --- a/src/ejabberd_admin.erl +++ b/src/ejabberd_admin.erl @@ -44,6 +44,11 @@ registered_users/1, %% Migration jabberd1.4 import_file/1, import_dir/1, + %% Acme + get_certificate/1, + renew_certificate/0, + list_certificates/1, + revoke_certificate/1, %% Purge DB delete_expired_messages/0, delete_old_messages/1, %% Mnesia @@ -104,7 +109,7 @@ get_commands_spec() -> module = ?MODULE, function = status, result_desc = "Result tuple", result_example = {ok, <<"The node ejabberd@localhost is started with status: started" - "ejabberd X.X is running in that node">>}, + "ejabberd X.X is running in that node">>}, args = [], result = {res, restuple}}, #ejabberd_commands{name = stop, tags = [server], desc = "Stop ejabberd gracefully", @@ -126,9 +131,9 @@ get_commands_spec() -> #ejabberd_commands{name = stop_kindly, tags = [server], desc = "Inform users and rooms, wait, and stop the server", longdesc = "Provide the delay in seconds, and the " - "announcement quoted, for example: \n" - "ejabberdctl stop_kindly 60 " - "\\\"The server will stop in one minute.\\\"", + "announcement quoted, for example: \n" + "ejabberdctl stop_kindly 60 " + "\\\"The server will stop in one minute.\\\"", module = ?MODULE, function = stop_kindly, args_desc = ["Seconds to wait", "Announcement to send, with quotes"], args_example = [60, <<"Server will stop now.">>], @@ -192,7 +197,7 @@ get_commands_spec() -> result_example = [<<"user1">>, <<"user2">>], args = [{host, binary}], result = {users, {list, {username, string}}}}, - #ejabberd_commands{name = registered_vhosts, tags = [server], + #ejabberd_commands{name = registered_vhosts, tags = [server], desc = "List all registered vhosts in SERVER", module = ?MODULE, function = registered_vhosts, result_desc = "List of available vhosts", @@ -215,7 +220,7 @@ get_commands_spec() -> #ejabberd_commands{name = leave_cluster, tags = [cluster], desc = "Remove and shutdown Node from the running cluster", longdesc = "This command can be run from any running node of the cluster, " - "even the node to be removed.", + "even the node to be removed.", module = ?MODULE, function = leave_cluster, args_desc = ["Nodename of the node to kick from the cluster"], args_example = [<<"ejabberd1@machine8">>], @@ -242,6 +247,30 @@ get_commands_spec() -> args_example = ["/var/lib/ejabberd/jabberd14/"], args = [{file, string}], result = {res, restuple}}, + #ejabberd_commands{name = get_certificate, tags = [acme], + desc = "Gets a certificate for all or the specified domains {all|domain1;domain2;...}.", + module = ?MODULE, function = get_certificate, + args_desc = ["Domains for which to acquire a certificate"], + args_example = ["all | www.example.com;www.example1.net"], + args = [{domains, string}], + result = {certificates, string}}, + #ejabberd_commands{name = renew_certificate, tags = [acme], + desc = "Renews all certificates that are close to expiring", + module = ?MODULE, function = renew_certificate, + args = [], + result = {certificates, string}}, + #ejabberd_commands{name = list_certificates, tags = [acme], + desc = "Lists all curently handled certificates and their respective domains in {plain|verbose} format", + module = ?MODULE, function = list_certificates, + args_desc = ["Whether to print the whole certificate or just some metadata. Possible values: plain | verbose"], + args = [{option, string}], + result = {certificates, {list, {certificate, string}}}}, + #ejabberd_commands{name = revoke_certificate, tags = [acme], + desc = "Revokes the selected certificate", + module = ?MODULE, function = revoke_certificate, + args_desc = ["The domain or file (in pem format) of the certificate in question {domain:Domain | file:File}"], + args = [{domain_or_file, string}], + result = {res, restuple}}, #ejabberd_commands{name = import_piefxis, tags = [mnesia], desc = "Import users data from a PIEFXIS file (XEP-0227)", @@ -321,9 +350,9 @@ get_commands_spec() -> desc = "Change the erlang node name in a backup file", module = ?MODULE, function = mnesia_change_nodename, args_desc = ["Name of the old erlang node", "Name of the new node", - "Path to old backup file", "Path to the new backup file"], + "Path to old backup file", "Path to the new backup file"], args_example = ["ejabberd@machine1", "ejabberd@machine2", - "/var/lib/ejabberd/old.backup", "/var/lib/ejabberd/new.backup"], + "/var/lib/ejabberd/old.backup", "/var/lib/ejabberd/new.backup"], args = [{oldnodename, string}, {newnodename, string}, {oldbackup, string}, {newbackup, string}], result = {res, restuple}}, @@ -421,7 +450,7 @@ stop_kindly(DelaySeconds, AnnouncementTextString) -> {"Stopping ejabberd", application, stop, [ejabberd]}, {"Stopping Mnesia", mnesia, stop, []}, {"Stopping Erlang node", init, stop, []} - ], + ], NumberLast = length(Steps), TimestampStart = calendar:datetime_to_gregorian_seconds({date(), time()}), lists:foldl( @@ -469,8 +498,8 @@ update_module(ModuleNameBin) when is_binary(ModuleNameBin) -> update_module(ModuleNameString) -> ModuleName = list_to_atom(ModuleNameString), case ejabberd_update:update([ModuleName]) of - {ok, _Res} -> {ok, []}; - {error, Reason} -> {error, Reason} + {ok, _Res} -> {ok, []}; + {error, Reason} -> {error, Reason} end. %%% @@ -500,7 +529,7 @@ registered_users(Host) -> lists:map(fun({U, _S}) -> U end, SUsers). registered_vhosts() -> - ?MYHOSTS. + ?MYHOSTS. reload_config() -> ejabberd_config:reload_file(). @@ -542,6 +571,38 @@ import_dir(Path) -> {cannot_import_dir, String} end. +%%% +%%% Acme +%%% + +get_certificate(Domains) -> + case ejabberd_acme:is_valid_domain_opt(Domains) of + true -> + ejabberd_acme:get_certificates(Domains); + false -> + io_lib:format("Invalid domains: ~p", [Domains]) + end. + +renew_certificate() -> + ejabberd_acme:renew_certificates(). + +list_certificates(Verbose) -> + case ejabberd_acme:is_valid_verbose_opt(Verbose) of + true -> + ejabberd_acme:list_certificates(Verbose); + false -> + String = io_lib:format("Invalid verbose option: ~p", [Verbose]), + {invalid_option, String} + end. + +revoke_certificate(DomainOrFile) -> + case ejabberd_acme:is_valid_revoke_cert(DomainOrFile) of + true -> + ejabberd_acme:revoke_certificate(DomainOrFile); + false -> + String = io_lib:format("Bad argument: ~s", [DomainOrFile]), + {invalid_argument, String} + end. %%% %%% Purge DB diff --git a/src/ejabberd_http.erl b/src/ejabberd_http.erl index 30cce1952..0bc0d8fc4 100644 --- a/src/ejabberd_http.erl +++ b/src/ejabberd_http.erl @@ -465,7 +465,9 @@ process_request(#state{request_method = Method, opts = Options, headers = RequestHeaders, ip = IP}, - Res = case process(RequestHandlers, Request, Socket, SockMod, Trail) of + RequestHandlers1 = ejabberd_hooks:run_fold( + http_request_handlers, RequestHandlers, [Host, Request]), + Res = case process(RequestHandlers1, Request, Socket, SockMod, Trail) of El when is_record(El, xmlel) -> make_xhtml_output(State, 200, CustomHeaders, El); {Status, Headers, El} diff --git a/src/ejabberd_pkix.erl b/src/ejabberd_pkix.erl index 7efe7b427..68b8226c8 100644 --- a/src/ejabberd_pkix.erl +++ b/src/ejabberd_pkix.erl @@ -301,7 +301,14 @@ add_certfiles(Host, State) -> NewAccState end end, State, certfiles_from_config_options()), - if State /= State1 -> + State2 = case ejabberd_acme:certificate_exists(Host) of + {true, Path} -> + {_, State3} = add_certfile(Path, State1), + State3; + false -> + State1 + end, + if State /= State2 -> case build_chain_and_check(State1) of ok -> {ok, State1}; {error, _} = Err -> Err