diff --git a/README.md b/README.md index 188af0e1..b87eeedd 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ | 🌐 DNS mode | Use DNS TXT records | | 🔗 [DNS alias mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode) | Use DNS alias for verification | | 📡 [Stateless mode](https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode) | Stateless verification | +| 📌 DNS persist mode | Persistent DNS TXT record ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)) | --- @@ -396,7 +397,51 @@ acme.sh --renew -d example.com --- -### 🔟 Issue Certificates of Different Key Types (ECC or RSA) +### 🔟 Use DNS Persist Mode + +📚 Spec: [draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) + +DNS persist mode lets you place a **single, long‑lived `_validation-persist` TXT record** in your zone and reuse it for every subsequent issuance and renewal. There is no per-issuance challenge token, so renewals require **no DNS edits** — useful when DNS API access is not available but you still want unattended renewals. + +#### 🪄 Step 1: Print the TXT record value + +```bash +acme.sh --make-dns-persist-value -d example.com [--server letsencrypt] [--dns-persist-wildcard] [--dns-persist-ca-name "sectigo.com"] [--dns-persist-days 365] +``` + +Options: + +| Flag | Description | +|------|-------------| +| `--server ` | Pick the CA (default is your configured default). The account is registered automatically if you have not used this CA before. | +| `--dns-persist-wildcard` | Adds `policy=wildcard` to the record so it also authorizes wildcard / subdomain certs. | +| `--dns-persist-ca-name ` | Use a specific CA identity domain (e.g. `sectigo.com`). If omitted, identities are read from the ACME directory's `caaIdentities` field and one record per identity is printed — you only need to add **any one** of them. | +| `--dns-persist-days ` | Adds `persistUntil=` to the record, set to N days from now. The CA will refuse new validations against the record after that time. Omit for a record with no expiry. | + +You should get an output like: + +```sh +TXT persist domain:_validation-persist.example.com +TXT persist value :"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456789" +``` + +#### ✍️ Step 2: Add the TXT record to your DNS + +Add the printed `TXT persist domain` / `TXT persist value` pair as a TXT record at your DNS provider, then wait for it to propagate. + +#### 📜 Step 3: Issue the certificate + +```bash +acme.sh --issue -d example.com --dns-persist +``` + +✅ **Done!** No challenge token is provisioned during issuance — the CA reads the persistent TXT record directly. + +> 🔄 Renewals just work: `acme.sh --renew -d example.com` (or the cron job) reuses the same TXT record automatically — no further DNS edits needed. + +--- + +### 1️⃣1️⃣ Issue Certificates of Different Key Types (ECC or RSA) Just set the `keylength` to a valid, supported value. @@ -427,7 +472,7 @@ acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com - --- -### 1️⃣1️⃣ Issue Wildcard Certificates +### 1️⃣2️⃣ Issue Wildcard Certificates It's simple! Just give a wildcard domain as the `-d` parameter: @@ -439,9 +484,9 @@ acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf --- -### 1️⃣2️⃣ How to Renew Certificates +### 1️⃣3️⃣ How to Renew Certificates -> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days. +> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days, **or earlier when the CA's ARI says so** (see below). However, you can force a renewal: @@ -455,9 +500,41 @@ acme.sh --renew -d example.com --force acme.sh --renew -d example.com --force --ecc ``` +#### 📡 ACME Renewal Information (ARI) — RFC 9773 + +If the CA exposes a `renewalInfo` endpoint in its ACME directory (Let's Encrypt, ZeroSSL, etc.), `acme.sh` follows [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html) automatically — **no flag needed, no opt-in**: + +| What | When | Why | +|------|------|-----| +| 🔍 **Polls `suggestedWindow`** | Every cron run, before deciding to skip | Lets the CA shift the renewal time forward in case of an incident (key compromise, mass revocation, etc.) | +| 🎯 **Picks a random renewal time** inside the window | Right after a successful issuance/renewal | Disperses renewals across the network so all clients don't hit the CA at the same instant | +| 🔗 **Sends `replaces=`** in `newOrder` | On renewal | Lets the CA correlate the new order with the certificate it supersedes (RFC 9773 §5) | +| ↩️ **Retries without `replaces`** | If the CA rejects with `alreadyReplaced` or an ARI validation error | Robust against edge cases (e.g. switching CAs, retired issuers) | + +**Renewal trigger logic:** the cert is renewed if **any one** of the following becomes true: + +1. `--force` is given +2. The CA's **ARI `suggestedWindow` has started** +3. The cached `Le_NextRenewTime` has passed (default fallback for CAs without ARI) + +You can see the resulting next renewal time (already ARI-picked when applicable) in: + +```sh +acme.sh --info -d example.com +# Look for: Le_NextRenewTimeStr=... +``` + +For the live ARI window the CA is currently advertising, run with `--debug 2`: + +```sh +acme.sh --renew -d example.com --debug 2 2>&1 | grep -i 'ARI suggestedWindow' +``` + +> 💡 If your CA does not advertise `renewalInfo`, `acme.sh` falls back to the classic 30-day rule — no behavior change. + --- -### 1️⃣3️⃣ How to Stop Certificate Renewal +### 1️⃣4️⃣ How to Stop Certificate Renewal To stop renewal of a cert, you can execute the following to remove the cert from the renewal list: @@ -471,7 +548,7 @@ The cert/key file is not removed from the disk. --- -### 1️⃣4️⃣ How to Upgrade acme.sh +### 1️⃣5️⃣ How to Upgrade acme.sh > 🚀 acme.sh is in constant development — it's strongly recommended to use the latest code. @@ -495,25 +572,25 @@ acme.sh --upgrade --auto-upgrade 0 --- -### 1️⃣5️⃣ Issue a Certificate from an Existing CSR +### 1️⃣6️⃣ Issue a Certificate from an Existing CSR 📚 https://github.com/acmesh-official/acme.sh/wiki/Issue-a-cert-from-existing-CSR --- -### 1️⃣6️⃣ Send Notifications in Cronjob +### 1️⃣7️⃣ Send Notifications in Cronjob 📚 https://github.com/acmesh-official/acme.sh/wiki/notify --- -### 1️⃣7️⃣ Under the Hood +### 1️⃣8️⃣ Under the Hood > 🔧 Speak ACME language using shell, directly to "Let's Encrypt". --- -### 1️⃣8️⃣ Acknowledgments +### 1️⃣9️⃣ Acknowledgments | Project | Link | |---------|------| @@ -555,7 +632,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 1️⃣9️⃣ License & Others +### 2️⃣0️⃣ License & Others 📄 **License:** GPLv3 @@ -565,7 +642,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 2️⃣0️⃣ Donate +### 2️⃣1️⃣ Donate > 💝 Your donation makes **acme.sh** better! @@ -577,7 +654,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 2️⃣1️⃣ About This Repository +### 2️⃣2️⃣ About This Repository > [!NOTE] > This repository is officially maintained by ZeroSSL as part of our commitment to providing secure and reliable SSL/TLS solutions. We welcome contributions and feedback from the community! diff --git a/acme.sh b/acme.sh index e780f61d..6c191c92 100755 --- a/acme.sh +++ b/acme.sh @@ -59,6 +59,7 @@ DEFAULT_OPENSSL_BIN="openssl" VTYPE_HTTP="http-01" VTYPE_DNS="dns-01" VTYPE_ALPN="tls-alpn-01" +VTYPE_DNS_PERSIST="dns-persist-01" ID_TYPE_DNS="dns" ID_TYPE_IP="ip" @@ -71,6 +72,7 @@ NO_VALUE="no" W_DNS="dns" W_ALPN="alpn" +W_DNS_PERSIST="dns_persist" DNS_ALIAS_PREFIX="=" MODE_STATELESS="stateless" @@ -1013,6 +1015,24 @@ _checkcert() { fi } +#file +_enddate() { + _cf="$1" + _res="$(${ACME_OPENSSL_BIN:-openssl} x509 -noout -enddate -in "$_cf")" + if [ "$?" != "0" ] || [ -z "$_res" ]; then + return 1 + fi + + case "$_res" in + notAfter=*) + echo "${_res#notAfter=}" + ;; + *) + return 1 + ;; + esac +} + #Usage: hashalg [outputhex] #Output Base64-encoded digest _digest() { @@ -1037,6 +1057,25 @@ _digest() { } +#Usage: certpath hashalg +#Output certificate fingerprint without colons +_fingerprint() { + cert="$1" + alg="$2" + if [ -z "$alg" ]; then + _usage "Usage: _fingerprint certpath hashalg" + return 1 + fi + + if [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then + # openssl prints "SHA1 Fingerprint=AA:BB:CC:..."; strip prefix and colons. + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$cert" -noout -fingerprint -"$alg" | sed 's/.*=//; s/://g' + else + _err "$alg is not supported yet" + return 1 + fi +} + #Usage: hashalg secret_hex [outputhex] #Output binary hmac _hmac() { @@ -1844,6 +1883,25 @@ _date2time() { return 1 } +#support the output format of openssl -enddate: +# Apr 01 08:10:33 2022 GMT to 1641283833 +_ssldate2time() { + #Linux + if date -u -d "$1" +"%s" 2>/dev/null; then + return + fi + #Solaris + if gdate -u -d "$1" +"%s" 2>/dev/null; then + return + fi + #Mac/BSD + if date -j -f "%b %d %T %Y %Z" "$1" +"%s" 2>/dev/null; then + return + fi + _err "Cannot parse _ssldate2time $1" + return 1 +} + _utc_date() { date -u "+%Y-%m-%d %H:%M:%S" } @@ -4028,6 +4086,104 @@ deactivateaccount() { fi } +#domain wildcard ca_name days +#Print the TXT record(s) the user must add to enable persistent DNS validation +#per draft-ietf-acme-dns-persist-01. +makednspersistvalue() { + _mdpv_domain="$1" + _mdpv_wildcard="$2" + _mdpv_ca_name="$3" + _mdpv_days="$4" + + if [ -z "$_mdpv_domain" ]; then + _err "Please specify a domain with -d." + return 1 + fi + + if [ -n "$_mdpv_days" ]; then + case "$_mdpv_days" in + '' | *[!0-9]*) + _err "--dns-persist-days must be a positive integer, got: $_mdpv_days" + return 1 + ;; + esac + if [ "$_mdpv_days" -lt 1 ]; then + _err "--dns-persist-days must be at least 1." + return 1 + fi + fi + + _initpath + + _accUri="$(_readcaconf ACCOUNT_URL)" + if [ -z "$_accUri" ]; then + _info "No account is registered for $ACME_DIRECTORY yet, registering one now..." + if ! _regAccount "$DEFAULT_ACCOUNT_KEY_LENGTH"; then + _err "Cannot register account." + return 1 + fi + _accUri="$(_readcaconf ACCOUNT_URL)" + fi + + if [ -z "$_accUri" ]; then + _err "Cannot determine the ACME account URL." + return 1 + fi + _debug "Account URL" "$_accUri" + + _txt_name="_validation-persist.$_mdpv_domain" + + _txt_suffix="; accounturi=$_accUri" + if [ "$_mdpv_wildcard" = "1" ]; then + _txt_suffix="$_txt_suffix; policy=wildcard" + fi + if [ -n "$_mdpv_days" ]; then + _persist_until=$(_math "$(_time)" + "$_mdpv_days" \* 86400) + _txt_suffix="$_txt_suffix; persistUntil=$_persist_until" + _info "persistUntil set to $(__green "$(_time2str "$_persist_until")") ($_mdpv_days days from now)" + fi + + if [ -n "$_mdpv_ca_name" ]; then + _info "" + _info "Add the following DNS TXT record to enable persistent DNS validation:" + _info "" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")" + _info "" + return 0 + fi + + _info "Fetching ACME directory: $ACME_DIRECTORY" + _dir_resp="$(_get "$ACME_DIRECTORY" "" 30)" + if [ "$?" != "0" ] || [ -z "$_dir_resp" ]; then + _err "Cannot fetch ACME directory: $ACME_DIRECTORY" + return 1 + fi + _dir_resp="$(echo "$_dir_resp" | _json_decode)" + _debug2 _dir_resp "$_dir_resp" + + _caa_array="$(echo "$_dir_resp" | tr -d ' \r\n\t' | _egrep_o '"caaIdentities":\[[^]]*\]')" + _debug2 _caa_array "$_caa_array" + _caaids="$(echo "$_caa_array" | sed 's/.*\[//' | sed 's/\].*//' | tr ',' '\n' | tr -d '"')" + _debug2 _caaids "$_caaids" + + if [ -z "$_caaids" ]; then + _err "The directory does not include 'caaIdentities'. Please specify --dns-persist-ca-name explicitly." + return 1 + fi + + _info "" + _info "Add ANY ONE of the following DNS TXT records to enable persistent DNS validation." + _info "(You only need to add one; pick whichever issuer identity you prefer.)" + for _id in $_caaids; do + [ -z "$_id" ] && continue + _info "" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_id$_txt_suffix\"")")" + done + _info "" +} + # domain folder file _findHook() { _hookdomain="$1" @@ -4709,13 +4865,41 @@ issue() { if [ "$_certificate_profile" ]; then _newOrderObj="$_newOrderObj,\"profile\": \"$_certificate_profile\"" fi + + # RFC 9773 Section 5: include "replaces" only when this is an actual + # renewal (--renew path), the CA advertises renewalInfo, and a prior + # cert exists. --issue (even with --force) is not a renewal per RFC 9773 + # which speaks of "a clear predecessor certificate" issued by this CA. + _replaces_certID="" + if [ "$_ACME_IS_RENEW" = "1" ] && [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then + _replaces_certID="$(_getARICertID "$CERT_PATH")" + _debug "Adding ARI replaces" "$_replaces_certID" + fi + _debug "STEP 1, Ordering a Certificate" - if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _newOrderReplacesObj="$_newOrderObj" + if [ "$_replaces_certID" ]; then + _newOrderReplacesObj="$_newOrderObj,\"replaces\": \"$_replaces_certID\"" + fi + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderReplacesObj}"; then _err "Error creating new order." _clearup _on_issue_err "$_post_hook" return 1 fi + # RFC 9773 Section 5 only defines the "alreadyReplaced" error, but real CAs + # (Let's Encrypt) may also reject with a malformed error if the prior cert + # was issued by a different issuer / different CA. Retry without "replaces" + # whenever the failure mentions ARI or the replaces field. + if [ "$_replaces_certID" ] && { _contains "$response" "alreadyReplaced" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then + _info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'." + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _err "Error creating new order." + _clearup + _on_issue_err "$_post_hook" + return 1 + fi + fi if _contains "$response" "invalid"; then if echo "$response" | _normalizeJson | grep '"status":"invalid"' >/dev/null 2>&1; then _err "Create new order with invalid status." @@ -4806,7 +4990,9 @@ $_authorizations_map" vtype="$VTYPE_HTTP" #todo, v2 wildcard force to use dns - if _startswith "$_currentRoot" "$W_DNS"; then + if [ "$_currentRoot" = "$W_DNS_PERSIST" ]; then + vtype="$VTYPE_DNS_PERSIST" + elif _startswith "$_currentRoot" "$W_DNS"; then vtype="$VTYPE_DNS" fi @@ -4864,18 +5050,7 @@ $_authorizations_map" fi if [ -z "$keyauthorization" ]; then - token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')" - _debug token "$token" - - if [ -z "$token" ]; then - _err "Cannot get domain token $entry" - _clearup - _on_issue_err "$_post_hook" - return 1 - fi - uri="$(echo "$entry" | _egrep_o '"url":"[^"]*' | cut -d '"' -f 4 | _head_n 1)" - _debug uri "$uri" if [ -z "$uri" ]; then @@ -4884,8 +5059,26 @@ $_authorizations_map" _on_issue_err "$_post_hook" return 1 fi - keyauthorization="$token.$thumbprint" - _debug keyauthorization "$keyauthorization" + + if [ "$vtype" = "$VTYPE_DNS_PERSIST" ]; then + # dns-persist-01 challenges have no token; the TXT record is + # provisioned out-of-band. Use a non-empty placeholder so the + # downstream code does not treat this entry as already verified. + keyauthorization="$VTYPE_DNS_PERSIST" + _debug keyauthorization "$keyauthorization" + else + token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')" + _debug token "$token" + + if [ -z "$token" ]; then + _err "Cannot get domain token $entry" + _clearup + _on_issue_err "$_post_hook" + return 1 + fi + keyauthorization="$token.$thumbprint" + _debug keyauthorization "$keyauthorization" + fi fi dvlist="$d$sep$keyauthorization$sep$uri$sep$vtype$sep$_currentRoot$sep$_authz_url" @@ -5427,7 +5620,7 @@ $_authorizations_map" Le_CertCreateTimeStr=$(_time2str "$Le_CertCreateTime") _savedomainconf "Le_CertCreateTimeStr" "$Le_CertCreateTimeStr" - if [ -z "$Le_RenewalDays" ] || [ "$Le_RenewalDays" -lt "0" ]; then + if [ -z "$Le_RenewalDays" ]; then Le_RenewalDays="$DEFAULT_RENEW" else _savedomainconf "Le_RenewalDays" "$Le_RenewalDays" @@ -5486,11 +5679,49 @@ $_authorizations_map" Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi fi + elif [ "$Le_RenewalDays" -lt "0" ]; then + _enddate_value=$(_enddate "$CERT_PATH") + if [ "$?" != "0" ] || [ -z "$_enddate_value" ]; then + _err "Failed to get certificate end date for $CERT_PATH" + return 1 + fi + + _endtime=$(_ssldate2time "$_enddate_value") + if [ "$?" != "0" ] || [ -z "$_endtime" ]; then + _err "Cannot parse _enddate_value: $_enddate_value" + return 1 + fi + Le_NextRenewTime=$(_math "$_endtime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") else Le_NextRenewTime=$(_math "$Le_CertCreateTime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400) Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi + + # RFC 9773 ARI: if the CA exposes renewalInfo, override Le_NextRenewTime + # with a time picked at random within the suggestedWindow. This both gives + # the CA full control over renewal scheduling and disperses renewals across + # the network so all clients don't hit the CA at the same instant. + if [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then + _ari_resp_new="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp_new" "$_ari_resp_new" + _ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end_new="$(echo "$_ari_resp_new" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then + _ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")" + _ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")" + if [ "$_ari_start_t_new" ] && [ "$_ari_end_t_new" ] && [ "$_ari_end_t_new" -gt "$_ari_start_t_new" ]; then + _ari_window=$(_math "$_ari_end_t_new" - "$_ari_start_t_new") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")" + _info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + fi + fi + fi + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" @@ -5586,7 +5817,31 @@ renew() { _debug2 "initpath again." _initpath "$Le_Domain" "$_isEcc" - if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + # ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check. + # If the window has started, renew now even if Le_NextRenewTime is in the future. + _ari_should_renew="" + if [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then + if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then + _ari_resp="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp" "$_ari_resp" + _ari_start="$(echo "$_ari_resp" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + _debug "ARI suggestedWindow.start" "$_ari_start" + _debug "ARI suggestedWindow.end" "$_ari_end" + if [ "$_ari_start" ]; then + _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _debug "_ari_start_t" "$_ari_start_t" + if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then + _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal." + _ari_should_renew="1" + else + _info "ARI suggestedWindow starts at: $(__green "$_ari_start")" + fi + fi + fi + fi + + if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6274,13 +6529,13 @@ installcronjob() { return 1 fi _info "Installing cron job" - if ! $_CRONTAB -l | grep "$PROJECT_ENTRY --cron"; then + if ! $_CRONTAB -l 2>/dev/null | grep "$PROJECT_ENTRY --cron"; then if _exists uname && uname -a | grep SunOS >/dev/null; then _CRONTAB_STDIN="$_CRONTAB --" else _CRONTAB_STDIN="$_CRONTAB -" fi - $_CRONTAB -l | { + $_CRONTAB -l 2>/dev/null | { cat echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" } | $_CRONTAB_STDIN @@ -6608,21 +6863,29 @@ _getSerial() { } #cert -_get_ARI() { +#Compute the ARI/replaces certID for a cert: base64url(AKI).base64url(Serial) +#per RFC 9773 Section 4.1. +_getARICertID() { _cert="$1" _aki=$(_getAKI "$_cert") _ser=$(_getSerial "$_cert") _debug2 "_aki" "$_aki" _debug2 "_ser" "$_ser" - _akiurl="$(echo "$_aki" | _h2b | _base64 | tr -d = | _url_encode)" + _akiurl="$(echo "$_aki" | _h2b | _base64 | _url_replace)" _debug2 "_akiurl" "$_akiurl" - _serurl="$(echo "$_ser" | _h2b | _base64 | tr -d = | _url_encode)" + _serurl="$(echo "$_ser" | _h2b | _base64 | _url_replace)" _debug2 "_serurl" "$_serurl" - _ARI_URL="$ACME_RENEWAL_INFO/$_akiurl.$_serurl" - _get "$_ARI_URL" + printf "%s.%s" "$_akiurl" "$_serurl" +} +#cert +_get_ARI() { + _cert="$1" + _ari_certID="$(_getARICertID "$_cert")" + _ARI_URL="$ACME_RENEWAL_INFO/$_ari_certID" + _get "$_ARI_URL" } # Detect profile file if not specified as environment variable @@ -7158,6 +7421,8 @@ Commands: --update-account Update account info. --register-account Register account key. --deactivate-account Deactivate the account. + --make-dns-persist-value Print the DNS TXT record(s) to enable persistent DNS validation + (draft-ietf-acme-dns-persist-01). Use with -d . --create-account-key Create an account private key, professional use. --install-cronjob Install the cron job to renew certs, you don't need to call this. The 'install' command can automatically install the cron job. --uninstall-cronjob Uninstall the cron job. The 'uninstall' command can do this automatically. @@ -7205,6 +7470,10 @@ Parameters: --dns [dns_hook] Use dns manual mode or dns api. Defaults to manual mode when argument is omitted. See: $_DNS_API_WIKI + --dns-persist Use dns-persist-01 validation (draft-ietf-acme-dns-persist-01). + Requires the persistent _validation-persist TXT record to already + exist. Use '--make-dns-persist-value' to print the value to add. + --dnssleep The time in seconds to wait for all the txt records to propagate in dns api mode. It's not necessary to use this by default, $PROJECT_NAME polls dns status by DOH automatically. -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256, ec-384, ec-521. @@ -7215,6 +7484,18 @@ Parameters: --eab-kid Key Identifier for External Account Binding. --eab-hmac-key HMAC key for External Account Binding. + --dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the + generated TXT record so the issuer is also authorized for wildcards + and subdomains (draft-ietf-acme-dns-persist-01). + --dns-persist-ca-name Used with '--make-dns-persist-value'. Use the given CA identity domain + (e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If + omitted, the identities are read from the ACME directory's + 'caaIdentities' field and one record is printed per identity. + --dns-persist-days Used with '--make-dns-persist-value'. Add a 'persistUntil' field to + the TXT record so the record self-expires N days from now (the CA + will refuse new validations against the record after that time). + If omitted, the record has no expiry. + These parameters are to install the cert to nginx/Apache or any other server after issue/renew a cert: @@ -7235,6 +7516,7 @@ Parameters: -m, --email Specifies the account email, only valid for the '--install' and '--update-account' command. --accountkey Specifies the account key path, only valid for the '--install' command. --days Specifies the days to renew the cert when using '--issue' command. The default value is $DEFAULT_RENEW days. + Negative values could be used to specify a number of days relative to the expiration date of the certificate. --httpport Specifies the standalone listening port. Only valid if the server is behind a reverse proxy or load balancer. --tlsport Specifies the standalone tls listening port. Only valid if the server is behind a reverse proxy or load balancer. --local-address Specifies the standalone/tls server listening address, in case you have multiple ip addresses. @@ -7585,6 +7867,9 @@ _process() { _valid_to="" _certificate_profile="" _extended_key_usage="" + _dns_persist_wildcard="" + _dns_persist_ca_name="" + _dns_persist_days="" while [ ${#} -gt 0 ]; do case "${1}" in @@ -7679,6 +7964,20 @@ _process() { --deactivate-account) _CMD="deactivateaccount" ;; + --make-dns-persist-value | --makednspersistvalue) + _CMD="makednspersistvalue" + ;; + --dns-persist-wildcard | --dnspersistwildcard) + _dns_persist_wildcard="1" + ;; + --dns-persist-ca-name | --dnspersistcaname) + _dns_persist_ca_name="$2" + shift + ;; + --dns-persist-days | --dnspersistdays) + _dns_persist_days="$2" + shift + ;; --set-notify) _CMD="setnotify" ;; @@ -7822,6 +8121,14 @@ _process() { _webroot="$_webroot,$wvalue" fi ;; + --dns-persist) + wvalue="$W_DNS_PERSIST" + if [ -z "$_webroot" ]; then + _webroot="$wvalue" + else + _webroot="$_webroot,$wvalue" + fi + ;; --dnssleep) _dnssleep="$2" Le_DNSSleep="$_dnssleep" @@ -8238,6 +8545,9 @@ _process() { deactivateaccount) deactivateaccount ;; + makednspersistvalue) + makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" "$_dns_persist_days" + ;; list) list "$_listraw" "$_domain" ;; diff --git a/deploy/windows_rdp.sh b/deploy/windows_rdp.sh new file mode 100644 index 00000000..e708e9a7 --- /dev/null +++ b/deploy/windows_rdp.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh + +# install a certificate on a Windows host over OpenSSH and bind it to the Remote +# Desktop listener (RDP-Tcp). +# +# One ssh invocation does the whole job: +# * the PFX is built locally, base64'd, and embedded as a string literal +# inside a generated PowerShell script; +# * the script is piped to `powershell.exe -Command -` over ssh. No scp, +# no temp files on the Windows host. +# +# First run: +# export DEPLOY_WIN_RDP_HOST=winserver.example.com +# acme.sh --deploy -d winserver.example.com --deploy-hook windows_rdp +# +# Available variables: +# DEPLOY_WIN_RDP_HOST required SSH host +# DEPLOY_WIN_RDP_USER optional SSH user, must be a local administrator (can also by set via ssh_config) +# DEPLOY_WIN_RDP_PORT optional SSH port, default 22 +# DEPLOY_WIN_RDP_SSH_OPTS optional extra ssh options, e.g. +# "-i /root/.ssh/win_id_ed25519 -o StrictHostKeyChecking=yes" +# DEPLOY_WIN_RDP_LISTENER optional RDP listener name, default RDP-Tcp +# DEPLOY_WIN_RDP_RESTART optional "1" to restart TermService after install. +# Active RDP sessions will drop! + +windows_rdp_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if ! _exists "ssh"; then + _err "ssh is required but was not found in PATH." + return 1 + fi + + # ---- configuration ------------------------------------------------------ + _getdeployconf DEPLOY_WIN_RDP_HOST + _getdeployconf DEPLOY_WIN_RDP_USER + _getdeployconf DEPLOY_WIN_RDP_PORT + _getdeployconf DEPLOY_WIN_RDP_SSH_OPTS + _getdeployconf DEPLOY_WIN_RDP_LISTENER + _getdeployconf DEPLOY_WIN_RDP_RESTART + + if [ -z "$DEPLOY_WIN_RDP_HOST" ]; then + _err "DEPLOY_WIN_RDP_HOST must be set." + return 1 + fi + + _savedeployconf DEPLOY_WIN_RDP_HOST "$DEPLOY_WIN_RDP_HOST" + [ -n "$DEPLOY_WIN_RDP_USER" ] && _savedeployconf DEPLOY_WIN_RDP_USER "$DEPLOY_WIN_RDP_USER" + [ -n "$DEPLOY_WIN_RDP_PORT" ] && _savedeployconf DEPLOY_WIN_RDP_PORT "$DEPLOY_WIN_RDP_PORT" + [ -n "$DEPLOY_WIN_RDP_SSH_OPTS" ] && _savedeployconf DEPLOY_WIN_RDP_SSH_OPTS "$DEPLOY_WIN_RDP_SSH_OPTS" + [ -n "$DEPLOY_WIN_RDP_LISTENER" ] && _savedeployconf DEPLOY_WIN_RDP_LISTENER "$DEPLOY_WIN_RDP_LISTENER" + [ -n "$DEPLOY_WIN_RDP_RESTART" ] && _savedeployconf DEPLOY_WIN_RDP_RESTART "$DEPLOY_WIN_RDP_RESTART" + + _port="${DEPLOY_WIN_RDP_PORT:-22}" + _listener="${DEPLOY_WIN_RDP_LISTENER:-RDP-Tcp}" + if [ -n "$DEPLOY_WIN_RDP_USER" ]; then + _target="$DEPLOY_WIN_RDP_USER@$DEPLOY_WIN_RDP_HOST" + else + _target="$DEPLOY_WIN_RDP_HOST" + fi + _pfx_pass="acme" + + # ---- build thumbprint + PFX locally ------------------------------------ + _thumb="$(_fingerprint "$_ccert" 'sha1')" + if [ -z "$_thumb" ]; then + _err "Failed to compute certificate thumbprint." + return 1 + fi + _debug "Thumbprint: $_thumb" + + _debug "Building PFX at $_pfx_file" + _pfx_file="$(_mktemp)" + if ! _toPkcs "$_pfx_file" "$_ckey" "$_ccert" "$_cca" "$_pfx_pass"; then + _err "Failed to build PFX archive." + rm -f "$_pfx_file" + return 1 + fi + _pfx_b64=$(_base64 "multiline" <"$_pfx_file") + rm -f "$_pfx_file" + + # ---- build installer script -------------------------------------------- + if [ "$DEPLOY_WIN_RDP_RESTART" = "1" ]; then + _restart_ps='Restart-Service -Name TermService -Force' + else + _restart_ps='# New RdP connections will pick up the new cert automatically.' + fi + + # Escape every literal `$` with `\$` so the shell does not expand it. + # Values substituted from shell: $_pfx_b64, $_pfx_pass, $_thumb, $_listener. + _ps1=$( + cat <"$HTTP_HEADER" + + if [ "$method" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "$method")" + fi + + _ret="$?" + unset _H1 _H2 _H3 + _debug2 response "$response" + + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug2 _code "$_code" + + if [ "$_ret" != "0" ]; then + _err "Error calling API: $endpoint" + return 1 + fi + + if [ "$_code" != "200" ] && [ "$_code" != "201" ] && [ "$_code" != "204" ]; then + if [ "$_code" != "404" ]; then + _err "API error (HTTP $_code): $response" + fi + return 1 + fi + + return 0 +} diff --git a/dnsapi/dns_firestorm.sh b/dnsapi/dns_firestorm.sh new file mode 100644 index 00000000..808c2b89 --- /dev/null +++ b/dnsapi/dns_firestorm.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_firestorm_info='Firestorm.ch +Site: firestorm.ch +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_firestorm +Options: + FST_Key Customer ID + FST_Secret API Secret + FST_Url API URL. Optional. Default "https://api.firestorm.ch/acme-dns". +Issues: github.com/acmesh-official/acme.sh/issues/6839 +Author: FireStorm GmbH +' + +FST_Url_DEFAULT="https://api.firestorm.ch/acme-dns" + +######## Public functions ##################### + +# Usage: dns_firestorm_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_firestorm_add() { + fulldomain=$1 + txtvalue=$2 + + FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}" + FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}" + FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}" + + if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then + _err "FST_Key and FST_Secret must be set" + _err "Get your API credentials at https://admin.firestorm.ch" + return 1 + fi + + FST_Url="${FST_Url:-$FST_Url_DEFAULT}" + + _saveaccountconf_mutable FST_Key "$FST_Key" + _saveaccountconf_mutable FST_Secret "$FST_Secret" + if [ "$FST_Url" != "$FST_Url_DEFAULT" ]; then + _saveaccountconf_mutable FST_Url "$FST_Url" + else + _clearaccountconf_mutable FST_Url + fi + + subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//') + + _info "Adding TXT record for $fulldomain" + _debug "Subdomain" "$subdomain" + _debug "TXT value" "$txtvalue" + + body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}" + + response="$(_firestorm_api "update" "$body")" + + if _contains "$response" "$txtvalue"; then + _info "TXT record added successfully" + return 0 + fi + + _err "Failed to add TXT record: $response" + return 1 +} + +# Usage: dns_firestorm_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_firestorm_rm() { + fulldomain=$1 + txtvalue=$2 + + FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}" + FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}" + FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}" + FST_Url="${FST_Url:-$FST_Url_DEFAULT}" + + if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then + _err "FST_Key and FST_Secret must be set" + return 1 + fi + + subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//') + + _info "Removing TXT record for $fulldomain" + + body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}" + + response="$(_firestorm_api "remove" "$body")" + + if _contains "$response" "removed"; then + _info "TXT record removed" + return 0 + fi + + _err "Failed to remove TXT record: $response" + return 1 +} + +#################### Private functions below ################################## + +# Escape special characters for safe JSON string interpolation +_json_safe() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +_firestorm_api() { + action=$1 + data=$2 + + export _H1="X-Api-User: $FST_Key" + export _H2="X-Api-Key: $FST_Secret" + export _H3="Content-Type: application/json" + + _post "$data" "$FST_Url/$action" "" "POST" +}