diff --git a/acme.sh b/acme.sh index 9561ff76..ccd34b18 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.6 +VER=3.1.7 PROJECT_NAME="acme.sh" @@ -2140,6 +2140,7 @@ _resethttp() { __HTTP_INITIALIZED="" _ACME_CURL="" _ACME_WGET="" + _ACME_WGET2="" ACME_HTTP_NO_REDIRECTS="" } @@ -2190,7 +2191,15 @@ _inithttp() { fi if [ -z "$_ACME_WGET" ] && _exists "wget"; then - _ACME_WGET="wget -q" + #wget2, the wget of Fedora 40 and later, prints nothing for -S under -q, + #so it runs without -q and _wget2_fix_header cleans up after it + _ACME_WGET2="" + if _contains "$(wget --version 2>&1 | _head_n 1)" "Wget2"; then + _ACME_WGET2=1 + _ACME_WGET="wget" + else + _ACME_WGET="wget -q" + fi if [ "$ACME_USE_IPV6_REQUESTS" ]; then _ACME_WGET="$_ACME_WGET --inet6-only " elif [ "$ACME_USE_IPV4_REQUESTS" ]; then @@ -2200,7 +2209,8 @@ _inithttp() { _ACME_WGET="$_ACME_WGET --max-redirect 0 " fi if [ "$DEBUG" ] && [ "$DEBUG" -ge "2" ]; then - if [ "$_ACME_WGET" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then + #the -d demultiplexing after each request expects wget 1.x output + if [ -z "$_ACME_WGET2" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then _ACME_WGET="$_ACME_WGET -d " fi fi @@ -2220,6 +2230,30 @@ _inithttp() { } +#stdin: what wget2 -S wrote to stderr. Prints the response headers the way +#curl --dump-header writes them. wget2 frames every header block with lines of +#its own ("[0] Downloading ...", "# got header ...", and after the block +#"HTTP response 200 OK [url]", which callers would take for the status line), +#and prints no header block at all for a response without a body (a 204, the +#empty 200 of a revocation): then only the status line can be rebuilt, from +#the last "HTTP [ERROR ]response" line, with HTTP/1.1 assumed. +_wget2_headers() { + _w2h_in="$(cat)" + _w2h_cr="$(printf '\r')" + _w2h_blocks="$(printf "%s\n" "$_w2h_in" | sed -n "/^HTTP /d; /^HTTP\//,/^$_w2h_cr*\$/p")" + if [ "$_w2h_blocks" ]; then + printf "%s\n" "$_w2h_blocks" + else + printf "%s\n" "$_w2h_in" | sed -n 's/^HTTP ERROR response /HTTP response /; s/^HTTP response \([0-9][0-9]*\).*$/HTTP\/1.1 \1/p' | _tail_n 1 + fi +} + +#rewrite $HTTP_HEADER after a wget2 request, see _wget2_headers +_wget2_fix_header() { + _w2f_headers="$(_wget2_headers <"$HTTP_HEADER")" + printf "%s\n" "$_w2f_headers" >"$HTTP_HEADER" +} + # body url [needbase64] [POST|PUT|DELETE] [ContentType] _post() { body="$1" @@ -2315,7 +2349,15 @@ _post() { response="$($_WGET -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" fi elif [ "$httpmethod" = "HEAD" ]; then - if [ "$_postContentType" ]; then + if [ "$_ACME_WGET2" ]; then + #wget2 prints no headers for a HEAD response, not even with -S, but + #--save-headers writes them into the -O file + if [ "$_postContentType" ]; then + response="$($_WGET --method HEAD --save-headers -O "$HTTP_HEADER" --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --header "Content-Type: $_postContentType" "$_post_url" 2>/dev/null)" + else + response="$($_WGET --method HEAD --save-headers -O "$HTTP_HEADER" --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" "$_post_url" 2>/dev/null)" + fi + elif [ "$_postContentType" ]; then response="$($_WGET --spider -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --header "Content-Type: $_postContentType" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" else response="$($_WGET --spider -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" @@ -2341,6 +2383,9 @@ _post() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" else @@ -2434,6 +2479,9 @@ _post_file() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" else @@ -2501,6 +2549,9 @@ _get() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" fi @@ -2687,10 +2738,6 @@ _send_signed_request() { _debug2 _headers "$_headers" _CACHED_NONCE="$(echo "$_headers" | grep -i "Replay-Nonce:" | _head_n 1 | tr -d "\r\n " | cut -d ':' -f 2)" fi - if [ "$?" != "0" ]; then - _err "Cannot connect to $nonceurl to get nonce." - return 1 - fi else _debug2 "Use _CACHED_NONCE" "$_CACHED_NONCE" fi @@ -2729,11 +2776,19 @@ _send_signed_request() { _debug3 body "$body" response="$(_post "$body" "$url" "$needbase64" "POST" "$__request_conent_type")" + _post_ret="$?" _CACHED_NONCE="" - if [ "$?" != "0" ]; then - _err "Cannot make POST request to $url" - return 1 + #A transport failure (curl 7, 28, 35, ...) brings no answer at all. Issue + #7320: the status was tested only after the line above had reset it, so + #such a failure went on as a success, the code read from whatever + #$HTTP_HEADER still held, and the caller blamed the CA ("Finalize code was + #not 200"). Retry it the way a gateway error is retried, with a new nonce. + if [ "$_post_ret" != "0" ]; then + _sleep_post_retry_sec="$(_retry_backoff_sec "$_request_retry_times")" + _info "Cannot make POST request to $url, let's try again. Sleeping for $_sleep_post_retry_sec seconds. ($_request_retry_times/$MAX_REQUEST_RETRY_TIMES)" + _sleep "$_sleep_post_retry_sec" + continue fi responseHeaders="$(cat "$HTTP_HEADER")" @@ -2800,6 +2855,35 @@ _sed_escape_rhs() { sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g' } +#_write_conf file content +#Replace the conf file with the content. +#Redirecting straight into the conf truncates it before anything is written, so +#a failed write (e.g. no space left on device) left an empty conf and the cert +#could not be renewed any more (#7247). Write a temp file next to the conf and +#rename it over the conf only after the content is verified. +_write_conf() { + __w_conf="$1" + __w_text="$2" + __w_tmp="$__w_conf.$$.tmp" + #cp -p, so the temp file carries the mode and owner of the conf + if ! cp -p "$__w_conf" "$__w_tmp" 2>/dev/null || + ! printf -- "%s\n" "$__w_text" 2>/dev/null >"$__w_tmp" || + [ "$(cat "$__w_tmp")" != "$__w_text" ]; then + rm -f "$__w_tmp" + return 1 + fi + if [ ! -L "$__w_conf" ] && mv -f "$__w_tmp" "$__w_conf" 2>/dev/null; then + return 0 + fi + #a symlink or a bind mounted file cannot be renamed over, write in place + if ! cat "$__w_tmp" 2>/dev/null >"$__w_conf"; then + #the conf may be truncated now, the temp file is the only complete copy + _err "Cannot write $__w_conf, the new content is kept in $__w_tmp" + return 1 + fi + rm -f "$__w_tmp" +} + #setopt "file" "opt" "=" "value" [";"] _setopt() { __conf="$1" @@ -2826,39 +2910,29 @@ _setopt() { return 1 ;; esac - if [ -n "$(_tail_c 1 <"$__conf")" ]; then - echo >>"$__conf" + if ! __text="$(cat "$__conf")"; then + _err "Cannot read $__conf." + return 1 fi - + #build the new content first and write it once through _write_conf: + #redirecting straight into the conf truncates it before anything is + #written, so a failing sed (#2426) or a failing write (#7247) left a + #truncated conf, and a short append left a half written line + __sed_err="" if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then _debug3 OK __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" - text="$(cat "$__conf")" - #capture first, write only on success: redirecting sed straight into the - #conf file truncates it before sed runs, so a failing sed (e.g. on an - #unescaped special character in the value) wiped the whole conf (#2426) - if __text="$(printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then - printf -- "%s\n" "$__text" >"$__conf" - else - _err "Cannot save '$__opt' to $__conf." - return 1 - fi - + __text="$(printf -- "%s\n" "$__text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")" || __sed_err=1 elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" - text="$(cat "$__conf")" - if __text="$(printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then - printf -- "%s\n" "$__text" >"$__conf" - else - _err "Cannot save '$__opt' to $__conf." - return 1 - fi - + __text="$(printf -- "%s\n" "$__text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")" || __sed_err=1 else _debug3 APP - #printf, not echo: dash's builtin echo interprets backslash escapes in - #the value and would corrupt it - printf -- "%s\n" "$__opt$__sep$__val$__end" >>"$__conf" + __text="$__text${__text:+$__nl}$__opt$__sep$__val$__end" + fi + if [ "$__sed_err" ] || ! _write_conf "$__conf" "$__text"; then + _err "Cannot save '$__opt' to $__conf." + return 1 fi _debug3 "$(grep -n "^$__opt$__sep" "$__conf")" } @@ -2888,7 +2962,11 @@ _clear_conf() { _conf_data="$(cat "$_c_c_f")" #printf, not echo: dash's builtin echo interprets backslash escapes and #would corrupt saved values that contain them on every rewrite - printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f" + if ! _conf_data="$(printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d")" || + ! _write_conf "$_c_c_f" "$_conf_data"; then + _err "Cannot clear '$_sdkey' in $_c_c_f." + return 1 + fi else _err "Config file is empty, cannot clear" fi @@ -3365,7 +3443,13 @@ __initHome() { if [ -z "$ACCOUNT_CONF_PATH" ]; then if [ -f "$_DEFAULT_ACCOUNT_CONF_PATH" ]; then + #Same as in _initpath: keep the live ACCOUNT_EMAIL across the sourcing, + #so that the -m address _process() exported is not replaced by the + #saved one. _process() calls __initHome directly, after the option + #loop, so this is the sourcing that -m used to lose to. + _ih_account_email="$ACCOUNT_EMAIL" . "$_DEFAULT_ACCOUNT_CONF_PATH" + ACCOUNT_EMAIL="$_ih_account_email" fi fi @@ -3475,12 +3559,22 @@ _initpath() { domain="$1" _ilength="$2" + #Keep the live ACCOUNT_EMAIL, the one -m exported in _process() or the + #caller put in the environment. account.conf is sourced twice below (here + #and inside __initHome), and a sourced assignment would overwrite it with + #the saved address, so -m silently lost to whatever account.conf held. + #The saved address is not lost either way: _getAccountEmail() reads it + #with _readaccountconf as its last resort, after the per-CA CA_EMAIL. + _cli_account_email="$ACCOUNT_EMAIL" + __initHome if [ -f "$ACCOUNT_CONF_PATH" ]; then . "$ACCOUNT_CONF_PATH" fi + ACCOUNT_EMAIL="$_cli_account_email" + if [ "$_ACME_IN_CRON" ]; then if [ ! "$_USER_PATH_EXPORTED" ]; then _USER_PATH_EXPORTED=1 @@ -4420,9 +4514,11 @@ _regAccount() { _secure_debug3 _eab_kid "$_eab_kid" _secure_debug3 _eab_hmac_key "$_eab_hmac_key" _email="$(_getAccountEmail)" - if [ "$_email" ]; then - _savecaconf "CA_EMAIL" "$_email" - fi + #CA_EMAIL is saved only once the CA has actually taken the contact, which + #is when it answers 201. For an account key it already knows it answers + #200 and ignores the contact of the request, so saving here would record + #an address the CA never stored. + _saved_ca_email="$(_readcaconf CA_EMAIL)" if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then @@ -4501,8 +4597,15 @@ _regAccount() { if [ "$code" = "" ] || [ "$code" = '201' ]; then echo "$response" >"$ACCOUNT_JSON_PATH" _info "Registered" + if [ "$_email" ]; then + _savecaconf "CA_EMAIL" "$_email" + fi elif [ "$code" = '409' ] || [ "$code" = '200' ]; then _info "Already registered" + if [ "$_email" ] && [ "$_email" != "$_saved_ca_email" ]; then + _info "The account email was not changed, the CA ignores the contact of an account it already has." + _info "Use '$PROJECT_ENTRY --update-account -m $_email' to change it." + fi elif [ "$code" = '400' ] && _contains "$response" 'The account is not awaiting external account binding'; then _info "EAB already registered" _eabAlreadyBound=1 @@ -5590,11 +5693,9 @@ issue() { _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" "urn:ietf:params:acme:error:malformed" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then + # Retry without "replaces" whenever the CA rejected that field, e.g. after + # switching the ACME server: the prior cert belongs to the old CA. + if [ "$_replaces_certID" ] && _isARIReplacesRejected "$code" "$response"; 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." @@ -7930,6 +8031,35 @@ _getARICertID() { printf "%s.%s" "$_akiurl" "$_serurl" } +#httpcode response +#Returns 0 when a newOrder was rejected because of the ARI "replaces" field, +#so that the order can be retried without it. +#The status code decides first, and an empty code counts as "not rejected": +#an ACCEPTED order echoes the field back, since RFC 9773 Section 5 says that +#a server accepting a newOrder request with a "replaces" field "MUST reflect +#that field in the response", and the certID it carries is base64url, so the +#response of a SUCCESSFUL order can contain "replaces" and even "ARI". +#Matching on the message alone would then re-order without "replaces" and +#defeat ARI. +#Only the 409 "alreadyReplaced" type is mandated by RFC 9773 Section 5; the +#other checks it lists (same ACME account, shared identifier) are left to +#server policy, so the wording differs per CA: Let's Encrypt answers +#malformed when the prior cert was issued by a different issuer, ZeroSSL +#answers 401 with 'The "replaces" field does not identify a certificate that +#belongs to this ACME account'. +#https://github.com/acmesh-official/acme.sh/issues/7280 +_isARIReplacesRejected() { + _ari_rej_code="$1" + _ari_rej_resp="$2" + if [ -z "$_ari_rej_code" ] || _startswith "$_ari_rej_code" "2"; then + return 1 + fi + _contains "$_ari_rej_resp" "alreadyReplaced" || + _contains "$_ari_rej_resp" "replaces" || + _contains "$_ari_rej_resp" "ARI" || + _contains "$_ari_rej_resp" "urn:ietf:params:acme:error:malformed" +} + #cert _get_ARI() { _cert="$1" diff --git a/deploy/fritzbox.sh b/deploy/fritzbox.sh index 416a4121..3c07d3af 100644 --- a/deploy/fritzbox.sh +++ b/deploy/fritzbox.sh @@ -74,9 +74,9 @@ fritzbox_deploy() { _info "Log in to the FRITZ!Box" _fritzbox_challenge="$(_get "${DEPLOY_FRITZBOX_URL}/login_sid.lua" | sed -e 's/^.*//' -e 's/<\/Challenge>.*$//')" if _exists iconv; then - _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF16LE | _digest md5 hex)" + _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF-16LE | _digest md5 hex)" elif _exists uconv; then - _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF16LE | _digest md5 hex)" + _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF-16LE | _digest md5 hex)" else _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | perl -p -e 'use Encode qw/encode/; print encode("UTF-16LE","$_"); $_="";' | _digest md5 hex)" fi diff --git a/deploy/nginx_proxy_manager.sh b/deploy/nginx_proxy_manager.sh new file mode 100644 index 00000000..50523a6d --- /dev/null +++ b/deploy/nginx_proxy_manager.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env sh + +# Deploy hook to deploy certificate and key to nginx proxy manager/NPMPlus using +# REST API. Separately uploading the intermediate for nginx proxy manager is +# not necessary as the "fullchain" is always used for the certificate. +# +# Environment variables: +# +# DEPLOY_NPM_HOST - example.com, 192.168.1.1, etc. (REQUIRED) +# Host/IP address of NPM admin panel +# DEPLOY_NPM_PORT - 80, 443, 8443, etc. (REQUIRED) +# Port number of NPM admin panel +# DEPLOY_NPM_PROTOCOL - http/https (REQUIRED) +# Protocol to connect to NPM admin panel. https is +# STRONGLY recommended +# DEPLOY_NPM_CERTNUM - 1, 2, 3, etc. (OPTIONAL) +# the ID number of the custom certificate to overwrite. +# Find it in the NPM admin panel under certificates. +# Must already exist. +# DEPLOY_NPM_USER - admin, npmuser, etc. (REQUIRED) +# Authorized user for NPM. Must have permission +# to manage certificates. +# DEPLOY_NPM_PASSWORD - mysecurepassword, mysecretpass, etc. (REQUIRED) +# Authorized user's password for NPM 2FA is not supported +# since neither nginx proxy manager nor NPMplus support +# permanent API tokens. Either disable 2FA for the +# administrator account, or (better option) create a new user +# with 2FA disabled, and grant this user permissions for +# managing certificates. +# DEPLOY_NPM_CERTNAME My Custom Certificate, Cool Certificate Name, etc. (OPTIONAL) +# Name of custom certificate to create. This is only used +# to create a certificate the first time. It is IGNORED +# once DEPLOY_NPM_CERTNUM is set. If neither certificate +# ID number and name are provided, a default name of +# "acme.sh custom certificate" with a timestamp suffix will be +# used +# +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +nginx_proxy_manager_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + _cpfx="$6" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + _debug _cpfx "$_cpfx" + + _getdeployconf DEPLOY_NPM_HOST + _getdeployconf DEPLOY_NPM_PORT + _getdeployconf DEPLOY_NPM_PROTOCOL + _getdeployconf DEPLOY_NPM_CERTNUM + _getdeployconf DEPLOY_NPM_USER + _getdeployconf DEPLOY_NPM_PASSWORD + _getdeployconf DEPLOY_NPM_CERTNAME + + if [ -z "$DEPLOY_NPM_HOST" ]; then + _err "DEPLOY_NPM_HOST must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_PORT" ]; then + _err "DEPLOY_NPM_PORT must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_CERTNUM" ]; then + _info "DEPLOY_NPM_CERTNUM not provided, a new certificate will be created." + if [ -z "$DEPLOY_NPM_CERTNAME" ]; then + DEPLOY_NPM_CERTNAME="acme.sh custom certificate - $(_utc_date | tr ' ' '_' | tr -d -- ':')" + _info "DEPLOY_NPM_CERTNAME not set. Using default:" + _info DEPLOY_NPM_CERTNAME "$DEPLOY_NPM_CERTNAME" + fi + fi + if [ "$DEPLOY_NPM_PROTOCOL" != "https" ] && [ "$DEPLOY_NPM_PROTOCOL" != "http" ]; then + _err "DEPLOY_NPM_PROTOCOL must be either http or https" + return 1 + fi + if [ -z "$DEPLOY_NPM_USER" ]; then + _err "DEPLOY_NPM_USER must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_PASSWORD" ]; then + _err "DEPLOY_NPM_PASSWORD must be defined" + return 1 + fi + + # extract and return response code + _npm_response_code() { + _npm_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")" + printf '%s\n' "$_npm_code" | _egrep_o "^[0-9][0-9]*" + } + + # strip backslashes, encode for JSON, and remove newlines + _npm_payload_format() { + _npm_payload_out="$(printf "%s\n" "$1" | _json_encode)" + printf '%s' "${_npm_payload_out%\\n}" + } + + _debug DEPLOY_NPM_USER "$DEPLOY_NPM_USER" + _secure_debug DEPLOY_NPM_PASSWORD "$DEPLOY_NPM_PASSWORD" + _npm_user_json="$(_npm_payload_format "$DEPLOY_NPM_USER")" + _npm_password_json="$(_npm_payload_format "$DEPLOY_NPM_PASSWORD")" + _debug _npm_user_json "$_npm_user_json" + _secure_debug _npm_password_json "$_npm_password_json" + + _info "Authenticating and fetching temporary API token" + _npm_token_raw="$(_post '{"identity":"'"$_npm_user_json"'","secret":"'"$_npm_password_json"'"}' "${DEPLOY_NPM_PROTOCOL}://${DEPLOY_NPM_HOST}:${DEPLOY_NPM_PORT}/api/tokens" "" "POST" "application/json")" + _secure_debug _npm_token_raw "$_npm_token_raw" + _npm_requires2fa="$(printf '%s' "$_npm_token_raw" | _egrep_o '"requires_2fa" *: *[^,}]*' | cut -d : -f 2 | tr -d ' ')" + _npm_requiresTotp="$(printf '%s' "$_npm_token_raw" | _egrep_o '"requiresTotp" *: *[^,}]*' | cut -d : -f 2 | tr -d ' ')" + if [ "$_npm_requires2fa" = "true" ] || [ "$_npm_requiresTotp" = "true" ]; then + _err "2FA/MFA is enabled, deployment is not supported. Disable it or create a user with 2FA/MFA disabled and update your configuration." + return 1 + fi + + _npm_token_code="$(_npm_response_code)" + _debug _npm_token_code "$_npm_token_code" + if [ "$_npm_token_code" != "200" ]; then + _err "Failed to retrieve a token, server response code: $_npm_token_code" + return 1 + fi + + # first look for a token in JSON + _npm_type="Nginx Proxy Manager API" + _npm_token="$(printf '%s' "$_npm_token_raw" | _egrep_o '"token" *: *"[^"]*"' | cut -d '"' -f 4)" + if [ "$_npm_token" = "" ]; then + _debug "Token not found in JSON (nginx proxy manager API), checking HTTP header" + # look for a token in the header + _npm_type="NPMplus API" + _npm_token="$(grep <"$HTTP_HEADER" -i "^Set-Cookie: *__Host-Http-token=" | _tail_n 1 | _egrep_o "__Host-Http-token=[^;]*" | _head_n 1 | cut -d'=' -f2-)" + if [ "$_npm_token" = "" ]; then + _err "Token not found in HTTP header (NPMplus API). Unable to obtain a token." + return 1 + fi + fi + _info "Detected API type: $_npm_type" + _secure_debug _npm_token "$_npm_token" + _info "API token retrieved." + + if [ "$_npm_type" = "Nginx Proxy Manager API" ]; then + _H1="Authorization: Bearer $_npm_token" + elif [ "$_npm_type" = "NPMplus API" ]; then + _H1="Cookie: __Host-Http-token=$_npm_token" + fi + export _H1 + _secure_debug _H1 "$_H1" + + _npm_nl="\0015\0012" + + # Create new certificate if a certificate ID number was not provided + if [ -z "$DEPLOY_NPM_CERTNUM" ]; then + _npm_certname_json="$(_npm_payload_format "$DEPLOY_NPM_CERTNAME")" + _npm_create_result="$(_post '{"provider":"other","nice_name":"'"$_npm_certname_json"'"}' "$DEPLOY_NPM_PROTOCOL://$DEPLOY_NPM_HOST:$DEPLOY_NPM_PORT/api/nginx/certificates" "" "POST" "application/json")" + _debug _npm_create_result "$_npm_create_result" + _npm_create_code="$(_npm_response_code)" + if [ "$_npm_create_code" != "201" ]; then + _err "Failed to create new certificate, server response code: $_npm_create_code" + unset _H1 + return 1 + fi + DEPLOY_NPM_CERTNUM="$(printf '%s' "$_npm_create_result" | _egrep_o '"id" *: *[^,}]*' | cut -d : -f 2 | tr -d ' "')" + _info "Created certificate ID number $DEPLOY_NPM_CERTNUM" + fi + + _info "Uploading certificates into entry $DEPLOY_NPM_CERTNUM" + _npm_form_boundary="--------------------------$(_utc_date | tr -d -- '-: ')" + _npm_post_payload="--$_npm_form_boundary${_npm_nl}Content-Disposition: form-data; name=\"certificate_key\"; filename=\"$(basename "$_ckey")\"${_npm_nl}Content-Type: application/octet-stream${_npm_nl}${_npm_nl}$(cat "$_ckey")\0012" + _npm_post_payload="$_npm_post_payload${_npm_nl}--$_npm_form_boundary${_npm_nl}Content-Disposition: form-data; name=\"certificate\"; filename=\"$(basename "$_cfullchain")\"${_npm_nl}Content-Type: application/octet-stream${_npm_nl}${_npm_nl}$(cat "$_cfullchain")\0012" + _npm_post_payload="$_npm_post_payload${_npm_nl}--$_npm_form_boundary--${_npm_nl}" + _npm_post_payload="$(printf "%b_" "$_npm_post_payload")" + _npm_post_payload="${_npm_post_payload%_}" + _secure_debug _npm_post_payload "$_npm_post_payload" + _npm_upload_result="$(_post "$_npm_post_payload" "${DEPLOY_NPM_PROTOCOL}://${DEPLOY_NPM_HOST}:${DEPLOY_NPM_PORT}/api/nginx/certificates/${DEPLOY_NPM_CERTNUM}/upload" "" "POST" "multipart/form-data; boundary=${_npm_form_boundary}")" + unset _H1 + _secure_debug _npm_upload_result "$_npm_upload_result" + _npm_upload_code="$(_npm_response_code)" + _debug _npm_upload_code "$_npm_upload_code" + case "$_npm_upload_code" in + "404") + _err "Failed to upload certificates, server response code: $_npm_upload_code" + _err "Check that certificate number $DEPLOY_NPM_CERTNUM exists." + return 1 + ;; + "200") + _info "Certificate uploaded successfully" + ;; + *) + _err "Failed to upload certificates, server response code: $_npm_upload_code" + return 1 + ;; + esac + + _info "$(__green "Deployed successfully")" + _savedeployconf DEPLOY_NPM_HOST "$DEPLOY_NPM_HOST" + _savedeployconf DEPLOY_NPM_PORT "$DEPLOY_NPM_PORT" + _savedeployconf DEPLOY_NPM_PROTOCOL "$DEPLOY_NPM_PROTOCOL" + _savedeployconf DEPLOY_NPM_CERTNUM "$DEPLOY_NPM_CERTNUM" + _savedeployconf DEPLOY_NPM_USER "$DEPLOY_NPM_USER" + _savedeployconf DEPLOY_NPM_PASSWORD "$DEPLOY_NPM_PASSWORD" "base64" + return 0 +} diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 336980a5..601f25a2 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -34,6 +34,8 @@ # interactive input the device name # - export SYNO_DEVICE_ID="" - (deprecated, auth with OTP code instead) # required for omitting 2FA-OTP +# - export SYNO_USE_TOKEN=0 - disable SynoToken authentication +# when deploying to Synology SRM # 3. Run command: # acme.sh --deploy --deploy-hook synology_dsm -d example.com ################################################################################ @@ -150,6 +152,20 @@ synology_dsm_deploy() { encoded_username="$(printf "%s" "$SYNO_USERNAME" | _url_encode)" encoded_password="$(printf "%s" "$SYNO_PASSWORD" | _url_encode)" + # Configure SynoToken authentication (DSM only) + _getdeployconf SYNO_USE_TOKEN + _check2cleardeployconfexp SYNO_USE_TOKEN + _savedeployconf SYNO_USE_TOKEN "$SYNO_USE_TOKEN" + _syno_use_token=1 + [ "$SYNO_USE_TOKEN" = "0" ] && _syno_use_token="" + + if [ -n "$_syno_use_token" ]; then + enable_token_param="&enable_syno_token=yes" + else + enable_token_param="" + fi + _debug enable_token_param "$enable_token_param" + # ## START ## - DEPRECATED, for backward compatibility _getdeployconf SYNO_TOTP_SECRET @@ -173,7 +189,7 @@ synology_dsm_deploy() { _debug3 H1 "${_H1}" fi - response=$(_post "method=login&account=$encoded_username&passwd=$encoded_password&api=SYNO.API.Auth&version=$api_version&enable_syno_token=yes&otp_code=$DEPRECATED_otp_code&device_name=certrenewal&device_id=$SYNO_DEVICE_ID" "$_base_url/webapi/$api_path?enable_syno_token=yes") + response=$(_post "method=login&account=$encoded_username&passwd=$encoded_password&api=SYNO.API.Auth&version=$api_version$enable_token_param&otp_code=$DEPRECATED_otp_code&device_name=certrenewal&device_id=$SYNO_DEVICE_ID" "$_base_url/webapi/$api_path${enable_token_param:+?enable_syno_token=yes}") _debug3 response "$response" # ## END ## - DEPRECATED, for backward compatibility # If SYNO_DEVICE_ID or SYNO_OTP_CODE is set, we treat current account enabled 2FA-OTP. @@ -225,7 +241,7 @@ synology_dsm_deploy() { otp_enforce_option="" fi fi - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param") if [ -n "$SYNO_USE_TEMP_ADMIN" ] && [ -n "$otp_enforce_option" ]; then synosetkeyvalue /etc/synoinfo.conf otp_enforce_option "$otp_enforce_option" _info "Restored previous enforce 2FA-OTP option." @@ -247,7 +263,7 @@ synology_dsm_deploy() { if [ -n "$SYNO_DEVICE_ID" ]; then # Omit OTP code with SYNO_DEVICE_ID. - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes&device_name=$SYNO_DEVICE_NAME&device_id=$SYNO_DEVICE_ID") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param&device_name=$SYNO_DEVICE_NAME&device_id=$SYNO_DEVICE_ID") _secure_debug3 response "$response" else # Require the OTP code if still unset. @@ -260,7 +276,7 @@ synology_dsm_deploy() { if [ -z "$SYNO_OTP_CODE" ]; then response='{"error":{"code":404}}' else - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes&enable_device_token=yes&device_name=$SYNO_DEVICE_NAME&otp_code=$SYNO_OTP_CODE") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param&enable_device_token=yes&device_name=$SYNO_DEVICE_NAME&otp_code=$SYNO_OTP_CODE") _secure_debug3 response "$response" id_property='device_id' @@ -302,14 +318,18 @@ synology_dsm_deploy() { token=$(echo "$response" | grep "synotoken" | sed -n 's/.*"synotoken" *: *"\([^"]*\).*/\1/p') _debug "Session ID" "$sid" _debug SynoToken "$token" - if [ -z "$sid" ] || [ -z "$token" ]; then + if [ -z "$sid" ] || { [ -n "$_syno_use_token" ] && [ -z "$token" ]; }; then # Still can't get necessary info even got no errors, may Synology have API updated? _err "Unable to authenticate to $_base_url, you may report this by providing full log with '--debug 3'." _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi - _H1="X-SYNO-TOKEN: $token" + if [ -n "$_syno_use_token" ]; then + _H1="X-SYNO-TOKEN: $token" + else + _H1="" + fi export _H1 _debug2 H1 "${_H1}" @@ -381,8 +401,15 @@ synology_dsm_deploy() { content="$(printf "%b_" "$content")" content="${content%_}" # protect trailing \n + if [ -n "$_syno_use_token" ]; then + token_param="&SynoToken=$token" + else + token_param="" + fi + _debug token_param "$token_param" + _info "Upload certificate to the Synology DSM." - response=$(_post "$content" "$_base_url/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1&SynoToken=$token&_sid=$sid" "" "POST" "multipart/form-data; boundary=${delim}") + response=$(_post "$content" "$_base_url/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1$token_param&_sid=$sid" "" "POST" "multipart/form-data; boundary=${delim}") _debug3 response "$response" if ! echo "$response" | grep '"error":' >/dev/null; then diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh index dfad8eeb..19641006 100644 --- a/dnsapi/dns_baidu.sh +++ b/dnsapi/dns_baidu.sh @@ -120,7 +120,7 @@ _baidu_run_with_fallback() { } _baidu_engine_order() { - _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")" + _pref="$(_baidu_trim_ws "${Baidu_API_Preference:-auto}" | _lower_case)" case "$_pref" in legacy) printf "%s" "legacy new" diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh new file mode 100644 index 00000000..8660b0d5 --- /dev/null +++ b/dnsapi/dns_bergdns.sh @@ -0,0 +1,348 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_bergdns_info='bergdns.at +Site: bergdns.at +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_bergdns +Options: + BERGDNS_API_KEY API key, as issued in the account UI. Needs read (to find the zone and the record) and write over the challenge names. + BERGDNS_API_URL API base URL. Optional. Default "https://bergdns.at/v1". + BERGDNS_TTL TTL of the challenge record, in seconds. Optional. Default "60". + BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "60". "0" does not wait. +Issues: github.com/acmesh-official/acme.sh/issues/7261 +Author: Kenny Kropp +' + +_BERGDNS_DEFAULT_URL='https://bergdns.at/v1' +_BERGDNS_DEFAULT_TTL='60' +_BERGDNS_DEFAULT_WAIT='60' + +######## Public functions #################################################### + +# Usage: dns_bergdns_add _acme-challenge.www.example.com "token" +# The value is added to the RRset, so a domain and its wildcard can be +# validated at the same time. +dns_bergdns_add() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + _bergdns_init || return 1 + _bergdns_find_zone "$fulldomain" || return 1 + _bergdns_find_rrset "$fulldomain" || return 1 + + _info "Adding TXT $fulldomain in zone $_bergdns_zone_name" + if [ -z "$_bergdns_rrset_id" ]; then + if _bergdns_rest POST "zones/$_bergdns_zone_id/rrsets" \ + "{\"name\":\"$fulldomain\",\"type\":\"TXT\",\"ttl\":$BERGDNS_TTL,\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + _bergdns_rrset_id=$(echo "$response" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _debug _bergdns_rrset_id "$_bergdns_rrset_id" + _bergdns_wait "$fulldomain" + return 0 + fi + if [ "$_bergdns_code" != "rrset_exists" ]; then + _err "bergdns: could not add the challenge record: $_bergdns_error" + return 1 + fi + # another run created it since the lookup above + _bergdns_find_rrset "$fulldomain" || return 1 + if [ -z "$_bergdns_rrset_id" ]; then + _err "bergdns: could not find the challenge record at $fulldomain" + return 1 + fi + fi + + if ! _bergdns_rest POST \ + "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \ + "{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + _err "bergdns: could not add the challenge record: $_bergdns_error" + return 1 + fi + + _bergdns_wait "$fulldomain" +} + +# Usage: dns_bergdns_rm _acme-challenge.www.example.com "token" +# Only this value is removed. Removing the last value deletes the RRset, and +# removing a value that does not exist is not an error. +dns_bergdns_rm() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + _bergdns_init || return 1 + _bergdns_find_zone "$fulldomain" || return 1 + _bergdns_find_rrset "$fulldomain" || return 1 + + if [ -z "$_bergdns_rrset_id" ]; then + _info "bergdns: no TXT records at $fulldomain, nothing to remove" + return 0 + fi + + _info "Removing TXT $fulldomain from zone $_bergdns_zone_name" + if ! _bergdns_rest DELETE \ + "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \ + "{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + # Any flavour of not-found is a cleanup that has already happened: the + # RRset was removed by a previous run, or by the other half of a + # domain-and-wildcard pair taking the last value with it. + if [ "$_bergdns_status" = "404" ]; then + _info "bergdns: $fulldomain holds no such record any more, nothing to remove" + return 0 + fi + _err "bergdns: could not remove the challenge record: $_bergdns_error" + return 1 + fi + return 0 +} + +######## Private functions ################################################### + +_bergdns_init() { + BERGDNS_API_KEY="${BERGDNS_API_KEY:-$(_readaccountconf_mutable BERGDNS_API_KEY)}" + BERGDNS_API_URL="${BERGDNS_API_URL:-$(_readaccountconf_mutable BERGDNS_API_URL)}" + BERGDNS_TTL="${BERGDNS_TTL:-$(_readaccountconf_mutable BERGDNS_TTL)}" + BERGDNS_PROPAGATION_TIMEOUT="${BERGDNS_PROPAGATION_TIMEOUT:-$(_readaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT)}" + + if [ -z "$BERGDNS_API_KEY" ]; then + BERGDNS_API_KEY="" + _clearaccountconf_mutable BERGDNS_API_KEY + _err "You have not set BERGDNS_API_KEY. Create a key in the bergdns UI and export it:" + _err " export BERGDNS_API_KEY=\"bgd_...\"" + return 1 + fi + + [ -n "$BERGDNS_API_URL" ] || BERGDNS_API_URL="$_BERGDNS_DEFAULT_URL" + [ -n "$BERGDNS_TTL" ] || BERGDNS_TTL="$_BERGDNS_DEFAULT_TTL" + [ -n "$BERGDNS_PROPAGATION_TIMEOUT" ] || BERGDNS_PROPAGATION_TIMEOUT="$_BERGDNS_DEFAULT_WAIT" + + # strip trailing slashes + BERGDNS_API_URL=$(echo "$BERGDNS_API_URL" | sed 's#/*$##') + + # The TTL is interpolated into the request body and the timeout is counted + # down in arithmetic, so a stray value from the environment or from an old + # account.conf has to be caught here rather than become malformed JSON and + # an opaque 400. + case "$BERGDNS_TTL" in + *[!0-9]* | '') + _err "bergdns: BERGDNS_TTL must be a number of seconds, not \"$BERGDNS_TTL\"." + return 1 + ;; + esac + case "$BERGDNS_PROPAGATION_TIMEOUT" in + *[!0-9]* | '') + _err "bergdns: BERGDNS_PROPAGATION_TIMEOUT must be a number of seconds, not \"$BERGDNS_PROPAGATION_TIMEOUT\"." + return 1 + ;; + esac + + _saveaccountconf_mutable BERGDNS_API_KEY "$BERGDNS_API_KEY" + # Only what the user actually chose is written back, and a value equal to + # the default clears any older setting. Persisting a default would pin the + # install to today's value, and a later change to the shipped one -- a move + # of the API base above all -- would never reach it; leaving an old setting + # in place would mean the environment could never put one back to default. + if [ "$BERGDNS_API_URL" = "$_BERGDNS_DEFAULT_URL" ]; then + _clearaccountconf_mutable BERGDNS_API_URL + else + _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" + fi + if [ "$BERGDNS_TTL" = "$_BERGDNS_DEFAULT_TTL" ]; then + _clearaccountconf_mutable BERGDNS_TTL + else + _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" + fi + if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "$_BERGDNS_DEFAULT_WAIT" ]; then + _clearaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT + else + _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" + fi + return 0 +} + +# Usage: _bergdns_find_zone _acme-challenge.www.example.com +# Sets _bergdns_zone_id and _bergdns_zone_name. +# Zones are addressed by an id, not by name, so the zone list is fetched once +# and the longest matching zone name wins. +_bergdns_find_zone() { + _bergdns_fqdn=$1 + _bergdns_zone_id="" + _bergdns_zone_name="" + + if ! _bergdns_rest GET "zones"; then + _err "bergdns: could not list zones: $_bergdns_error" + return 1 + fi + + # one zone object per line, so id and name stay together + _bergdns_zone_lines=$(echo "$response" | tr '{' '\n') + + _bergdns_cand="$_bergdns_fqdn" + while [ -n "$_bergdns_cand" ]; do + _bergdns_line=$(echo "$_bergdns_zone_lines" | _bergdns_select "$_bergdns_cand" | _head_n 1) + if [ -n "$_bergdns_line" ]; then + _bergdns_zone_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_zone_name="$_bergdns_cand" + _debug _bergdns_zone_id "$_bergdns_zone_id" + _debug _bergdns_zone_name "$_bergdns_zone_name" + [ -n "$_bergdns_zone_id" ] && return 0 + break + fi + case "$_bergdns_cand" in + *.*) _bergdns_cand=${_bergdns_cand#*.} ;; + *) _bergdns_cand="" ;; + esac + done + + _err "bergdns: no zone in this account holds $_bergdns_fqdn." + _err "bergdns: the key must be able to read the zone as well as write the record." + return 1 +} + +# Usage: _bergdns_find_rrset _acme-challenge.www.example.com +# Sets _bergdns_rrset_id to the id of the TXT RRset at that name, or to an +# empty string if there is none. Records are addressed by id, so the zone's +# RRsets are listed to find it. +_bergdns_find_rrset() { + _bergdns_fqdn=$1 + _bergdns_rrset_id="" + + if ! _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets"; then + _err "bergdns: could not list the records of $_bergdns_zone_name: $_bergdns_error" + return 1 + fi + + _bergdns_line=$(echo "$response" | tr '{' '\n' | _bergdns_select "$_bergdns_fqdn" TXT | _head_n 1) + if [ -n "$_bergdns_line" ]; then + _bergdns_rrset_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + fi + _debug _bergdns_rrset_id "$_bergdns_rrset_id" + return 0 +} + +# Usage: ... | _bergdns_select name [type] +# Reads one JSON object per line and prints those whose "name" is name and, +# when a type is given, whose "type" is that type. The two fields are matched +# one at a time, so neither the order the server writes its keys in nor +# anything sitting between them changes the answer. +# +# The comparison is a shell case, which is literal by construction: grep -F +# does not exist on Solaris, and _contains and _startswith would read the name +# as a regular expression. Each pattern anchors on the start of the object or +# on the comma before the key, so a key that merely ends in "name" cannot +# match. +_bergdns_select() { + _bergdns_sel_name=$1 + _bergdns_sel_type=$2 + while IFS= read -r _bergdns_sel_line || [ -n "$_bergdns_sel_line" ]; do + case "$_bergdns_sel_line" in + '"name":"'"$_bergdns_sel_name"'"'* | *',"name":"'"$_bergdns_sel_name"'"'*) ;; + *) continue ;; + esac + if [ -n "$_bergdns_sel_type" ]; then + case "$_bergdns_sel_line" in + '"type":"'"$_bergdns_sel_type"'"'* | *',"type":"'"$_bergdns_sel_type"'"'*) ;; + *) continue ;; + esac + fi + printf '%s\n' "$_bergdns_sel_line" + done +} + +# Usage: _bergdns_wait _acme-challenge.www.example.com +# Polls the propagation endpoint until all bergdns nameservers serve the +# record. A timeout is logged but does not fail the issuance. +# +# This covers the zone transfer from the primary to the secondaries, which +# takes seconds; the resolver side is acme.sh's own _check_dns_entries, which +# runs after every record has been added and has a timeout of its own. +_bergdns_wait() { + _bergdns_fqdn=$1 + if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "0" ] || [ -z "$_bergdns_rrset_id" ]; then + return 0 + fi + + _bergdns_waited=0 + while [ "$_bergdns_waited" -lt "$BERGDNS_PROPAGATION_TIMEOUT" ]; do + if _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/propagation"; then + case "$response" in + *'"propagated":true'*) + _info "bergdns: $_bergdns_fqdn is served by every secondary after ${_bergdns_waited}s" + return 0 + ;; + esac + elif [ "$_bergdns_code" = "propagation_unavailable" ]; then + # propagation checks are not configured on this server + _info "bergdns: this deployment does not offer propagation checks; not waiting" + return 0 + else + case "$_bergdns_status" in + 429) ;; # rate limited, worth another go + 4*) + # The check is refused rather than pending, and waiting will not + # change that. _check_dns_entries still has to pass, so this is not + # the place to fail the issuance. + _info "bergdns: the propagation check is unavailable ($_bergdns_error); not waiting" + return 0 + ;; + esac + fi + _sleep 5 + _bergdns_waited=$((_bergdns_waited + 5)) + done + + _info "bergdns: $_bergdns_fqdn was not on every secondary after ${BERGDNS_PROPAGATION_TIMEOUT}s; continuing anyway" + return 0 +} + +# Usage: _bergdns_rest method endpoint [body] +# Sets response and _bergdns_status. On failure also sets _bergdns_error and, +# where the API itself answered, _bergdns_code. +_bergdns_rest() { + _bergdns_method=$1 + _bergdns_endpoint=$2 + _bergdns_body=$3 + _bergdns_error="" + _bergdns_code="" + _bergdns_status="" + + export _H1="Authorization: Bearer $BERGDNS_API_KEY" + export _H2="Accept: application/json" + + _bergdns_url="$BERGDNS_API_URL/$_bergdns_endpoint" + _debug _bergdns_url "$_bergdns_url" + + # drop the headers of the previous request, so that a request which never + # reaches the server cannot be read as carrying its status + if [ -f "$HTTP_HEADER" ]; then + : >"$HTTP_HEADER" + fi + + if [ "$_bergdns_method" = "GET" ]; then + response="$(_get "$_bergdns_url")" + else + _debug2 _bergdns_body "$_bergdns_body" + response="$(_post "$_bergdns_body" "$_bergdns_url" "" "$_bergdns_method" "application/json")" + fi + _bergdns_ret="$?" + _debug2 response "$response" + + if [ "$_bergdns_ret" != "0" ]; then + _bergdns_error="the request to $_bergdns_url could not be made" + return 1 + fi + + _bergdns_status="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug _bergdns_status "$_bergdns_status" + + # The HTTP status decides. Errors from the API itself are RFC 9457 + # problem+json and carry a "detail" to show and a stable "code" to branch on, + # but a request that never gets that far -- bergdns.at answers from behind a + # reverse proxy, whose 502 and 504 are HTML -- has neither, and reading the + # body alone would take those for success. + case "$_bergdns_status" in + 2*) return 0 ;; + esac + _bergdns_error=$(echo "$response" | _egrep_o '"detail":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_code=$(echo "$response" | _egrep_o '"code":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + [ -n "$_bergdns_error" ] || _bergdns_error="$_bergdns_url answered HTTP ${_bergdns_status:-(none)}" + _debug _bergdns_code "$_bergdns_code" + return 1 +} diff --git a/dnsapi/dns_bhosted.sh b/dnsapi/dns_bhosted.sh index 46ddd5dd..31da0099 100644 --- a/dnsapi/dns_bhosted.sh +++ b/dnsapi/dns_bhosted.sh @@ -283,7 +283,7 @@ _bhosted_response_has_error() { fi # Fallback for unexpected/non-XML responses - _resp_lc="$(_lower_case "$_resp")" + _resp_lc="$(printf "%s\n" "$_resp" | _lower_case)" if _contains "$_resp_lc" "error"; then _debug "Detected 'error' in response" diff --git a/dnsapi/dns_mvmnet.sh b/dnsapi/dns_mvmnet.sh new file mode 100755 index 00000000..0a9a27be --- /dev/null +++ b/dnsapi/dns_mvmnet.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_mvmnet_info='mvmnet.com +Site: mvmnet.com +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_mvmnet +Api Docs: https://api.mvmnet.com/api/1.0/documentation/ +Issues: https://github.com/acmesh-official/acme.sh/issues/7297 +Author: Matteo Gaggiano +Options: + MVMNET_ID application_id + MVMNET_KEY application_key + MVMNET_SEED application_seed + MVMNET_API_URL API base URL (defaults to https://api.mvmnet.com/api/1.0, use https://api.mvmnet.com/ote/1.0 for test) +' + +MVMNET_API_URL="${MVMNET_API_URL:-https://api.mvmnet.com/api/1.0}" + +######## Public functions ##################### + +dns_mvmnet_add() { + fulldomain=$1 + txtvalue=$2 + + _mvmnet_retrieve_and_check_variables || return 1 + + _debug "First detect the root zone" + if ! _mvmnet_get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _subdomain "$_subdomain" + + _info "Adding TXT record to ${fulldomain}" + postdata=$(printf '{"domain":"%s","rtype":"TXT","ldata":"%s","rdata":"%s"}' "${_domain}" "${_subdomain}" "${txtvalue}") + _mvmnet_rest POST "dns/zone/record" "$postdata" + + if ! _contains "${response}" 'error'; then + return 0 + fi + _err "Could not create resource record, check logs" + _err "${response}" + return 1 +} + +dns_mvmnet_rm() { + fulldomain=$1 + txtvalue=$2 + + _mvmnet_check_variables || return 1 + + _debug "First detect the root zone" + if ! _mvmnet_get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _subdomain "$_subdomain" + + if ! _mvmnet_get_record_id "$_subdomain" "$_domain" "$txtvalue"; then + _warn "Record id for $_subdomain not found, please remove it manually" + return 0 + fi + + _debug _subdomain_record_id "$_subdomain_record_id" + + _info "Deleting resource record $fulldomain ($_subdomain_record_id)" + _mvmnet_rest DELETE "dns/zone/record" "domain=${_domain}&id=${_subdomain_record_id}" + + if ! _contains "${response}" 'error'; then + return 0 + fi + _err "Could not delete resource record, check logs" + _err "${response}" + return 1 +} + +#################### Private functions below ################################## + +_mvmnet_check_variables() { + MVMNET_ID="${MVMNET_ID:-$(_readaccountconf_mutable MVMNET_ID)}" + MVMNET_KEY="${MVMNET_KEY:-$(_readaccountconf_mutable MVMNET_KEY)}" + MVMNET_SEED="${MVMNET_SEED:-$(_readaccountconf_mutable MVMNET_SEED)}" + if [ -z "$MVMNET_ID" ] || [ -z "$MVMNET_KEY" ] || [ -z "$MVMNET_SEED" ]; then + MVMNET_ID="" + MVMNET_KEY="" + MVMNET_SEED="" + _err "You don't specify application_id, application_key or application_seed." + return 1 + fi +} + +_mvmnet_retrieve_and_check_variables() { + _mvmnet_check_variables || return 1 + + _saveaccountconf_mutable MVMNET_ID "$MVMNET_ID" + _saveaccountconf_mutable MVMNET_KEY "$MVMNET_KEY" + _saveaccountconf_mutable MVMNET_SEED "$MVMNET_SEED" +} + +#_acme-challenge.www.domain.com +#returns +# _subdomain=_acme-challenge.www +# _domain=domain.com +# _domain_id=123456789 +_mvmnet_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + #not valid + _debug "not valid $h" + return 1 + fi + + if ! _mvmnet_rest GET "domain/info" "domain=$h"; then + return 1 + fi + + if _contains "$response" "\"domain_idn\":\"$h\""; then + _domain_id=$(echo "$response" | _egrep_o ".\"id\": *\"[0-9]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \" | tr -d " ") + if [ "$_domain_id" ]; then + _subdomain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +# _acme-challenge.www +# domain.com +# value (Optional) +#returns +# _subdomain_record_id=123456789 +_mvmnet_get_record_id() { + subdomain=$1 + domain=$2 + txtvalue=$3 + + _debug3 subdomain "$subdomain" + _debug3 domain "$domain" + _debug3 txtvalue "$txtvalue" + + hosttofind="${subdomain}.${domain}" + + if ! _mvmnet_rest GET "dns/zone" "domain=$domain"; then + return 1 + fi + + objectbody="$(echo "$response" | _egrep_o '\{[^{}]*\}' | grep -E "\"host\" *: *\"$hosttofind\.\".*\"type\" *: *\"TXT\"|\"type\" *: *\"TXT\".*\"host\" *: *\"$hosttofind\.\"")" + + _debug3 objectbody "${objectbody}" + + if [ "$txtvalue" != "" ]; then + _info "A value will be searched: $txtvalue" + objectbody="$(echo "$objectbody" | grep "\"value\" *: *\"\\\\\"$txtvalue\\\\\"\"")" + _debug3 objectbody "${objectbody}" + fi + + _subdomain_record_id="$(echo "$objectbody" | _egrep_o ".\"id\": *\"[0-9]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \" | tr -d ' ')" + + if [ "${_subdomain_record_id}x" = "x" ]; then + _err "error retrieve sub domain record id" + return 1 + fi + return 0 +} + +#returns +# response +_mvmnet_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + signature="$(printf "%s" "${MVMNET_ID}+${MVMNET_KEY}+$m+${MVMNET_SEED}" | _digest "sha1" "hex")" + + export _H1="X-Mvm-Application: ${MVMNET_ID}" + export _H2="X-Mvm-Signature: ${signature}" + + case "$m" in + POST | PUT) + _debug data "$data" + response="$(_post "$data" "$MVMNET_API_URL/$ep" "" "$m" "application/json")" + ;; + DELETE) + response="$(_post "" "$MVMNET_API_URL/$ep?$data" "" "$m")" + ;; + GET) + response="$(_get "$MVMNET_API_URL/$ep?$data")" + ;; + esac + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + + _debug response "${response}" + return 0 +} diff --git a/dnsapi/dns_pblin.sh b/dnsapi/dns_pblin.sh new file mode 100644 index 00000000..b95898c0 --- /dev/null +++ b/dnsapi/dns_pblin.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_pblin_info='publicinternet.com +Domains: public-internet.co.uk pblin.net +Site: www.publicinternet.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_pblin +Options: + PBLIN_API_KEY API Key. Create an API key in the Public Internet customer portal at https://portal.publicinternet.com. A key is scoped to one domain, so each certificate needs the key for its own domain +Issues: github.com/acmesh-official/acme.sh/issues/7314 +Author: Public Internet +' + +PBLIN_Api="https://api.pblin.net/dns" + +#Usage: dns_pblin_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_pblin_add() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _pblin_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + return 1 + fi + + #an API key is scoped to one zone, so it is kept per certificate, and only once a zone lookup has accepted it + _savedomainconf SAVED_PBLIN_API_KEY "$PBLIN_API_KEY" + #the out-of-tree dns_pblin.sh saved the key unprefixed, which a renewal would load over a newly exported key + _cleardomainconf PBLIN_API_KEY + + _info "Adding TXT record for $fulldomain" + if ! _pblin_rest POST "domains/$_domain/records" "{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"content\":\"$txtvalue\",\"ttl\":600}"; then + _err "Failed to add TXT record: $_message" + return 1 + fi + + _info "TXT record added" + return 0 +} + +#Usage: dns_pblin_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_pblin_rm() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _pblin_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + return 1 + fi + + _info "Removing TXT record for $fulldomain" + if ! _pblin_rest GET "domains/$_domain/records?type=TXT&name=$fulldomain"; then + _err "Failed to list TXT records: $_message" + return 1 + fi + + #the API has no content filter and accepts duplicates, so match the value here and remove every copy + _record_ids="$(echo "$response" | tr '{' '\n' | grep "\"content\":\"$txtvalue\"" | _egrep_o '"id" *: *[0-9]*' | cut -d : -f 2 | tr -d ' ')" + _debug _record_ids "$_record_ids" + + if [ -z "$_record_ids" ]; then + _info "TXT record already removed" + return 0 + fi + + for _record_id in $_record_ids; do + if ! _pblin_rest DELETE "domains/$_domain/records/$_record_id" && [ "$_code" != "404" ]; then + _err "Failed to remove TXT record $_record_id: $_message" + return 1 + fi + done + + _info "TXT record removed" + return 0 +} + +_pblin_init() { + #the SAVED_ prefix keeps a reloaded certificate conf from overriding a newly exported key + PBLIN_API_KEY="${PBLIN_API_KEY:-$(_readdomainconf SAVED_PBLIN_API_KEY)}" + #only renewals load the out-of-tree plugin's unprefixed key, so read it here for --issue too + PBLIN_API_KEY="${PBLIN_API_KEY:-$(_readdomainconf PBLIN_API_KEY)}" + + if [ -z "$PBLIN_API_KEY" ]; then + _err "PBLIN_API_KEY is not set. Create an API key in the Public Internet customer portal at https://portal.publicinternet.com, then:" + _err "export PBLIN_API_KEY=\"your-api-key\"" + return 1 + fi + + return 0 +} + +_get_root() { + domain=$1 + i=1 + + while true; do + #-s: plain cut returns a dotless name (e.g. from --domain-alias) for every i, so the loop would keep querying it + h=$(printf "%s" "$domain" | cut -s -d . -f "$i"-100) + + if [ -z "$h" ]; then + _err "No zone for $domain found in this Public Internet account" + return 1 + fi + + if _pblin_rest GET "domains/$h"; then + _domain=$h + return 0 + fi + + case "$_code" in + 404) ;; + 401 | 403) + _err "The API key was rejected ($_message), check PBLIN_API_KEY" + return 1 + ;; + *) + _err "Failed to look up zone $h: $_message" + return 1 + ;; + esac + + i=$(_math "$i" + 1) + done +} + +_pblin_rest() { + m=$1 + ep=$2 + data=$3 + + export _H1="Authorization: Bearer $PBLIN_API_KEY" + export _H2="Content-Type: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "$PBLIN_Api/$ep")" + else + response="$(_post "$data" "$PBLIN_Api/$ep" "" "$m")" + fi + + if [ "$?" != "0" ]; then + _code="" + _message="connection failed" + return 1 + fi + + #printf, not echo: dash's builtin echo interprets backslash escapes in record content + response="$(printf "%s\n" "$response" | _normalizeJson)" + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 _code "$_code" + _debug2 response "$response" + + case "$_code" in + 2*) return 0 ;; + esac + + _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + _message="HTTP ${_code:-request failed}${_message:+: $_message}" + return 1 +} diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh index a4c81835..2ae86347 100644 --- a/dnsapi/dns_poweradmin.sh +++ b/dnsapi/dns_poweradmin.sh @@ -114,21 +114,32 @@ _rm_record() { full=$1 txtvalue=$2 - if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records"; then + if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records?type=TXT"; then _err "Failed to retrieve records" return 1 fi - # The API returns: {"success":true,"data":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]} - _txt_record_obj=$( - printf '%s\n' "$response" | - sed 's/^.*"data":\[//; s/\],"message":.*$//' | - awk '{ gsub(/},{/, "}\n{"); print }' | - grep -F "\"name\":\"$full\"" | - grep -F "\"type\":\"TXT\"" | - grep -F "\"content\":\"$txtvalue\"" | - _head_n 1 - ) + # The API returns: {"success":true,"data":{"records":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]},"message":"..."} + _records="$(printf '%s\n' "$response" | tr -d '\r\n' | _egrep_o '\{[^{}]*\}')" + _txt_record_obj="" + while IFS= read -r _rec || [ -n "$_rec" ]; do + case "$_rec" in + *"\"name\":\"$full\""*) ;; + *) continue ;; + esac + case "$_rec" in + *"\"type\":\"TXT\""*) ;; + *) continue ;; + esac + case "$_rec" in + *"\"content\":\"$txtvalue\""*) + _txt_record_obj="$_rec" + break + ;; + esac + done </dev/null; then - _err "Invalid response of acme-dns for selfhost" + if ! _selfhost_api "present" "$rid" "$txt"; then return 1 fi @@ -96,11 +76,22 @@ dns_selfhost_add() { fi # Now that we know the values are good, save them - _saveaccountconf_mutable SELFHOSTDNS_USERNAME "$SELFHOSTDNS_USERNAME" - _saveaccountconf_mutable SELFHOSTDNS_PASSWORD "$SELFHOSTDNS_PASSWORD" + _saveaccountconf_mutable SELFHOSTDNS_APIKEY "$SELFHOSTDNS_APIKEY" + # The old api credentials are not used anymore + _clearaccountconf_mutable SELFHOSTDNS_USERNAME + _clearaccountconf_mutable SELFHOSTDNS_PASSWORD # These values are domain dependent, so store them there _savedomainconf SELFHOSTDNS_MAP "$SELFHOSTDNS_MAP" _savedomainconf SELFHOSTDNS_MAP_LAST_USED_INTERNAL "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" + # remember which RID holds this txt value, so it can be cleaned up later + # (format: 'ridA:txtA ridB:txtB ...') + SELFHOSTDNS_TXT_RID_INTERNAL=$(_readdomainconf SELFHOSTDNS_TXT_RID_INTERNAL) + if test -z "$SELFHOSTDNS_TXT_RID_INTERNAL"; then + SELFHOSTDNS_TXT_RID_INTERNAL="$rid:$txt" + else + SELFHOSTDNS_TXT_RID_INTERNAL="$SELFHOSTDNS_TXT_RID_INTERNAL $rid:$txt" + fi + _savedomainconf SELFHOSTDNS_TXT_RID_INTERNAL "$SELFHOSTDNS_TXT_RID_INTERNAL" } dns_selfhost_rm() { @@ -108,5 +99,96 @@ dns_selfhost_rm() { txt=$2 _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - _info "Creating and removing of records is not supported by selfhost API, will not delete anything." + + case "$fulldomain" in + acmetestXyzRandomName.*) + # See dns_selfhost_add. + _info "Skipping the DNS-API-Test record $fulldomain, the selfhost api can only update existing TXT records." + return 0 + ;; + esac + + if ! _selfhost_load_conf; then + return 1 + fi + + # find the RID which was used for this txt value + SELFHOSTDNS_TXT_RID_INTERNAL=$(_readdomainconf SELFHOSTDNS_TXT_RID_INTERNAL) + rids="" + remainingTxtRidEntries="" + for txtRidEntry in $SELFHOSTDNS_TXT_RID_INTERNAL; do + if [ "${txtRidEntry#*:}" = "$txt" ]; then + rids="${txtRidEntry%%:*}" + elif test -z "$remainingTxtRidEntries"; then + remainingTxtRidEntries="$txtRidEntry" + else + remainingTxtRidEntries="$remainingTxtRidEntries $txtRidEntry" + fi + done + if test -z "$rids"; then + # unknown txt value, so try all RIDs of the domain + rids="$rid1 $rid2" + fi + + for rid in $rids; do + _info "Trying to remove $txt on selfhost for rid: $rid" + if ! _selfhost_api "cleanup" "$rid" "$txt"; then + return 1 + fi + done + + _savedomainconf SELFHOSTDNS_TXT_RID_INTERNAL "$remainingTxtRidEntries" +} + +#################### Private functions below ################################## + +# reads the config and sets rid1 and rid2 for $fulldomain +_selfhost_load_conf() { + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}" + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}" + SELFHOSTDNS_APIKEY="${SELFHOSTDNS_APIKEY:-$(_readaccountconf_mutable SELFHOSTDNS_APIKEY)}" + # These values are domain dependent, so read them from there + SELFHOSTDNS_MAP="${SELFHOSTDNS_MAP:-$(_readdomainconf SELFHOSTDNS_MAP)}" + + if [ -z "${SELFHOSTDNS_APIKEY:-}" ]; then + _err "SELFHOSTDNS_APIKEY must be set" + # hint for users still configured for the old username/password api + SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" + SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}" + if [ -n "${SELFHOSTDNS_USERNAME:-}" ] || [ -n "${SELFHOSTDNS_PASSWORD:-}" ]; then + _err "Selfhost has changed its API: SELFHOSTDNS_USERNAME and SELFHOSTDNS_PASSWORD are no longer supported, you have to migrate to SELFHOSTDNS_APIKEY." + _err "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_selfhost for instructions." + fi + return 1 + fi + + # get the domain entry from SELFHOSTDNS_MAP + # only match full domains (at the beginning of the string or with a leading whitespace), + # e.g. don't match mytest.example.com or sub.test.example.com for test.example.com + # if the domain is defined multiple times only the last occurance will be matched + # prepend a space to each line so "start of line" and "after whitespace" + # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..)) + _selfhost_tab="$(printf '\t')" + mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p") + _debug2 mapEntry "$mapEntry" + if test -z "$mapEntry"; then + _err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID" + return 1 + fi + + # get the RIDs from the map entry + rid1=$(echo "$mapEntry" | cut -d: -f2) + rid2=$(echo "$mapEntry" | cut -d: -f3) +} + +# usage: _selfhost_api action rid txt +_selfhost_api() { + data="{\"api_key\":\"$SELFHOSTDNS_APIKEY\",\"action\":\"$1\",\"record_id\":$2,\"content\":\"$3\"}" + response="$(_post "$data" "$SELFHOSTDNS_UPDATE_URL" "" "POST" "application/json")" + _debug2 response "$response" + + if ! echo "$response" | grep '"ok" *: *1' >/dev/null; then + _err "Invalid response of acme-dns for selfhost: $response" + return 1 + fi } diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index cacb5977..3af89d1b 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -113,7 +113,7 @@ dns_yc_add() { _info "Adding record" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then - if _contains "$response" "\"done\": true"; then + if _contains "$response" "\"done\": *true"; then _info "Added, OK" return 0 else @@ -168,7 +168,7 @@ dns_yc_rm() { # leaving any other values at the same name (e.g. base + wildcard domain) # intact -- no need to read the current data set and recompute it. if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then - if _contains "$response" "\"done\": true"; then + if _contains "$response" "\"done\": *true"; then _info "Delete, OK" return 0 else @@ -201,6 +201,8 @@ _get_root() { if [ "$_domain" ]; then _cutlength=$((${#domain} - ${#_domain})) _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") + # Strip the trailing dot: a relative name, as in the folder lookup below + _sub_domain="${_sub_domain%.}" _domain_id=$YC_Zone_ID return 0 else @@ -227,8 +229,9 @@ _get_root() { echo "You didn't specify a Yandex Cloud Folder ID." return 1 fi - if _contains "$response" "\"zone\": \"$h\""; then - _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') + if _contains "$response" "\"zone\": *\"$h\""; then + # Drop labels objects first, their braces would break the [^{]* match + _domain_id=$(echo "$response" | _normalizeJson | sed "s/\"labels\":{[^}]*}/\"labels\":0/g" | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') _debug _domain_id "$_domain_id" if [ "$_domain_id" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") diff --git a/notify/healthchecks.sh b/notify/healthchecks.sh new file mode 100644 index 00000000..90d37a0b --- /dev/null +++ b/notify/healthchecks.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env sh + +#Support Healthchecks.io (hosted or self-hosted) +#https://healthchecks.io/docs/http_api/ + +#Required: +#HEALTHCHECKS_URL="https://hc-ping.com/your-uuid" + +#Use with --notify-level 3, so a ping is sent on every cron run, even when +#all certs are skipped. Otherwise Healthchecks reports the check as down. + +healthchecks_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + HEALTHCHECKS_URL="${HEALTHCHECKS_URL:-$(_readaccountconf_mutable HEALTHCHECKS_URL)}" + if [ -z "$HEALTHCHECKS_URL" ]; then + HEALTHCHECKS_URL="" + _err "You didn't specify the Healthchecks.io ping url HEALTHCHECKS_URL yet." + _err "Example: export HEALTHCHECKS_URL=\"https://hc-ping.com/your-uuid\"" + return 1 + fi + _saveaccountconf_mutable HEALTHCHECKS_URL "$HEALTHCHECKS_URL" + + _hc_url="${HEALTHCHECKS_URL%/}" + case "$_statusCode" in + 0 | 2) ;; + 1) + _hc_url="$_hc_url/fail" + ;; + *) + _hc_url="$_hc_url/log" + ;; + esac + + _data="$_subject + +$_content" + + response="$(_post "$_data" "$_hc_url" "" "POST" "text/plain")" + + if [ "$?" = "0" ] && [ "$response" = "OK" ]; then + _info "healthchecks ping success." + return 0 + fi + _err "healthchecks ping error." + _err "$response" + return 1 +}