diff --git a/docs/run/acme-dns-01.md b/docs/run/acme-dns-01.md new file mode 100644 index 000000000..d54095862 --- /dev/null +++ b/docs/run/acme-dns-01.md @@ -0,0 +1,353 @@ +# ACME DNS-01 provider devices + +This guide configures HyperBEAM to obtain and renew a TLS certificate through +ACME DNS-01. DNS-01 is required for wildcard certificates such as +`*.example.com`. + +HyperBEAM includes provider devices for DigitalOcean and Cloudflare. The DNS +orchestrator resolves the configured provider through the normal AO-Core device +loader, so third-party provider devices can be packaged and installed without +changing `tls@1.0`. + +## Before you begin + +You need: + +- A domain whose authoritative DNS is managed by DigitalOcean or Cloudflare. +- A public HyperBEAM node reachable on TCP port 443. +- An API token scoped only to the relevant DNS zone. +- An ACME directory, such as Let's Encrypt. + +Create DNS routing records independently of the certificate configuration. For +a node at `example.com`, route both names to the node: + +- `example.com` +- `*.example.com` + +The wildcard DNS record routes per-ID hostnames to HyperBEAM. Adding +`*.example.com` to `tls.domains` makes the certificate valid for those +hostnames. One does not replace the other. + +If Cloudflare hosts the zone and HyperBEAM should terminate TLS itself, set the +records to **DNS only**. A proxied record terminates public TLS at Cloudflare +instead of directly at HyperBEAM. + +## Common TLS configuration + +Start with the following `config.json` structure, replacing the example domain: + +```json +{ + "port": 443, + "protocol": "http2", + "tls": { + "domains": [ + "example.com", + "*.example.com" + ], + "acme": { + "directory-url": "https://acme-v02.api.letsencrypt.org/directory", + "terms-of-service-agreed": true, + "dns-provider": "PROVIDER_DEVICE", + "dns-zone": "example.com", + "dns-propagation-timeout": 60000, + "dns-poll-interval": 2000 + } + } +} +``` + +Add the API token as `priv-dns-api-token` inside `tls.acme`. The `priv-` prefix +marks it as private so HyperBEAM excludes it from public messages and sanitized +event output. Restrict access to the configuration file. DNS-01 does not use +`tls/acme/http-port`, so that setting can be removed. + +**NOTE:** JSON config doesn't support private parameters. You need a `config.flat` +to also be loaded that contains the `priv-dns-api-token`. Example: + +``` +tls/acme/priv-dns-api-token: TOKEN +``` + +## DigitalOcean + +### 1. Prepare the zone + +In the DigitalOcean control panel, ensure `example.com` is a managed domain and +create records equivalent to: + +| Type | Hostname | Value | +|------|----------|-------| +| A/AAAA | `@` | The node's public address | +| A/AAAA | `*` | The node's public address | + +Use CNAME records instead when appropriate for the deployment. + +### 2. Create the API token + +Create a DigitalOcean personal access token with only the permissions needed to +create and delete domain records. DigitalOcean supports custom token scopes. +See [Creating a DigitalOcean personal access token](https://docs.digitalocean.com/reference/api/create-personal-access-token/). + +The token must be able to call the domain-record create and delete endpoints. +With custom scopes, grant `domain:create` and `domain:delete`. + +### 3. Configure HyperBEAM + +Set the common configuration's provider device to +`tls-dns-digitalocean@1.0`: + +```json +{ + "dns-provider": "tls-dns-digitalocean@1.0", + "dns-zone": "example.com", + "dns-propagation-timeout": 60000, + "dns-poll-interval": 2000 +} +``` + +Update in a separate `config.flat` the API token: + +`tls/acme/priv-dns-api-token: replace-with-token` + +The token is required. + +### 4. Start the node + +For a development shell: + +```bash +HB_CONFIG=config.json,config-priv.flat rebar3 shell +``` + +For a release console: + +```bash +./bin/hb console +``` + +HyperBEAM creates a relative DigitalOcean TXT record named +`_acme-challenge`, queries every authoritative nameserver until the expected +value is visible, completes validation, and deletes the record. + +## Cloudflare + +### 1. Prepare the zone + +Add `example.com` to Cloudflare and ensure its assigned nameservers are +authoritative. Create records equivalent to: + +| Type | Name | Content | Proxy status | +|------|------|---------|--------------| +| A/AAAA | `@` | The node's public address | DNS only | +| A/AAAA | `*` | The node's public address | DNS only | + +DNS-only mode is necessary when clients should inspect the certificate served +by HyperBEAM. Cloudflare-proxied deployments use Cloudflare's public edge +certificate instead. + +### 2. Create the API token + +In the Cloudflare dashboard, create a token from the **Edit Zone DNS** template +and restrict its resources to the specific zone. Cloudflare documents this in +[Create API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/). + +Grant: + +- `DNS Write` for the selected zone. +- `Zone Read` for the selected zone if HyperBEAM will look up the zone ID. + +You can omit `Zone Read` by configuring `dns-zone-id`. The zone ID is available +from the zone overview; see [Find account and zone IDs](https://developers.cloudflare.com/fundamentals/account/find-account-and-zone-ids/). + +### 3. Configure HyperBEAM + +With automatic zone-ID lookup: + +```json +{ + "dns-provider": "tls-dns-cloudflare@1.0", + "dns-zone": "example.com", + "dns-propagation-timeout": 60000, + "dns-poll-interval": 2000 +} +``` + +Update in a separate `config.flat` the API token: + +`tls/acme/priv-dns-api-token: replace-with-token` + +With an explicit zone ID and no `Zone Read` permission: + +```json +{ + "dns-provider": "tls-dns-cloudflare@1.0", + "dns-zone": "example.com", + "dns-zone-id": "023e105f4ecef8ad9ca31a8372d0c353", + "dns-propagation-timeout": 60000, + "dns-poll-interval": 2000 +} +``` + +The token is required. HyperBEAM does not read it from the environment. + +### 4. Start the node + +For a development shell: + +```bash +HB_CONFIG=config.json,config-priv rebar3 shell +``` + +For a release console: + +```bash +./bin/hb console +``` + +HyperBEAM creates the full Cloudflare TXT record +`_acme-challenge.example.com`, queries every authoritative nameserver until the +expected value is visible, completes validation, and deletes the record. + +`dns-propagation-timeout` bounds the complete propagation check and defaults to +30000 milliseconds. `dns-poll-interval` defaults to 2000 milliseconds. The old +`dns-propagation-delay` property remains accepted as a compatibility alias for +the timeout when `dns-propagation-timeout` is absent. + +## Integrating another provider + +`tls.acme.dns-provider` is a device name or specification ID, not a provider +keyword. The named device receives the `tls.acme` message as its base and must +export these keys: + +Existing configurations using `cloudflare` or `digitalocean` as keywords must +switch to the full built-in device names shown above. + +| Key | Request | Successful result | +|-----|---------|-------------------| +| `put` | `record`: full TXT record name; `value`: TXT value | `{ok, Handle}`, where `Handle` is an AO-Core message containing everything needed for cleanup | +| `delete` | `handle`: the exact handle returned by `put` | `{ok, ok}` | + +Both handlers may return `{error, Reason}`. A handle must be an Erlang map with +normal AO-Core message values; do not return tuples or process-local state. +HyperBEAM keeps propagation polling in the `tls@1.0` orchestrator, so providers +implement only record creation and deletion. + +A provider root has this interface: + +```erlang +%%% @doc ACME DNS-01 support for Example DNS. +-module(dev_tls_dns_example). +-implements(<<"tls-dns-example@1.0">>). +-export([info/1, put/3, delete/3]). + +info(_Opts) -> #{exports => [put, delete]}. + +put(Base, Req, Opts) -> + Record = hb_maps:get(<<"record">>, Req, undefined, Opts), + Value = hb_maps:get(<<"value">>, Req, undefined, Opts), + %% Validate the inputs and create the provider's TXT record. + {ok, #{<<"record-id">> => ProviderRecordID}}. + +delete(Base, Req, Opts) -> + Handle = hb_maps:get(<<"handle">>, Req, #{}, Opts), + %% Validate the handle against Base, then delete the record. + {ok, ok}. +``` + +The names `Base`, `Req`, and `Opts` describe the contract; a production device +must validate all inputs and provider responses. Use `hb_maps` rather than map +pattern matching when reading messages because AO-Core messages may be +lazy-loaded. Store provider secrets under provider-defined `priv-` keys inside +`tls.acme` so they are removed from public configuration and sanitized events. +A provider must not include credentials in its handle, errors, or logs. + +Package and test the implementation using +[Building a third-party device repository](../build/external-device-repository.md). +Once it is present in the node's preloaded store, pinned in `trusted-devices`, +or loadable from a trusted signer, configure its declared name: + +```json +{ + "tls": { + "acme": { + "dns-provider": "tls-dns-example@1.0", + "dns-zone": "example.com" + } + } +} +``` + +Provider calls use raw AO-Core dispatch locally. Normal device loading and +export checks apply, while provider results do not acquire hashpaths or enter +the resolver cache. + +## What to expect + +At boot, HyperBEAM emits sanitized events on the `tls` topic. The successful +flow includes: + +1. `certificate_issuance_started` +2. `dns_challenge` with provider device and action only +3. `dns_record_created` +4. `dns_propagation_started` +5. `dns_record_propagated` +6. `dns_record_deleted` +7. `acme_certificate_generation_requested` +8. `acme_certificate_generated` +9. `acme_certificate_downloaded` +10. `acme_certificate_ready` + +Renewals additionally emit `acme_certificate_installed` after the live +certificate is replaced. These events exclude API tokens, authorization +headers, TXT values, certificate bytes, wallets, and private keys. + +HyperBEAM normally renews before expiration. If issuance or installation fails, +it retains or starts with the wallet-key self-signed fallback and retries later. + +## Verify the certificate + +Inspect the certificate served for the base hostname: + +```bash +openssl s_client -connect example.com:443 -servername example.com /dev/null \ + | openssl x509 -noout -subject -issuer -dates -ext subjectAltName +``` + +The subject alternative names must include both `example.com` and +`*.example.com`. + +Test hostname verification for a wildcard hostname: + +```bash +curl --head https://tls-test.example.com/ +``` + +The HTTP response may be a redirect or an application error; the important +part is that TLS hostname and chain verification succeed without `-k`. + +The TXT record is temporary. It may disappear before a manual `dig` query is +run because HyperBEAM deletes it immediately after ACME validation. + +## Troubleshooting + +| Symptom or error | Check | +|------------------|-------| +| `acme-dns-api-token-missing` | `HB_CONFIG` includes the `config.flat` file containing `tls/acme/priv-dns-api-token`. | +| `acme-dns-provider-not-loadable` | The configured provider device is preloaded, explicitly pinned, or signed by a configured trusted device signer. | +| Provider returns 401 or 403 | The token is valid, scoped to the correct zone, and has create/delete permission. Cloudflare automatic lookup also needs `Zone Read`. | +| `cloudflare-zone-not-found` | `dns-zone` is the authoritative Cloudflare zone, or configure its exact `dns-zone-id`. | +| `acme-dns-record-outside-zone` | `dns-zone` must contain every configured TLS hostname. For `example.com` and `*.example.com`, use `example.com`. | +| `acme-dns-propagation-timeout` | Increase `dns-propagation-timeout` and confirm every authoritative nameserver serves the expected TXT record. | +| ACME validation times out | Confirm authoritative nameservers and check whether restrictive CAA records block the selected CA. | +| Base hostname works but asset subdomains fail | Configure both the wildcard DNS routing record and `*.example.com` in `tls.domains`. | +| Browser still sees a self-signed certificate | Inspect `acme_bootstrap_failed` or `acme_renewal_failed`, correct the cause, and allow the scheduled retry or restart the node. | +| Cloudflare certificate appears instead of the HyperBEAM certificate | Set the Cloudflare routing records to DNS only. | + +## Token security + +- Scope the token to one zone and only the required permissions. +- Store it as `tls/acme/priv-dns-api-token` in a protected `config.flat` file. +- Never put it in shell history, logs, public messages, or source control. +- Rotate the token after accidental exposure and restart the node with the new + value. diff --git a/docs/run/configuring-your-machine.md b/docs/run/configuring-your-machine.md index 39de806ce..a15c4584f 100644 --- a/docs/run/configuring-your-machine.md +++ b/docs/run/configuring-your-machine.md @@ -81,7 +81,7 @@ These options control identity and security settings. #### TLS termination -TLS is opt-in and uses ACME HTTP-01 to obtain and renew a certificate whose +TLS is opt-in and uses ACME to obtain and renew a certificate whose leaf key is the node's RSA `priv-wallet`: ```text @@ -98,6 +98,48 @@ tls/acme/http-port: 80 `tls/acme/http-port` defaults to `80` and must be reachable for HTTP-01 validation. This cleartext listener serves only the exact ACME challenge path. +Wildcard domains automatically use DNS-01 instead. DNS providers are configured +by device name; HyperBEAM includes DigitalOcean and Cloudflare implementations: + +```text +tls/domains/ao-types: .=list +tls/domains/1: example.com +tls/domains/2: *.example.com + +tls/acme/directory-url: https://acme-v02.api.letsencrypt.org/directory +tls/acme/terms-of-service-agreed: true +tls/acme/dns-provider: tls-dns-digitalocean@1.0 +tls/acme/dns-zone: example.com +tls/acme/priv-dns-api-token: replace-with-token +tls/acme/dns-propagation-timeout: 60000 +tls/acme/dns-poll-interval: 2000 +``` + +The token needs permission to create and delete domain records. Its `priv-` +prefix marks it as private so it is excluded from public messages and sanitized +event output. Restrict access to the configuration file. HyperBEAM polls every +authoritative nameserver for the expected TXT value before requesting ACME +validation. `dns-propagation-timeout` and `dns-poll-interval` are milliseconds; +they default to `30000` and `2000`. The former `dns-propagation-delay` setting +is accepted as a compatibility alias for the timeout. Set +`tls/acme/challenge-type: dns-01` to use DNS-01 without a wildcard. + +For Cloudflare, use: + +```text +tls/acme/dns-provider: tls-dns-cloudflare@1.0 +tls/acme/dns-zone: example.com +tls/acme/priv-dns-api-token: replace-with-token +``` + +The Cloudflare token needs `DNS Write` on the zone. HyperBEAM resolves the zone +ID by name, which additionally requires `Zone Read`. To avoid that lookup and +permission, set `tls/acme/dns-zone-id` to the 32-character Cloudflare zone ID. + +See [ACME DNS-01 provider devices](./acme-dns-01.md) for provider setup, the +third-party provider interface, startup, verification, renewal, and +troubleshooting steps. + The ACME directory uses the operating-system trust store unless `tls/acme/ca-certificate` supplies a PEM CA certificate. TLS supports `http1` and `http2`, not `http3`. diff --git a/mkdocs.yml b/mkdocs.yml index e12fd5d9c..201e1af56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -17,6 +17,7 @@ nav: - Run a Node: - Running a HyperBEAM node: 'run/running-a-hyperbeam-node.md' - Configuring your machine: 'run/configuring-your-machine.md' + - ACME DNS-01: 'run/acme-dns-01.md' - TEE nodes: 'run/tee-nodes.md' - Joining/running a router: 'run/joining-running-a-router.md' - Build on HyperBEAM: diff --git a/src/core/http/hb_http_server.erl b/src/core/http/hb_http_server.erl index db229013f..41213f886 100644 --- a/src/core/http/hb_http_server.erl +++ b/src/core/http/hb_http_server.erl @@ -313,13 +313,17 @@ prepare_tls(TLS, Wallet, ServerID, NodeMsg) -> ChallengeRef = {tls_http_01, ServerID}, stop_tls(ServerID), try - ChallengeNode = challenge_node(ACME, ServerID, NodeMsg), - {ok, _, _} = start_http2( - ChallengeRef, - listener_protocol_options(ChallengeRef, ChallengeNode), - ChallengeNode, - [] - ), + case uses_http_challenge(ACME, TLS, NodeMsg) of + true -> + ChallengeNode = challenge_node(ACME, ServerID, NodeMsg), + {ok, _, _} = start_http2( + ChallengeRef, + listener_protocol_options(ChallengeRef, ChallengeNode), + ChallengeNode, + [] + ); + false -> ok + end, PrivateTLS = #{ <<"server-id">> => ServerID, <<"lifecycle-capability">> => make_ref() @@ -374,6 +378,27 @@ challenge_node(ACME, ServerID, NodeMsg) -> NodeMsg ). +%% @doc Return whether TLS issuance requires an HTTP-01 challenge listener. +uses_http_challenge(ACME, TLS, Opts) -> + Domains = hb_maps:get(<<"domains">>, TLS, [], Opts), + Request = + #{ + <<"path">> => <<"challenge-type">>, + <<"challenge-type">> => + hb_maps:get(<<"challenge-type">>, ACME, undefined, Opts), + <<"domains">> => Domains + }, + ResolveOpts = + Opts#{ + <<"only">> => local, + <<"hashpath">> => ignore, + <<"cache-control">> => [<<"no-cache">>, <<"no-store">>] + }, + {ok, ChallengeType} = hb_ao:resolve( + #{ <<"device">> => <<"tls@1.0">> }, Request, ResolveOpts + ), + ChallengeType =:= <<"http-01">>. + listener_protocol_options(ServerID, NodeMsg) -> Dispatcher = cowboy_router:compile([{'_', [{'_', ?MODULE, ServerID}]}]), #{ @@ -812,6 +837,17 @@ set_tls_opts_rejected_test() -> set_opts(#{ <<"tls">> => false }, #{}) ). +%% @doc Test selecting an ACME challenge listener from TLS configuration. +tls_challenge_listener_test() -> + ?assert(uses_http_challenge(#{}, + #{<<"domains">> => [<<"node.example">>]}, #{})), + ?assertNot(uses_http_challenge(#{}, + #{<<"domains">> => [<<"*.node.example">>]}, #{})), + ?assertNot(uses_http_challenge( + #{<<"challenge-type">> => <<"dns-01">>}, + #{<<"domains">> => [<<"node.example">>]}, #{} + )). + tls_http3_rejected_before_start_test() -> ?assertError( 'tls-not-supported-for-http3', diff --git a/src/core/resolver/hb_opts.erl b/src/core/resolver/hb_opts.erl index b1acd6be8..a74f4e416 100644 --- a/src/core/resolver/hb_opts.erl +++ b/src/core/resolver/hb_opts.erl @@ -33,7 +33,7 @@ [ error, http_error, cron_error, hook_error, warning, http_server_short, http_client_short, - compute_short, push_short, copycat_short, bundler_short + compute_short, push_short, copycat_short, bundler_short, tls ] ). -endif. diff --git a/src/core/util/hb_hostname.erl b/src/core/util/hb_hostname.erl index f1d1c321c..964cbe151 100644 --- a/src/core/util/hb_hostname.erl +++ b/src/core/util/hb_hostname.erl @@ -1,6 +1,7 @@ %%% @doc Public DNS and local-network exclusion helpers. -module(hb_hostname). -export([is_public/2, public_ips/2, is_public_ip/1, uri_host/1, normalize/1]). +-export([records/3]). -include_lib("eunit/include/eunit.hrl"). -define(DEFAULT_DNS_SERVERS, [{{1, 1, 1, 1}, 53}, {{8, 8, 8, 8}, 53}]). @@ -30,13 +31,18 @@ public_ips(Host, Opts) -> %% @doc Return public-DNS A and AAAA answers for `Host'. dns_ips(Host, Opts) -> + records(Host, a, Opts) ++ records(Host, aaaa, Opts). + +%% @doc Resolve public DNS records without consulting host files or search +%% domains. DNS failures return an empty list. +records(Host, Type, Opts) -> ResOpts = [ % Query DNS directly so host files and search domains are ignored. {nameservers, dns_servers(Opts)}, {timeout, hb_opts:get(dns_timeout, 1000, Opts)}, {retry, hb_opts:get(dns_retries, 1, Opts)} ], - lookup(Host, a, ResOpts) ++ lookup(Host, aaaa, ResOpts). + lookup(Host, Type, ResOpts). %% @doc True iff `IP' is neither special-use nor on a local interface subnet. is_public_ip(IP) -> @@ -203,6 +209,13 @@ public_ips_literal_test() -> ?assertEqual([{8, 8, 8, 8}], public_ips(<<"8.8.8.8">>, #{})), ?assertEqual([], public_ips(<<"127.0.0.1">>, #{})). +%% @doc Test generic DNS record lookup failure and resolver validation. +records_test() -> + ?assertEqual([], records(<<"invalid host">>, ns, #{})), + ?assertThrow(invalid_dns_server, records( + <<"example.com">>, ns, #{<<"dns-servers">> => []} + )). + uri_host_test() -> ?assertEqual({ok, <<"example.com">>}, uri_host(<<"https://Example.COM./x">>)), ?assertEqual({error, invalid_uri}, uri_host(<<"/x">>)). diff --git a/src/preloaded/node/dev_tls.erl b/src/preloaded/node/dev_tls.erl index a41fde71f..7117a330e 100644 --- a/src/preloaded/node/dev_tls.erl +++ b/src/preloaded/node/dev_tls.erl @@ -1,6 +1,6 @@ %%% @doc Node-wallet TLS and ACME renewal device. -module(dev_tls). --export([info/1, request/3, well_known/3, obtain/3]). +-export([info/1, request/3, well_known/3, obtain/3, challenge_type/3]). -include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). @@ -10,7 +10,14 @@ -define(RENEW_RETRY_MS, 60 * 60 * 1000). info(_) -> - #{ exports => [<<"request">>, <<"well-known">>, <<"obtain">>] }. + #{ + exports => [ + <<"request">>, + <<"well-known">>, + <<"obtain">>, + <<"challenge-type">> + ] + }. %% @doc Route the exact HTTP-01 path through the normal AO-Core hook. request(_Base, HookRequest, Opts) -> @@ -29,7 +36,8 @@ request(_Base, HookRequest, Opts) -> <<"token">> => Token } ] }}; - _ -> not_found() + _ -> + not_found() end. %% @doc Serve an active key authorization from the singleton. @@ -88,6 +96,16 @@ obtain(_Base, Request, Opts) -> end end. +%% @doc Select the ACME challenge type for a TLS configuration. +challenge_type(_Base, Request, Opts) -> + ACME = + #{ + <<"challenge-type">> => + hb_maps:get(<<"challenge-type">>, Request, undefined, Opts) + }, + Domains = hb_maps:get(<<"domains">>, Request, [], Opts), + {ok, dev_tls_acme:challenge_type(ACME, Domains, Opts)}. + ensure_started(Opts) -> TLS = hb_tls:config(Opts), true = is_map(TLS), @@ -97,6 +115,7 @@ ensure_started(Opts) -> loop(#{ server_id => ServerID, tls => TLS, + opts => Opts, wallet => hb_opts:get(priv_wallet, no_viable_wallet, Opts), account_wallet => ar_wallet:new(), challenges => #{}, @@ -154,15 +173,27 @@ renew(State) -> end. issue(Operation, State) -> + ?event(tls, {certificate_issuance_started, + {operation, operation_name(Operation)}}), Parent = self(), + TLS = maps:get(tls, State), + Opts = maps:get(opts, State), spawn_link(fun() -> - Challenge = fun(Action) -> call(Parent, Action, ?CALL_TIMEOUT) end, + Challenge = fun + ({dns_put, _, _} = Action) -> + dev_tls_dns:challenge(Action, TLS, Opts); + ({dns_delete, _} = Action) -> + dev_tls_dns:challenge(Action, TLS, Opts); + ({dns_wait, _, _, _} = Action) -> + dev_tls_dns:challenge(Action, TLS, Opts); + (Action) -> call(Parent, Action, ?CALL_TIMEOUT) + end, Parent ! {acme_result, dev_tls_acme:obtain( - maps:get(tls, State), + TLS, maps:get(wallet, State), maps:get(account_wallet, State), Challenge, - maps:get(tls, State) + Opts )} end), State#{operation => Operation}. @@ -170,7 +201,9 @@ issue(Operation, State) -> complete(Result, State = #{operation := {obtain, From, Ref}}) -> From ! {Ref, Result}, case Result of - {ok, Chain} -> schedule_certificate(Chain, State#{operation => idle}); + {ok, Chain} -> + ?event(tls, certificate_event(acme_certificate_ready, Chain)), + schedule_certificate(Chain, State#{operation => idle}); {error, Reason} -> retry(Reason, State#{operation => idle}) end; complete({ok, Chain}, State = #{operation := renew}) -> @@ -178,7 +211,9 @@ complete({ok, Chain}, State = #{operation := renew}) -> case hb_tls:install( maps:get(server_id, State), maps:get(wallet, State), Chain ) of - ok -> schedule_certificate(Chain, maps:remove(pending_chain, Idle)); + ok -> + ?event(tls, certificate_event(acme_certificate_installed, Chain)), + schedule_certificate(Chain, maps:remove(pending_chain, Idle)); {error, Reason} -> retry(Reason, Idle#{pending_chain => Chain}) end; complete({error, Reason}, State = #{operation := renew}) -> @@ -206,6 +241,18 @@ retry(Reason, State) -> ?event(tls, {acme_renewal_failed, {reason, Reason}}), schedule(?RENEW_RETRY_MS, State). +%% @doc Build a TLS certificate lifecycle event with chain metadata. +certificate_event(Name, Chain) -> + { + Name, + {chain_length, length(Chain)}, + {expires_at_ms, hb_tls:certificate_expiry(Chain)} + }. + +%% @doc Return the event name for a TLS lifecycle operation. +operation_name({obtain, _, _}) -> obtain; +operation_name(renew) -> renew. + call(undefined, _Request, _Timeout) -> {error, 'tls-runtime-not-found'}; call(PID, Request, Timeout) -> diff --git a/src/preloaded/node/dev_tls_acme.erl b/src/preloaded/node/dev_tls_acme.erl index 211fb17aa..a81bae2fb 100644 --- a/src/preloaded/node/dev_tls_acme.erl +++ b/src/preloaded/node/dev_tls_acme.erl @@ -1,6 +1,7 @@ %%% @doc Bounded RFC 8555 client for a node-wallet certificate. -module(dev_tls_acme). --export([obtain/5]). +-export([obtain/5, challenge_type/3]). +-include("include/hb.hrl"). -include_lib("eunit/include/eunit.hrl"). -include_lib("public_key/include/public_key.hrl"). @@ -18,6 +19,7 @@ obtain(TLS, Wallet, AccountWallet, Challenge, Opts) -> nonce => undefined, kid => undefined, thumbprint => account_thumbprint(AccountWallet), + challenge_type => challenge_type(ACME, Domains, Opts), http_opts => http_options(ACME, Opts), deadline => erlang:monotonic_time(millisecond) + ?ISSUANCE_TIMEOUT }, @@ -27,12 +29,20 @@ obtain(TLS, Wallet, AccountWallet, Challenge, Opts) -> State4 = authorize(maps:get(<<"authorizations">>, Order), Challenge, State3), {_Ready, State5} = poll(OrderURL, <<"ready">>, State4), + ?event(debug_tls, + {acme_certificate_generation_requested, + {domain_count, length(Domains)}}), State6 = finalize(maps:get(<<"finalize">>, Order), Domains, State5), {Valid, State7} = poll(OrderURL, <<"valid">>, State6), + ?event(debug_tls, acme_certificate_generated), {_Headers, PEM, _State8} = expect(jws_post( maps:get(<<"certificate">>, Valid), post_as_get, State7 ), [200]), - {ok, certificate_chain(PEM)} + Chain = certificate_chain(PEM), + ?event(debug_tls, + {acme_certificate_downloaded, + {chain_length, length(Chain)}}), + {ok, Chain} catch throw:{acme, Reason} -> {error, Reason}; _:Reason -> {error, Reason} @@ -58,14 +68,51 @@ require(true, _Reason) -> ok; require(false, Reason) -> throw({acme, Reason}). domains(Domains) when is_list(Domains), Domains =/= [] -> - require(lists:all(fun(Domain) -> - is_binary(Domain) andalso byte_size(Domain) > 0 - andalso binary:match(Domain, <<"*">>) =:= nomatch - end, Domains), 'invalid-tls-domains'), + require( + lists:all( + fun(Domain) -> + is_binary(Domain) + andalso byte_size(Domain) > 0 + andalso valid_wildcard(Domain) + end, + Domains + ), + 'invalid-tls-domains'), [hb_util:to_lower(Domain) || Domain <- Domains]; domains(_) -> throw({acme, 'invalid-tls-domains'}). +%% @doc Return whether a domain has no wildcard or one leading wildcard. +valid_wildcard(<<"*.", Rest/binary>>) -> + Rest =/= <<>> andalso binary:match(Rest, <<"*">>) =:= nomatch; +valid_wildcard(Domain) -> + binary:match(Domain, <<"*">>) =:= nomatch. + +%% @doc Select and validate the configured ACME challenge type. +challenge_type(ACME, Domains, Opts) -> + Wildcard = + lists:any( + fun + (<<"*.", _/binary>>) -> true; + (_) -> false + end, + Domains), + Type = + case hb_maps:get(<<"challenge-type">>, ACME, undefined, Opts) of + undefined when Wildcard -> <<"dns-01">>; + undefined -> <<"http-01">>; + Value -> hb_util:to_lower(hb_util:bin(Value)) + end, + require( + Type =:= <<"dns-01">> orelse Type =:= <<"http-01">>, + 'invalid-acme-challenge-type' + ), + require( + not Wildcard orelse Type =:= <<"dns-01">>, + 'acme-wildcard-requires-dns-01' + ), + Type. + account_thumbprint(Wallet) -> #{<<"e">> := E, <<"n">> := N} = jwk(Wallet), Canonical = << @@ -107,28 +154,81 @@ authorize([URL | Rest], Challenge, State) -> case maps:get(<<"status">>, Authorization) of <<"valid">> -> authorize(Rest, Challenge, State1); _ -> - HTTPChallenge = http_challenge(Authorization), - Token = maps:get(<<"token">>, HTTPChallenge), + ChallengeType = maps:get(challenge_type, State1), + ?event(debug_tls, {authorize, {challenge_type, ChallengeType}}), + ACMEChallenge = acme_challenge(ChallengeType, Authorization), + Token = maps:get(<<"token">>, ACMEChallenge), validate_token(Token), + ?event(debug_tls, {authorize, token_validated}), KeyAuthorization = <>, - ok = Challenge({put, Token, KeyAuthorization}), - try - {_H, _B, State2} = expect(jws_post( - maps:get(<<"url">>, HTTPChallenge), #{}, State1 - ), [200, 202]), - {_Valid, State3} = poll(URL, <<"valid">>, State2), - authorize(Rest, Challenge, State3) - after - Challenge({delete, Token}) - end + {Cleanup, Readiness} = present_challenge( + Authorization, Token, KeyAuthorization, Challenge, State1 + ), + run_challenge( + Cleanup, Readiness, Challenge, State1, + fun() -> + {_H, _B, State2} = + expect( + jws_post( + maps:get(<<"url">>, ACMEChallenge), + #{}, + State1 + ), + [200, 202] + ), + {_Valid, State3} = poll(URL, <<"valid">>, State2), + authorize(Rest, Challenge, State3) + end + ) end. -http_challenge(Authorization) -> +%% @doc Select the offered ACME challenge matching `Type'. +acme_challenge(Type, Authorization) -> case [Challenge || Challenge <- maps:get(<<"challenges">>, Authorization, []), - maps:get(<<"type">>, Challenge, undefined) =:= <<"http-01">>] of + maps:get(<<"type">>, Challenge, undefined) =:= Type] of [Challenge | _] -> Challenge; - [] -> throw({acme, 'acme-http-01-not-offered'}) + [] -> throw({acme, {acme_challenge_not_offered, Type}}) + end. + +%% @doc Present an HTTP-01 or DNS-01 challenge and return cleanup instructions. +present_challenge(_Authorization, Token, KeyAuthorization, Challenge, + #{challenge_type := <<"http-01">>}) -> + ok = challenge_result(Challenge({put, Token, KeyAuthorization})), + {{delete, Token}, ready}; +present_challenge(Authorization, _Token, KeyAuthorization, Challenge, _State) -> + Identifier = maps:get( + <<"value">>, maps:get(<<"identifier">>, Authorization, #{}) + ), + Record = <<"_acme-challenge.", Identifier/binary>>, + Value = hb_util:encode(crypto:hash(sha256, KeyAuthorization)), + Handle = + case Challenge({dns_put, Record, Value}) of + {ok, Result} -> Result; + Result -> challenge_result(Result) + end, + {{dns_delete, Handle}, {dns_wait, Record, Value}}. + +%% @doc Normalize a challenge callback result or throw its ACME error. +challenge_result(ok) -> ok; +challenge_result({error, Reason}) -> throw({acme, Reason}); +challenge_result(Other) -> + throw({acme, {'invalid-acme-challenge-response', Other}}). + +%% @doc Wait for a presented challenge to become ready for ACME validation. +challenge_ready(ready, _Challenge, _State) -> ok; +challenge_ready({dns_wait, Record, Value}, Challenge, State) -> + challenge_result(Challenge( + {dns_wait, Record, Value, deadline(State)} + )). + +%% @doc Wait for challenge readiness, execute `Fun', and always clean up. +run_challenge(Cleanup, Readiness, Challenge, State, Fun) -> + try + ok = challenge_ready(Readiness, Challenge, State), + Fun() + after + Challenge(Cleanup) end. finalize(URL, Domains, State) -> @@ -296,7 +396,12 @@ rsa_sign({{{rsa, E}, D, N}, {{rsa, E}, N}}, Data) -> [{rsa_padding, rsa_pkcs1_padding}]). header(Name, Headers) -> - proplists:get_value(Name, Headers, not_found). + Normalized = [{ + hb_util:to_lower(hb_util:bin(Key)), hb_util:bin(Value) + } || {Key, Value} <- Headers], + proplists:get_value( + hb_util:to_lower(hb_util:bin(Name)), Normalized, not_found + ). is_bad_nonce(400, Body) -> case decode_json(Body) of @@ -315,6 +420,7 @@ retry_after(Headers, Default) -> end. wait(Delay, State) -> + ?event(debug_tls, {wait, {delay, Delay}}), case deadline(State) > Delay of true -> timer:sleep(Delay); false -> throw({acme, 'acme-timeout'}) @@ -333,8 +439,7 @@ http_options(ACME, Opts) -> not_found -> public_key:cacerts_get(); PEM -> certificate_chain(PEM) end, - #{ - <<"http-client">> => gun, + Opts#{ <<"protocol">> => http1, <<"http-retry">> => 0, <<"http-client-connect-timeout">> => ?REQUEST_TIMEOUT, @@ -438,3 +543,86 @@ protocol_validation_test() -> ?assert(is_bad_nonce(400, hb_json:encode(#{ <<"type">> => <<"urn:ietf:params:acme:error:badNonce">> }))). + +%% @doc Test normalization of response header names and values. +response_header_normalization_test() -> + Headers = + [ + {<<"Replay-Nonce">>, <<"nonce">>}, + {"Retry-After", "10"} + ], + ?assertEqual(<<"nonce">>, header(<<"replay-nonce">>, Headers)), + ?assertEqual(<<"10">>, header(<<"retry-after">>, Headers)), + ?assertEqual(not_found, header(<<"missing">>, Headers)). + +%% @doc Test automatic and configured ACME challenge selection. +challenge_type_test() -> + ?assertEqual( + <<"http-01">>, + challenge_type(#{}, [<<"node.example">>], #{}) + ), + ?assertEqual( + <<"dns-01">>, + challenge_type(#{}, [<<"*.node.example">>], #{}) + ), + ?assertThrow( + {acme, 'acme-wildcard-requires-dns-01'}, + challenge_type(#{<<"challenge-type">> => <<"http-01">>}, + [<<"*.node.example">>], #{}) + ). + +%% @doc Test construction and presentation of a DNS-01 challenge. +dns_challenge_test() -> + Self = self(), + Callback = + fun(Action) -> + Self ! Action, + case Action of + {dns_put, _, _} -> {ok, 42}; + _ -> ok + end + end, + Authorization = #{<<"identifier">> => #{<<"value">> => <<"node.example">>}}, + State = #{ + challenge_type => <<"dns-01">>, + deadline => erlang:monotonic_time(millisecond) + 1000 + }, + Digest = hb_util:encode(crypto:hash(sha256, <<"token.thumbprint">>)), + {Cleanup, Readiness} = present_challenge( + Authorization, <<"token">>, <<"token.thumbprint">>, Callback, State + ), + ?assertEqual(ok, run_challenge( + Cleanup, Readiness, Callback, State, fun() -> ok end + )), + Put = receive PutAction -> PutAction after 0 -> timeout end, + Wait = receive WaitAction -> WaitAction after 0 -> timeout end, + Delete = receive DeleteAction -> DeleteAction after 0 -> timeout end, + ?assertEqual( + {dns_put, <<"_acme-challenge.node.example">>, Digest}, Put + ), + ?assertMatch( + {dns_wait, <<"_acme-challenge.node.example">>, Digest, _}, Wait + ), + ?assertEqual({dns_delete, 42}, Delete). + +%% @doc Test that DNS challenge cleanup runs after validation failure. +dns_challenge_cleanup_test() -> + Self = self(), + Callback = fun(Action) -> Self ! Action, ok end, + State = #{deadline => erlang:monotonic_time(millisecond) + 1000}, + ?assertThrow(validation_failed, run_challenge( + {dns_delete, 42}, + {dns_wait, <<"_acme-challenge.node.example">>, <<"digest">>}, + Callback, + State, + fun() -> throw(validation_failed) end + )), + Wait = receive WaitAction -> WaitAction after 0 -> timeout end, + Delete = receive DeleteAction -> DeleteAction after 0 -> timeout end, + ?assertMatch({dns_wait, _, _, _}, Wait), + ?assertEqual({dns_delete, 42}, Delete). + +%% @doc Test preserving a configured HTTP client in ACME request options. +configured_http_client_test() -> + HTTPOpts = http_options(#{}, #{ <<"http-client">> => httpc }), + ?assertEqual(httpc, hb_opts:get(http_client, undefined, HTTPOpts)). diff --git a/src/preloaded/node/dev_tls_dns.erl b/src/preloaded/node/dev_tls_dns.erl new file mode 100644 index 000000000..376be3cea --- /dev/null +++ b/src/preloaded/node/dev_tls_dns.erl @@ -0,0 +1,286 @@ +%%% @doc DNS-01 challenge orchestration for the node TLS lifecycle. +-module(dev_tls_dns). +-device_libraries([lib_tls_dns_provider]). +-export([challenge/3]). +-include_lib("eunit/include/eunit.hrl"). +-include("include/hb.hrl"). + +%% @doc Execute a DNS-01 challenge action using the configured provider. +challenge(Action, TLS, Opts) -> + ACME = hb_maps:get(<<"acme">>, TLS, #{}, Opts), + Provider = hb_maps:get(<<"dns-provider">>, ACME, undefined, Opts), + ?event(tls, {dns_challenge, {provider, Provider}, + {action, action_name(Action)}}), + case Action of + {dns_wait, Record, Value, MaxWait} -> + wait_dns(Record, Value, MaxWait, ACME, Opts); + _ when Provider =:= undefined -> + {error, 'acme-dns-provider-missing'}; + _ when is_binary(Provider) -> + invoke_provider(Action, Provider, ACME, Opts); + _ -> + {error, 'invalid-acme-dns-provider'} + end. + +%% @doc Invoke a configured DNS provider through its AO-Core device API. +invoke_provider(Action, Provider, ACME, Opts) -> + try + Base = ACME#{ <<"device">> => Provider }, + provider_result(Action, hb_ao:raw(Base, provider_request(Action), Opts)) + catch + throw:{acme, Reason} -> {error, Reason}; + throw:{error, {device_not_loadable, _, Reason}} -> + {error, {'acme-dns-provider-not-loadable', Provider, Reason}}; + Class:Reason -> + {error, {'acme-dns-provider-failed', Provider, Class, Reason}} + end. + +%% @doc Convert an internal DNS action into a provider device request. +provider_request({dns_put, Record, Value}) -> + #{ + <<"path">> => <<"put">>, + <<"record">> => Record, + <<"value">> => Value + }; +provider_request({dns_delete, Handle}) -> + #{ <<"path">> => <<"delete">>, <<"handle">> => Handle }; +provider_request(_) -> + throw({acme, 'invalid-acme-dns-action'}). + +%% @doc Normalize a provider device response for the ACME client. +provider_result({dns_put, _, _}, {ok, Handle}) when is_map(Handle) -> + {ok, Handle}; +provider_result({dns_delete, _}, {ok, ok}) -> ok; +provider_result(_, {error, _} = Error) -> Error; +provider_result(_, _) -> {error, 'invalid-acme-dns-provider-response'}. + +%% @doc Return the event name for a DNS challenge action. +action_name({dns_put, _, _}) -> put; +action_name({dns_delete, _}) -> delete; +action_name({dns_wait, _, _, _}) -> wait; +action_name(_) -> invalid. + +%% @doc Wait until every authoritative nameserver for the configured zone can +%% serve the exact TXT value. Recursive resolvers are used only to discover the +%% authorities and their addresses; TXT queries go directly to those servers. +wait_dns(Record, Value, MaxWait, ACME, Opts) -> + Zone = hb_util:to_lower(hb_maps:get(<<"dns-zone">>, ACME, <<>>, Opts)), + ValidRecord = valid_name(Zone) andalso record_in_zone(Record, Zone), + maybe + {true, true, {ok, ConfiguredTimeout, Interval}} ?= + {ValidRecord, is_binary(Value), propagation_config(ACME, Opts)}, + Timeout = min(ConfiguredTimeout, MaxWait), + PollInterval = min(Interval, Timeout), + {ok, Servers} ?= authoritative_servers(Zone, Opts), + ?event(tls, {dns_propagation_started, + {nameserver_count, length(Servers)}, + {timeout_ms, Timeout}}), + Deadline = erlang:monotonic_time(millisecond) + Timeout, + poll_dns(Record, Value, Servers, Deadline, PollInterval) + else + {false, _, _} -> {error, 'invalid-acme-dns-record'}; + {_, false, _} -> {error, 'invalid-acme-dns-value'}; + {_, _, {error, _} = Error} -> Error; + {error, _} = Error -> Error + end. + +%% @doc Return whether `Record' belongs to the configured DNS `Zone'. +record_in_zone(Record, Zone) -> + try _ = lib_tls_dns_provider:relative_name(Record, Zone), true + catch _:_ -> false + end. + +%% @doc Validate and return DNS propagation polling configuration. +propagation_config(ACME, Opts) -> + Legacy = hb_maps:get(<<"dns-propagation-delay">>, ACME, 30000, Opts), + Timeout = hb_maps:get(<<"dns-propagation-timeout">>, ACME, Legacy, Opts), + Interval = hb_maps:get(<<"dns-poll-interval">>, ACME, 2000, Opts), + case is_integer(Timeout) andalso Timeout > 0 + andalso is_integer(Interval) andalso Interval > 0 + andalso Interval =< Timeout of + true -> {ok, Timeout, Interval}; + false -> {error, 'invalid-dns-propagation-options'} + end. + +%% @doc Discover the authoritative nameservers and public IPs for `Zone'. +authoritative_servers(Zone, Opts) -> + try + validate_authorities( + hb_hostname:records(Zone, ns, Opts), + fun(Name) -> hb_hostname:public_ips(Name, Opts) end + ) + catch _:_ -> {error, 'acme-dns-authorities-not-found'} + end. + +%% @doc Normalize and validate discovered authoritative nameservers. +validate_authorities(Names, Resolve) -> + Normalized = lists:usort([hb_hostname:normalize(Name) || Name <- Names]), + Servers = [{Name, Resolve(Name)} || Name <- Normalized], + case Normalized =/= [] andalso lists:all(fun({_Name, IPs}) -> + IPs =/= [] + end, Servers) of + true -> {ok, Servers}; + false -> {error, 'acme-dns-authorities-not-found'} + end. + +%% @doc Poll authoritative nameservers until they serve the expected TXT value. +poll_dns(Record, Value, Servers, Deadline, Interval) -> + Query = fun(IP) -> authoritative_txt(IP, Record, Value, Interval) end, + poll_dns(Servers, Deadline, Interval, Query). + +%% @doc Poll DNS using the supplied nameserver query function. +poll_dns(Servers, Deadline, Interval, Query) -> + Visible = lists:all(fun({_Name, IPs}) -> + lists:any(Query, IPs) + end, Servers), + case Visible of + true -> + ?event(tls, {dns_record_propagated, + {nameserver_count, length(Servers)}}), + ok; + false -> poll_dns_retry(Servers, Deadline, Interval, Query) + end. + +%% @doc Retry an unpropagated DNS query until its deadline expires. +poll_dns_retry(Servers, Deadline, Interval, Query) -> + Remaining = Deadline - erlang:monotonic_time(millisecond), + case Remaining > 0 of + true -> + timer:sleep(min(Interval, Remaining)), + poll_dns(Servers, Deadline, Interval, Query); + false -> + ?event(tls, {dns_propagation_timeout, + {nameserver_count, length(Servers)}}), + {error, 'acme-dns-propagation-timeout'} + end. + +%% @doc Return whether an authoritative server exposes the expected TXT value. +authoritative_txt(IP, Record, Value, Interval) -> + Answers = dns_lookup(Record, txt, [ + {nameservers, [{IP, 53}]}, + {timeout, min(1000, Interval)}, + {retry, 1} + ]), + lists:any(fun(Answer) -> txt_value(Answer) =:= Value end, Answers). + +%% @doc Resolve DNS records, returning an empty list on resolver failure. +dns_lookup(Name, Type, ResolverOpts) -> + try inet_res:lookup(hb_util:list(Name), in, Type, ResolverOpts) + catch _:_ -> [] + end. + +%% @doc Normalize a DNS TXT answer to a binary value. +txt_value(Answer) when is_binary(Answer) -> Answer; +txt_value(Answer) when is_list(Answer) -> + try iolist_to_binary(Answer) catch _:_ -> <<>> end; +txt_value(_) -> <<>>. + +%% @doc Return whether a DNS name is non-empty and syntactically valid. +valid_name(Name) when is_binary(Name), byte_size(Name) > 0 -> + re:run(Name, <<"^[a-z0-9.-]+$">>, [{capture, none}]) =:= match; +valid_name(_) -> false. + +%%% Tests + +%% @doc Test DNS provider configuration validation. +provider_validation_test() -> + ?assertEqual({error, 'acme-dns-provider-missing'}, + challenge({dns_put, <<"a">>, <<"b">>}, #{<<"acme">> => #{}}, #{})), + ?assertEqual({error, 'invalid-acme-dns-provider'}, challenge( + {dns_put, <<"a">>, <<"b">>}, + #{<<"acme">> => #{<<"dns-provider">> => 42}}, #{} + )). + +%% @doc Test the provider request and response interface. +provider_interface_test() -> + Handle = #{<<"record-id">> => 42}, + ?assertEqual(#{ + <<"path">> => <<"put">>, + <<"record">> => <<"record">>, + <<"value">> => <<"value">> + }, provider_request({dns_put, <<"record">>, <<"value">>})), + ?assertEqual( + #{<<"path">> => <<"delete">>, <<"handle">> => Handle}, + provider_request({dns_delete, Handle}) + ), + ?assertEqual( + {ok, Handle}, + provider_result({dns_put, <<"record">>, <<"value">>}, {ok, Handle}) + ), + ?assertEqual(ok, provider_result({dns_delete, Handle}, {ok, ok})), + ?assertEqual( + {error, 'invalid-acme-dns-provider-response'}, + provider_result({dns_put, <<"record">>, <<"value">>}, {ok, invalid}) + ). + +%% @doc Test dispatch to a separately packaged provider device. +provider_dispatch_test() -> + TLS = #{<<"acme">> => #{ + <<"dns-provider">> => <<"tls-dns-cloudflare@1.0">>, + <<"dns-zone">> => <<"example.com">>, + <<"priv-dns-api-token">> => <<"unused">> + }}, + ?assertEqual( + {error, 'invalid-cloudflare-dns-handle'}, + challenge({dns_delete, #{}}, TLS, #{}) + ). + +%% @doc Test authoritative nameserver normalization and completeness checks. +authoritative_servers_test() -> + Resolve = fun + (<<"ns1.example">>) -> [{1, 1, 1, 1}]; + (<<"ns2.example">>) -> [{8, 8, 8, 8}]; + (_) -> [] + end, + ?assertEqual( + {ok, [ + {<<"ns1.example">>, [{1, 1, 1, 1}]}, + {<<"ns2.example">>, [{8, 8, 8, 8}]} + ]}, + validate_authorities( + [<<"NS2.EXAMPLE.">>, <<"ns1.example">>, <<"ns1.example">>], + Resolve + ) + ), + ?assertEqual( + {error, 'acme-dns-authorities-not-found'}, + validate_authorities([], Resolve) + ), + ?assertEqual( + {error, 'acme-dns-authorities-not-found'}, + validate_authorities([<<"missing.example">>], Resolve) + ). + +%% @doc Test DNS propagation timeout and interval configuration. +propagation_config_test() -> + ?assertEqual({ok, 30000, 2000}, propagation_config(#{}, #{})), + ?assertEqual({ok, 45000, 1000}, propagation_config(#{ + <<"dns-propagation-delay">> => 45000, + <<"dns-poll-interval">> => 1000 + }, #{})), + ?assertEqual({ok, 60000, 1000}, propagation_config(#{ + <<"dns-propagation-delay">> => 45000, + <<"dns-propagation-timeout">> => 60000, + <<"dns-poll-interval">> => 1000 + }, #{})), + ?assertEqual({error, 'invalid-dns-propagation-options'}, + propagation_config(#{<<"dns-poll-interval">> => 0}, #{})). + +%% @doc Test polling all authoritative nameservers for DNS propagation. +authoritative_poll_test() -> + Servers = [ + {<<"ns1.example">>, [first]}, + {<<"ns2.example">>, [second]} + ], + ?assertEqual(ok, poll_dns( + Servers, erlang:monotonic_time(millisecond) + 1000, 1, + fun(_IP) -> true end + )), + ?assertEqual({error, 'acme-dns-propagation-timeout'}, poll_dns( + Servers, erlang:monotonic_time(millisecond) - 1, 1, + fun(_IP) -> false end + )), + ?assertEqual(<<"expected">>, txt_value(["ex", "pected"])), + ?assert(record_in_zone( + <<"_acme-challenge.example.com">>, <<"example.com">> + )). diff --git a/src/preloaded/node/dev_tls_dns_cloudflare.erl b/src/preloaded/node/dev_tls_dns_cloudflare.erl new file mode 100644 index 000000000..181406867 --- /dev/null +++ b/src/preloaded/node/dev_tls_dns_cloudflare.erl @@ -0,0 +1,197 @@ +%%% @doc Cloudflare implementation of the TLS DNS provider interface. +-module(dev_tls_dns_cloudflare). +-implements(<<"tls-dns-cloudflare@1.0">>). +-device_libraries([lib_tls_dns_provider]). +-export([info/1, put/3, delete/3]). +-include_lib("eunit/include/eunit.hrl"). +-include("include/hb.hrl"). + +-define(API_URL, <<"https://api.cloudflare.com/client/v4">>). + +%% @doc Describe the DNS provider interface exported by this device. +info(_Opts) -> #{exports => [put, delete]}. + +%% @doc Create an ACME DNS-01 TXT record and return its cleanup handle. +put(Base, Req, Opts) -> + Record = hb_maps:get(<<"record">>, Req, undefined, Opts), + Value = hb_maps:get(<<"value">>, Req, undefined, Opts), + lib_tls_dns_provider:with_config( + Base, Opts, ?API_URL, + fun(Zone, Token, APIURL) -> + maybe + {true, true} ?= {is_binary(Record), is_binary(Value)}, + _ = lib_tls_dns_provider:relative_name(Record, Zone), + {ok, ZoneID} ?= zone_id(Base, Opts, Zone, Token, APIURL), + {Path, Method, Body} = provider_request(Record, Value, ZoneID), + {ok, #{<<"id">> := ID}} ?= provider_result( + lib_tls_dns_provider:request( + APIURL, Path, Method, Token, Body, Opts + ) + ), + true ?= valid_id(ID), + ?event(tls, {dns_record_created, {provider, cloudflare}}), + {ok, #{ + <<"provider">> => <<"cloudflare">>, + <<"zone">> => Zone, + <<"zone-id">> => ZoneID, + <<"record-id">> => ID + }} + else + {false, _} -> {error, 'invalid-acme-dns-record'}; + {_, false} -> {error, 'invalid-acme-dns-value'}; + {ok, _} -> {error, 'invalid-cloudflare-dns-response'}; + false -> {error, 'invalid-cloudflare-dns-response'}; + {error, _} = Error -> Error + end + end + ). + +%% @doc Delete an ACME DNS-01 TXT record from a cleanup handle. +delete(Base, Req, Opts) -> + Handle = hb_maps:get(<<"handle">>, Req, #{}, Opts), + lib_tls_dns_provider:with_config( + Base, Opts, ?API_URL, + fun(ConfiguredZone, Token, APIURL) -> + Zone = hb_maps:get(<<"zone">>, Handle, undefined, Opts), + ZoneID = hb_maps:get(<<"zone-id">>, Handle, undefined, Opts), + ID = hb_maps:get(<<"record-id">>, Handle, undefined, Opts), + Provider = hb_maps:get(<<"provider">>, Handle, undefined, Opts), + maybe + true ?= Provider =:= <<"cloudflare">> + andalso Zone =:= ConfiguredZone andalso valid_id(ZoneID) + andalso valid_id(ID), + Path = <<"/zones/", ZoneID/binary, "/dns_records/", ID/binary>>, + {ok, _} ?= provider_result(lib_tls_dns_provider:request( + APIURL, Path, <<"DELETE">>, Token, <<>>, Opts + )), + ?event(tls, {dns_record_deleted, {provider, cloudflare}}), + {ok, ok} + else + false -> {error, 'invalid-cloudflare-dns-handle'}; + {error, _} = Error -> Error + end + end + ). + +%% @doc Build a Cloudflare DNS record creation request. +provider_request(Record, Value, ZoneID) -> + { + <<"/zones/", ZoneID/binary, "/dns_records">>, + <<"POST">>, + hb_json:encode(#{ + <<"type">> => <<"TXT">>, + <<"name">> => Record, + <<"content">> => Value, + <<"ttl">> => 60 + }) + }. + +%% @doc Resolve and validate the Cloudflare zone identifier. +zone_id(Base, Opts, Zone, Token, APIURL) -> + case hb_maps:get(<<"dns-zone-id">>, Base, undefined, Opts) of + ZoneID when is_binary(ZoneID) -> + maybe + true ?= valid_id(ZoneID), + {ok, ZoneID} + else + false -> {error, 'invalid-cloudflare-zone-id'} + end; + undefined -> + Path = <<"/zones?name=", Zone/binary, + "&status=active&per_page=1">>, + maybe + {ok, [#{<<"id">> := ZoneID}]} ?= provider_result( + lib_tls_dns_provider:request( + APIURL, Path, <<"GET">>, Token, <<>>, Opts + ) + ), + true ?= valid_id(ZoneID), + {ok, ZoneID} + else + {ok, _} -> {error, 'cloudflare-zone-not-found'}; + false -> {error, 'invalid-cloudflare-zone-response'}; + {error, _} = Error -> Error + end; + _ -> {error, 'invalid-cloudflare-zone-id'} + end. + +%% @doc Normalize a Cloudflare API response. +provider_result({ok, 200, _, Body}) -> + try + case hb_json:decode(Body) of + #{<<"success">> := true, <<"result">> := Result} -> {ok, Result}; + Response -> {error, {'cloudflare-dns-error', Response}} + end + catch _:_ -> {error, 'invalid-cloudflare-dns-response'} + end; +provider_result({ok, Status, _, Body}) -> + {error, {'cloudflare-dns-status', Status, + lib_tls_dns_provider:problem(Body)}}; +provider_result({error, Reason}) -> {error, Reason}. + +%% @doc Return whether an API identifier has the expected hexadecimal form. +valid_id(ID) when is_binary(ID) -> + re:run(ID, <<"^[A-Fa-f0-9]{32}$">>, [{capture, none}]) =:= match; +valid_id(_) -> false. + +%%% Tests + +%% @doc Test exact Cloudflare request paths and record bodies. +provider_request_test() -> + ZoneID = <<"023e105f4ecef8ad9ca31a8372d0c353">>, + Record = <<"_acme-challenge.example.com">>, + Value = <<"digest">>, + {Path, <<"POST">>, Body} = provider_request(Record, Value, ZoneID), + ?assertEqual( + <<"/zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records">>, + Path + ), + ?assertEqual(#{ + <<"type">> => <<"TXT">>, + <<"name">> => Record, + <<"content">> => Value, + <<"ttl">> => 60 + }, hb_json:decode(Body)). + +%% @doc Test Cloudflare response and identifier validation. +provider_response_test() -> + ZoneID = <<"023e105f4ecef8ad9ca31a8372d0c353">>, + ?assertEqual({ok, ZoneID}, zone_id( + #{<<"dns-zone-id">> => ZoneID}, #{}, <<"example.com">>, + <<"unused">>, <<"https://unused.example">> + )), + ?assertEqual({ok, #{<<"id">> => ZoneID}}, provider_result( + {ok, 200, [], hb_json:encode(#{ + <<"success">> => true, + <<"result">> => #{<<"id">> => ZoneID} + })} + )), + ?assertMatch({error, {'cloudflare-dns-error', _}}, provider_result( + {ok, 200, [], hb_json:encode(#{ + <<"success">> => false, + <<"errors">> => [#{<<"message">> => <<"denied">>}] + })} + )), + ?assertEqual( + {error, 'invalid-cloudflare-dns-response'}, + provider_result({ok, 200, [], <<"not-json">>}) + ), + ?assertMatch( + {error, {'cloudflare-dns-status', 403, _}}, + provider_result({ok, 403, [], <<"denied">>}) + ), + ?assertEqual(false, valid_id(<<"not-a-zone-id">>)), + ?assertEqual( + {error, 'acme-dns-record-outside-zone'}, + put( + #{ + <<"dns-zone">> => <<"example.com">>, + <<"priv-dns-api-token">> => <<"unused">> + }, + #{ + <<"record">> => <<"_acme-challenge.other.example">>, + <<"value">> => <<"value">> + }, + #{} + ) + ). diff --git a/src/preloaded/node/dev_tls_dns_digitalocean.erl b/src/preloaded/node/dev_tls_dns_digitalocean.erl new file mode 100644 index 000000000..eaa45e8ae --- /dev/null +++ b/src/preloaded/node/dev_tls_dns_digitalocean.erl @@ -0,0 +1,133 @@ +%%% @doc DigitalOcean implementation of the TLS DNS provider interface. +-module(dev_tls_dns_digitalocean). +-implements(<<"tls-dns-digitalocean@1.0">>). +-device_libraries([lib_tls_dns_provider]). +-export([info/1, put/3, delete/3]). +-include_lib("eunit/include/eunit.hrl"). +-include("include/hb.hrl"). + +-define(API_URL, <<"https://api.digitalocean.com/v2">>). + +%% @doc Describe the DNS provider interface exported by this device. +info(_Opts) -> #{exports => [put, delete]}. + +%% @doc Create an ACME DNS-01 TXT record and return its cleanup handle. +put(Base, Req, Opts) -> + Record = hb_maps:get(<<"record">>, Req, undefined, Opts), + Value = hb_maps:get(<<"value">>, Req, undefined, Opts), + lib_tls_dns_provider:with_config( + Base, Opts, ?API_URL, + fun(Zone, Token, APIURL) -> + maybe + {true, true} ?= {is_binary(Record), is_binary(Value)}, + {Path, Method, Body} = provider_request(Record, Value, Zone), + {ok, ID} ?= create_result(lib_tls_dns_provider:request( + APIURL, Path, Method, Token, Body, Opts + )), + ?event(tls, {dns_record_created, {provider, digitalocean}}), + {ok, #{ + <<"provider">> => <<"digitalocean">>, + <<"zone">> => Zone, + <<"record-id">> => ID + }} + else + {false, _} -> {error, 'invalid-acme-dns-record'}; + {_, false} -> {error, 'invalid-acme-dns-value'}; + {error, _} = Error -> Error + end + end + ). + +%% @doc Delete an ACME DNS-01 TXT record from a cleanup handle. +delete(Base, Req, Opts) -> + Handle = hb_maps:get(<<"handle">>, Req, #{}, Opts), + lib_tls_dns_provider:with_config( + Base, Opts, ?API_URL, + fun(ConfiguredZone, Token, APIURL) -> + Zone = hb_maps:get(<<"zone">>, Handle, undefined, Opts), + ID = hb_maps:get(<<"record-id">>, Handle, undefined, Opts), + Provider = hb_maps:get(<<"provider">>, Handle, undefined, Opts), + maybe + true ?= Provider =:= <<"digitalocean">> + andalso Zone =:= ConfiguredZone andalso is_integer(ID), + Path = <<"/domains/", Zone/binary, "/records/", + (integer_to_binary(ID))/binary>>, + ok ?= delete_result(lib_tls_dns_provider:request( + APIURL, Path, <<"DELETE">>, Token, <<>>, Opts + )), + ?event(tls, {dns_record_deleted, {provider, digitalocean}}), + {ok, ok} + else + false -> {error, 'invalid-digitalocean-dns-handle'}; + {error, _} = Error -> Error + end + end + ). + +%% @doc Build a DigitalOcean DNS record creation request. +provider_request(Record, Value, Zone) -> + { + <<"/domains/", Zone/binary, "/records">>, + <<"POST">>, + hb_json:encode(#{ + <<"type">> => <<"TXT">>, + <<"name">> => lib_tls_dns_provider:relative_name(Record, Zone), + <<"data">> => Value, + <<"ttl">> => 30 + }) + }. + +%% @doc Validate a DigitalOcean DNS record creation response. +create_result({ok, 201, _, Body}) -> + try + #{<<"domain_record">> := #{<<"id">> := ID}} = hb_json:decode(Body), + true = is_integer(ID), + {ok, ID} + catch _:_ -> {error, 'invalid-digitalocean-dns-response'} + end; +create_result(Response) -> provider_error(Response). + +%% @doc Validate a DigitalOcean DNS record deletion response. +delete_result({ok, 204, _, _}) -> ok; +delete_result(Response) -> provider_error(Response). + +%% @doc Normalize a DigitalOcean API error response. +provider_error({ok, Status, _, Body}) -> + {error, {'digitalocean-dns-status', Status, + lib_tls_dns_provider:problem(Body)}}; +provider_error({error, Reason}) -> {error, Reason}. + +%%% Tests + +%% @doc Test exact DigitalOcean request paths and record bodies. +provider_request_test() -> + Zone = <<"example.com">>, + Record = <<"_acme-challenge.example.com">>, + Value = <<"digest">>, + {Path, <<"POST">>, Body} = provider_request(Record, Value, Zone), + ?assertEqual(<<"/domains/example.com/records">>, Path), + ?assertEqual(#{ + <<"type">> => <<"TXT">>, + <<"name">> => <<"_acme-challenge">>, + <<"data">> => Value, + <<"ttl">> => 30 + }, hb_json:decode(Body)). + +%% @doc Test DigitalOcean response validation. +provider_response_test() -> + ?assertEqual({ok, 42}, create_result( + {ok, 201, [], hb_json:encode(#{ + <<"domain_record">> => #{<<"id">> => 42} + })} + )), + ?assertEqual( + {error, 'invalid-digitalocean-dns-response'}, + create_result({ok, 201, [], hb_json:encode(#{ + <<"domain_record">> => #{<<"id">> => <<"invalid">>} + })}) + ), + ?assertMatch( + {error, {'digitalocean-dns-status', 403, _}}, + create_result({ok, 403, [], <<"denied">>}) + ), + ?assertEqual(ok, delete_result({ok, 204, [], <<>>})). diff --git a/src/preloaded/node/lib_tls_dns_provider.erl b/src/preloaded/node/lib_tls_dns_provider.erl new file mode 100644 index 000000000..916c1dbc5 --- /dev/null +++ b/src/preloaded/node/lib_tls_dns_provider.erl @@ -0,0 +1,118 @@ +%%% @doc Shared utilities for TLS DNS provider devices. +-module(lib_tls_dns_provider). +-export([with_config/4, relative_name/2, request/6, problem/1]). +-include_lib("eunit/include/eunit.hrl"). +-include("include/hb.hrl"). + +-define(REQUEST_TIMEOUT, 30000). +-define(RESPONSE_LIMIT, 1024 * 1024). + +%% @doc Validate common provider configuration before invoking `Fun'. +with_config(Base, Opts, DefaultAPIURL, Fun) -> + Zone = hb_util:to_lower(hb_maps:get(<<"dns-zone">>, Base, <<>>, Opts)), + Token = token(hb_maps:get(<<"priv-dns-api-token">>, Base, undefined, Opts)), + APIURL = hb_maps:get(<<"dns-api-url">>, Base, DefaultAPIURL, Opts), + case {valid_name(Zone), Token, valid_api_url(APIURL)} of + {true, {ok, Value}, true} -> + try Fun(Zone, Value, APIURL) + catch throw:{acme, Reason} -> {error, Reason} + end; + {false, _, _} -> {error, 'invalid-acme-dns-zone'}; + {_, {error, Reason}, _} -> {error, Reason}; + {_, _, false} -> {error, 'invalid-acme-dns-api-url'} + end. + +%% @doc Validate a DNS provider API token. +token(undefined) -> {error, 'acme-dns-api-token-missing'}; +token(Token) when is_binary(Token), byte_size(Token) > 0 -> {ok, Token}; +token(_) -> {error, 'invalid-acme-dns-api-token'}. + +%% @doc Return a DNS record name relative to `Zone'. +relative_name(Zone, Zone) -> <<"@">>; +relative_name(Record, Zone) -> + Suffix = <<".", Zone/binary>>, + Size = byte_size(Record) - byte_size(Suffix), + case Size > 0 andalso binary:part(Record, Size, byte_size(Suffix)) + =:= Suffix of + true -> binary:part(Record, 0, Size); + false -> throw({acme, 'acme-dns-record-outside-zone'}) + end. + +%% @doc Return whether a DNS name is non-empty and syntactically valid. +valid_name(Name) when is_binary(Name), byte_size(Name) > 0 -> + re:run(Name, <<"^[a-z0-9.-]+$">>, [{capture, none}]) =:= match; +valid_name(_) -> false. + +%% @doc Return whether a provider API URL is a safe HTTPS base URL. +valid_api_url(URL) when is_binary(URL) -> + try + URI = uri_string:parse(URL), + hb_util:to_lower(hb_util:bin(maps:get(scheme, URI, <<>>))) =:= <<"https">> + andalso maps:is_key(host, URI) + andalso not maps:is_key(userinfo, URI) + andalso not maps:is_key(query, URI) + andalso not maps:is_key(fragment, URI) + catch _:_ -> false + end; +valid_api_url(_) -> false. + +%% @doc Send an authenticated request to a DNS provider API. +request(APIURL, Path, Method, Token, Body, Opts) -> + URL = <>, + URI = uri_string:parse(URL), + Peer = uri_string:recompose( + (maps:without([path, query], URI))#{path => <<>>} + ), + RequestPath = case maps:find(query, URI) of + {ok, Query} -> <<(hb_util:bin(maps:get(path, URI)))/binary, + "?", (hb_util:bin(Query))/binary>>; + error -> hb_util:bin(maps:get(path, URI)) + end, + hb_http_client:request(#{ + peer => Peer, + path => RequestPath, + method => Method, + headers => #{ + <<"authorization">> => <<"Bearer ", Token/binary>>, + <<"content-type">> => <<"application/json">>, + <<"user-agent">> => <<"HyperBEAM ACME">> + }, + body => Body, + limit => ?RESPONSE_LIMIT + }, http_options(Opts)). + +%% @doc Build bounded HTTP options for DNS provider API requests. +http_options(Opts) -> + Opts#{ + <<"protocol">> => http1, + <<"http-retry">> => 0, + <<"http-client-connect-timeout">> => ?REQUEST_TIMEOUT, + <<"http-client-send-timeout">> => ?REQUEST_TIMEOUT, + <<"http-client-tls-ca">> => public_key:cacerts_get() + }. + +%% @doc Decode a provider error body when it contains JSON. +problem(Body) -> + try hb_json:decode(Body) catch _:_ -> Body end. + +%%% Tests + +%% @doc Test common provider configuration validation. +provider_validation_test() -> + ?assertEqual(false, valid_api_url(<<"http://api.example/v2">>)), + ?assertEqual({error, 'invalid-acme-dns-api-token'}, token(<<>>)), + ?assertEqual({error, 'acme-dns-api-token-missing'}, token(undefined)). + +%% @doc Test conversion of fully qualified records to zone-relative names. +relative_name_test() -> + ?assertEqual(<<"_acme-challenge.hb">>, relative_name( + <<"_acme-challenge.hb.ajprincipe.pt">>, <<"ajprincipe.pt">> + )), + ?assertThrow({acme, 'acme-dns-record-outside-zone'}, relative_name( + <<"_acme-challenge.other.example">>, <<"ajprincipe.pt">> + )). + +%% @doc Test preserving a configured HTTP client in provider request options. +configured_http_client_test() -> + HTTPOpts = http_options(#{ <<"http-client">> => httpc }), + ?assertEqual(httpc, hb_opts:get(http_client, undefined, HTTPOpts)).