diff --git a/.github/workflows/Apache.yml b/.github/workflows/Apache.yml new file mode 100644 index 00000000..b17abbd1 --- /dev/null +++ b/.github/workflows/Apache.yml @@ -0,0 +1,50 @@ +name: Apache +on: + push: + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Apache: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_APACHE: 1 + CASE: le_test_apache + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat apache2 + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up Apache + # Apache serves on 5002, which is the HTTP-01 validation port in + # Pebble's default config; acme.sh appends the challenge Alias to + # the main config itself + run: | + echo "Listen 5002" | sudo tee /etc/apache2/ports.conf + sudo sed -i "s/\*:80/*:5002/" /etc/apache2/sites-available/000-default.conf + sudo apache2ctl configtest + sudo systemctl restart apache2 + curl -s -o /dev/null -w "%{http_code}" -H "Host: example.com" http://127.0.0.1:5002/ | grep -E "200|403|404" + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index a972ae1a..5417068f 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -26,9 +26,9 @@ jobs: id: step_one run: | if [ "${{secrets.TokenName1}}" ] ; then - echo "::set-output name=hasToken::true" + echo "hasToken=true" >> "$GITHUB_OUTPUT" else - echo "::set-output name=hasToken::false" + echo "hasToken=false" >> "$GITHUB_OUTPUT" fi - name: Check the value run: echo ${{ steps.step_one.outputs.hasToken }} @@ -116,7 +116,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install tools - run: brew install socat + run: | + brew untap aws/tap || true + brew install socat - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest @@ -176,9 +178,14 @@ jobs: C:\tools\cygwin\cygwinsetup.exe -qgnNdO -R C:/tools/cygwin -s https://mirrors.kernel.org/sourceware/cygwin/ -P socat,curl,cron,unzip,git shell: cmd - name: Set ENV - shell: cmd + shell: bash run: | - echo PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin >> %GITHUB_ENV% + echo 'PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin' >> "$GITHUB_ENV" + # cygwin git sees the runner workspace as owned by another user and + # fails with "dubious ownership" (exit 128) in the checkout post step + echo 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_KEY_0=safe.directory' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_VALUE_0=*' >> "$GITHUB_ENV" - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest diff --git a/.github/workflows/Nginx.yml b/.github/workflows/Nginx.yml new file mode 100644 index 00000000..2ca9d64a --- /dev/null +++ b/.github/workflows/Nginx.yml @@ -0,0 +1,66 @@ +name: Nginx +on: + push: + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Nginx: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_NGINX: 1 + CASE: le_test_nginx + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat nginx + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up nginx + # a backend on 8081 plus a site with an aaPanel/BT style + # "location ^~ /" proxy block that shadows plain regex locations + # (regression for #6125); the site listens on 5002, which is the + # HTTP-01 validation port in Pebble's default config + run: | + sudo tee /etc/nginx/sites-available/default >/dev/null <<'EOF' + server { + listen 127.0.0.1:8081; + location / { + default_type text/plain; + return 200 "backend"; + } + } + server { + listen 5002 default_server; + server_name example.com; + location ^~ / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $http_host; + } + } + EOF + sudo nginx -t + sudo systemctl restart nginx + curl -s -H "Host: example.com" http://127.0.0.1:5002/ | grep backend + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/acme.sh b/acme.sh index 5529fe88..4f1c0336 100755 --- a/acme.sh +++ b/acme.sh @@ -918,6 +918,15 @@ _json_decode() { echo "$_j_str" } +#extract the authorization URLs from an order response on stdin, as a +#comma-separated list. The entries are quoted URL strings and a quote cannot +#occur inside a URL, so the first '"]' is always the end of the array. A +#char-class scan would stop early on the brackets of an IPv6 host +#(https://[2001:db8::1]/...). Outputs nothing if the field is missing. +_authorizations_from_order() { + sed -n 's/.*"authorizations" *: *\[//p' | sed 's/" *\].*//' | tr -d '" ' +} + #options file _sed_i() { options="$1" @@ -1179,6 +1188,11 @@ _createkey() { length="$1" f="$2" _debug2 "_createkey for file:$f" + if ! _exists "${ACME_OPENSSL_BIN:-openssl}"; then + _err "Please install openssl first. ACME_OPENSSL_BIN=$ACME_OPENSSL_BIN" + _err "We need openssl to generate keys." + return 1 + fi eccname="$length" if _startswith "$length" "ec-"; then length=$(printf "%s" "$length" | cut -d '-' -f 2-100) @@ -1201,6 +1215,7 @@ _createkey() { _debug "Using length $length" + _new_key_file="" if ! [ -e "$f" ]; then if ! touch "$f" >/dev/null 2>&1; then _f_path="$(dirname "$f")" @@ -1214,6 +1229,7 @@ _createkey() { return 1 fi chmod 600 "$f" + _new_key_file="1" fi if _isEccKey "$length"; then @@ -1222,6 +1238,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for ECC key named $eccname" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi else @@ -1234,6 +1254,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for RSA key of length $length" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi fi @@ -1283,6 +1307,23 @@ _idn() { } #_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage +#cn +#The x509 Common Name is limited to 64 characters (RFC 5280 ub-common-name, +#enforced by openssl in ASN1_mbstring_ncopy), and an IP address or an empty +#name is not usable as CN either. When this rejects the name, _createcsr +#omits CN from the CSR subject and the CA takes the identifiers from the +#subjectAltName extension (issue 4867). +_is_valid_cn() { + _cn_v="$1" + if [ -z "$_cn_v" ] || [ "${#_cn_v}" -gt 64 ]; then + return 1 + fi + if _isIP "$_cn_v"; then + return 1 + fi + return 0 +} + _createcsr() { _debug _createcsr domain="$1" @@ -1346,16 +1387,16 @@ _createcsr() { _csr_cn="$(_idn "$domain")" _debug2 _csr_cn "$_csr_cn" if _contains "$(uname -a)" "MINGW"; then - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi else - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi fi } @@ -1381,7 +1422,9 @@ _readSubjectFromCSR() { _usage "_readSubjectFromCSR mycsr.csr" return 1 fi - ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' + # -config /dev/null: reading a CSR needs no config, but a missing default + # openssl.cnf is fatal on some systems (e.g. NetBSD does not install one) + ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject -config /dev/null | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' } #_csrfile @@ -1396,15 +1439,16 @@ _readSubjectAltNamesFromCSR() { _csrsubj="$(_readSubjectFromCSR "$_csrfile")" _debug _csrsubj "$_csrsubj" - _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')" + _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null | grep "^ *DNS:.*" | tr -d ' \n')" _debug _dnsAltnames "$_dnsAltnames" - if _contains "$_dnsAltnames," "DNS:$_csrsubj,"; then + # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below + _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" + _debug _excapedAlgnames "$_excapedAlgnames" + _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" + _debug _escapedSubject "$_escapedSubject" + if _contains "$_excapedAlgnames," "DNS:$_escapedSubject,"; then _debug "AltNames contains subject" - _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" - _debug _excapedAlgnames "$_excapedAlgnames" - _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" - _debug _escapedSubject "$_escapedSubject" _dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")" _debug _dnsAltnames "$_dnsAltnames" else @@ -1422,7 +1466,7 @@ _readKeyLengthFromCSR() { return 1 fi - _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile")" + _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null)" _debug2 _outcsr "$_outcsr" if _contains "$_outcsr" "Public Key Algorithm: id-ecPublicKey"; then _debug "ECC CSR" @@ -1503,6 +1547,22 @@ _toPkcs() { } +_toPkcs8() { + _cpkcs8="$1" + _ckey="$2" + pkcs8Password="$3" + + if [ "$pkcs8Password" ]; then + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -v2 aes256 -passout "pass:$pkcs8Password" -in "$_ckey" -out "$_cpkcs8" + else + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$_ckey" -out "$_cpkcs8" + fi + if [ "$?" = "0" ]; then + _savedomainconf "Le_PKCS8Password" "$pkcs8Password" "base64" + fi + +} + #domain [password] [isEcc] toPkcs() { domain="$1" @@ -1524,20 +1584,21 @@ toPkcs() { } -#domain [isEcc] +#domain [password] [isEcc] toPkcs8() { domain="$1" + pkcs8Password="$2" if [ -z "$domain" ]; then - _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--ecc]" + _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--password ] [--ecc]" return 1 fi - _isEcc="$2" + _isEcc="$3" _initpath "$domain" "$_isEcc" - ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$CERT_KEY_PATH" -out "$CERT_PKCS8_PATH" + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$pkcs8Password" if [ "$?" = "0" ]; then _info "Success, $CERT_PKCS8_PATH" @@ -1871,12 +1932,13 @@ _date2time() { if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then return fi - #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + #Omnios. Pass the date as argv (sys.argv[1]) instead of interpolating it into + #the -c program text, so a quote in the input cannot inject Python code. + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi _err "Cannot parse _date2time $1" @@ -2834,14 +2896,45 @@ __initHome() { _debug "Using default home: $DEFAULT_INSTALL_HOME" LE_WORKING_DIR="$DEFAULT_INSTALL_HOME" fi + # Convert a relative --home to an absolute path: later code cd's around + # (e.g. installOnline extracts and enters the archive dir), where a + # relative path would point into the wrong directory. + # https://github.com/acmesh-official/acme.sh/issues/6477 + case "$LE_WORKING_DIR" in + /*) ;; + *) + if [ -d "$LE_WORKING_DIR" ]; then + LE_WORKING_DIR="$(cd "$LE_WORKING_DIR" && pwd)" + fi + ;; + esac export LE_WORKING_DIR if [ -z "$LE_CONFIG_HOME" ]; then LE_CONFIG_HOME="$LE_WORKING_DIR" fi + case "$LE_CONFIG_HOME" in + /*) ;; + *) + if [ -d "$LE_CONFIG_HOME" ]; then + LE_CONFIG_HOME="$(cd "$LE_CONFIG_HOME" && pwd)" + fi + ;; + esac _debug "Using config home: $LE_CONFIG_HOME" export LE_CONFIG_HOME + # Paths with whitespace break the unquoted $_CURL/$_WGET command expansion, + # so fail early with a clear error instead of a cryptic curl/wget failure. + # https://github.com/acmesh-official/acme.sh/issues/2163 + case "$LE_WORKING_DIR$LE_CONFIG_HOME" in + *" "*) + _err "The --home or --config-home path can not contain spaces: '$LE_WORKING_DIR'" + _err "Please install $PROJECT_NAME to a path without spaces." + exit 1 + ;; + esac + _DEFAULT_ACCOUNT_CONF_PATH="$LE_CONFIG_HOME/account.conf" if [ -z "$ACCOUNT_CONF_PATH" ]; then @@ -2937,6 +3030,11 @@ _initAPI() { return 0 fi _err "Cannot init API for $_api_server" + if [ "$_api_server" = "$CA_ZEROSSL" ]; then + _info "$(__green "If this host is IPv6-only: ZeroSSL currently has no IPv6 endpoint.")" + _info "$(__green "Try another CA, e.g.: $PROJECT_ENTRY --set-default-ca --server letsencrypt")" + _info "See: $(__green "https://github.com/acmesh-official/acme.sh/issues/6872")" + fi return 1 } @@ -3367,9 +3465,14 @@ _setNginx() { fi echo "$NGINX_START -location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { - default_type text/plain; - return 200 \"\$1.$_thumbpt\"; +location ^~ /.well-known/acme-challenge/ { + # the ^~ prefix wins over regex-skipping blocks like \"location ^~ /\", + # the nested regex location still captures the token as \$1 + location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { + default_type text/plain; + return 200 \"\$1.$_thumbpt\"; + } + return 404; } #NGINX_START " >>"$FOUND_REAL_NGINX_CONF" @@ -3513,7 +3616,7 @@ _restoreNginx() { done _info "Reloading nginx" - if ! nginx -s reload >/dev/null; then + if ! nginx -s reload >/dev/null 2>&1; then _err "An error occurred while reloading nginx, please open an issue on $PROJECT." return 1 fi @@ -3817,10 +3920,10 @@ _on_issue_success() { #account_key_length eab-kid eab-hmac-key registeraccount() { _account_key_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _initpath - _regAccount "$_account_key_length" "$_eab_id" "$_eab_hmac_key" + _regAccount "$_account_key_length" "$_eab_kid" "$_eab_hmac_key" } __calcAccountKeyHash() { @@ -3831,6 +3934,16 @@ __calc_account_thumbprint() { printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace } +#Reads a comma- or space-separated email list from stdin and prints +#the ACME contact list items: "mailto:a@example.com","mailto:b@example.com" +_mailto_contacts() { + _mc_out="" + for _mc_m in $(tr ',' ' '); do + _mc_out="$_mc_out,\"mailto:$_mc_m\"" + done + echo "$_mc_out" | cut -c 2- +} + _getAccountEmail() { if [ "$ACCOUNT_EMAIL" ]; then echo "$ACCOUNT_EMAIL" @@ -3850,14 +3963,14 @@ _getAccountEmail() { _regAccount() { _initpath _reg_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _debug3 _regAccount "$_regAccount" _initAPI mkdir -p "$CA_DIR" - if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + if [ ! -s "$ACCOUNT_KEY_PATH" ]; then if ! _create_account_key "$_reg_length"; then _err "Error creating account key." return 1 @@ -3867,13 +3980,13 @@ _regAccount() { if ! _calcjwk "$ACCOUNT_KEY_PATH"; then return 1 fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - _savecaconf CA_EAB_KEY_ID "$_eab_id" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi - _eab_id=$(_readcaconf "CA_EAB_KEY_ID") + _eab_kid=$(_readcaconf "CA_EAB_KEY_ID") _eab_hmac_key=$(_readcaconf "CA_EAB_HMAC_KEY") - _secure_debug3 _eab_id "$_eab_id" + _secure_debug3 _eab_kid "$_eab_kid" _secure_debug3 _eab_hmac_key "$_eab_hmac_key" _email="$(_getAccountEmail)" if [ "$_email" ]; then @@ -3881,7 +3994,7 @@ _regAccount() { fi if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then - if [ -z "$_eab_id" ] || [ -z "$_eab_hmac_key" ]; then + if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then _info "No EAB credentials found for ZeroSSL, let's obtain them" if [ -z "$_email" ]; then _info "$(__green "$PROJECT_NAME is using ZeroSSL as default CA now.")" @@ -3890,17 +4003,19 @@ _regAccount() { _info "See: $(__green "$_ZEROSSL_WIKI")" return 1 fi - _eabresp=$(_post "email=$_email" $_ZERO_EAB_ENDPOINT) + #the ZeroSSL EAB endpoint takes a single address, use the first one + _eab_email="$(echo "$_email" | tr ',' ' ' | awk '{print $1}')" + _eabresp=$(_post "email=$_eab_email" $_ZERO_EAB_ENDPOINT) if [ "$?" != "0" ]; then _debug2 "$_eabresp" _err "Cannot get EAB credentials from ZeroSSL." return 1 fi _secure_debug2 _eabresp "$_eabresp" - _eab_id="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" - _secure_debug2 _eab_id "$_eab_id" - if [ -z "$_eab_id" ]; then - _err "Cannot resolve _eab_id" + _eab_kid="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" + _secure_debug2 _eab_kid "$_eab_kid" + if [ -z "$_eab_kid" ]; then + _err "Cannot resolve _eab_kid" return 1 fi _eab_hmac_key="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_hmac_key"' | cut -d : -f 2 | tr -d '"')" @@ -3909,12 +4024,12 @@ _regAccount() { _err "Cannot resolve _eab_hmac_key" return 1 fi - _savecaconf CA_EAB_KEY_ID "$_eab_id" + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_id\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_kid\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" _debug3 eab_protected "$eab_protected" eab_protected64=$(printf "%s" "$eab_protected" | _base64 | _url_replace) @@ -3928,6 +4043,10 @@ _regAccount() { key_hex="$(_durl_replace_base64 "$_eab_hmac_key" | _dbase64 | _hex_dump | tr -d ' ')" _debug3 key_hex "$key_hex" + if [ -z "$key_hex" ]; then + _err "Cannot base64-decode the eab-hmac-key. Please check the value, and your openssl version." + return 1 + fi eab_signature=$(printf "%s" "$eab_sign_t" | _hmac sha256 $key_hex | _base64 | _url_replace) _debug3 eab_signature "$eab_signature" @@ -3936,7 +4055,7 @@ _regAccount() { _debug3 externalBinding "$externalBinding" fi if [ "$_email" ]; then - email_sg="\"contact\": [\"mailto:$_email\"], " + email_sg="\"contact\": [$(echo "$_email" | _mailto_contacts)], " fi regjson="{$email_sg\"termsOfServiceAgreed\": true$externalBinding}" @@ -3980,7 +4099,9 @@ _regAccount() { _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" _savecaconf CA_KEY_HASH "$CA_KEY_HASH" - if [ "$code" = '403' ]; then + #RFC 8555 sec 7.3.6 requires 401 for requests from a deactivated account, + #but Boulder (Let's Encrypt) historically returns 403. Accept both. + if [ "$code" = '403' ] || [ "$code" = '401' ]; then _err "It seems that the account key has been deactivated, please use a new account key." return 1 fi @@ -4014,7 +4135,7 @@ updateaccount() { _email="$(_getAccountEmail)" if [ "$_email" ]; then - updjson='{"contact": ["mailto:'$_email'"]}' + updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}' else updjson='{"contact": []}' fi @@ -4033,6 +4154,93 @@ updateaccount() { fi } +#Implement account key rollover +updateaccountkey() { + _length="$1" + _initpath + + if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + _err "Account key not found at: $ACCOUNT_KEY_PATH" + return 1 + fi + ACCOUNT_KEY_PATH_NEW="$ACCOUNT_KEY_PATH.new" + + _accUri=$(_readcaconf "ACCOUNT_URL") + _debug _accUri "$_accUri" + + if [ -z "$_accUri" ]; then + _err "The account URL is empty, please run '--update-account' first to update the account info, then try again." + return 1 + fi + if ! _calcjwk "$ACCOUNT_KEY_PATH"; then + return 1 + fi + _inner_payload="{\"account\": \"$_accUri\", \"oldKey\": $jwk}" + + _initAPI + if [ -z "$ACME_KEY_CHANGE" ]; then + _err "Server does not expose keyChange url." + return 1 + fi + + _url="$ACME_KEY_CHANGE" + if _createkey "$_length" "$ACCOUNT_KEY_PATH_NEW"; then + _info "New account key creation OK." + else + _err "New account key creation error." + return 1 + fi + + if ! _calcjwk "$ACCOUNT_KEY_PATH_NEW"; then + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _inner_protected="{\"url\": \"${_url}$JWK_HEADERPLACE_PART2, \"jwk\": $jwk"'}' + _inner_protected64="$(printf "%s" "$_inner_protected" | _base64 | _url_replace)" + _inner_payload64="$(printf "%s" "$_inner_payload" | _base64 | _url_replace)" + if ! _inner_sig_t="$(printf "%s" "$_inner_protected64.$_inner_payload64" | _sign "$ACCOUNT_KEY_PATH_NEW" "sha256")"; then + _err "Sign request failed." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _debug3 _inner_sig_t "$_inner_sig_t" + + _inner_sig="$(printf "%s" "$_inner_sig_t" | _url_replace)" + _debug3 _inner_sig "$_inner_sig" + + _body="{\"protected\": \"$_inner_protected64\", \"payload\": \"$_inner_payload64\", \"signature\": \"$_inner_sig\"}" + + if ! _send_signed_request "$_url" "$_body" "" "$ACCOUNT_KEY_PATH"; then + _err "Error rotating account key: $response." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + if [ "$code" = '200' ]; then + echo "$response" >"$ACCOUNT_JSON_PATH" + mv -f "$ACCOUNT_KEY_PATH_NEW" "$ACCOUNT_KEY_PATH" + _info "Account key rotation success for $_accUri." + elif [ "$code" = "409" ]; then + _err "An existing account is using the new key" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + else + _err "Account key rollover error: $response" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + __CACHED_JWK_KEY_FILE="" + _calcjwk "$ACCOUNT_KEY_PATH" + + ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)" + _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT" + + CA_KEY_HASH="$(__calcAccountKeyHash)" + _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" + _savecaconf CA_KEY_HASH "$CA_KEY_HASH" +} + #Implement deactivate account deactivateaccount() { _initpath @@ -4060,7 +4268,8 @@ deactivateaccount() { if _send_signed_request "$_accUri" "$_djson" && _contains "$response" '"deactivated"'; then _info "Successfully deactivated account $_accUri." _accid=$(echo "$response" | _egrep_o "\"id\" *: *[^,]*," | cut -d : -f 2 | tr -d ' ,') - elif [ "$code" = "403" ]; then + elif [ "$code" = "403" ] || [ "$code" = "401" ]; then + #RFC 8555 sec 7.3.6: 401 from a deactivated account; Boulder returns 403 _info "The account is already deactivated." _accid=$(_getfield "$_accUri" "999" "/") else @@ -4533,16 +4742,25 @@ _match_issuer() { #ip _isIPv4() { - for seg in $(echo "$1" | tr '.' ' '); do - _debug2 seg "$seg" - if [ "$(echo "$seg" | tr -d '[0-9]')" ]; then - #not all number + #splitting must not glob: a "*" segment would match files in cwd + set -f + _ipv4_saved_ifs="$IFS" + IFS='.' + # shellcheck disable=SC2086 + set -- $1 + IFS="$_ipv4_saved_ifs" + set +f + if [ $# -ne 4 ]; then + return 1 + fi + for _ipv4_seg in "$@"; do + _debug2 _ipv4_seg "$_ipv4_seg" + case "$_ipv4_seg" in + *[!0-9]* | "") return 1 ;; + esac + if [ "${#_ipv4_seg}" -gt 3 ] || [ "$_ipv4_seg" -gt 255 ]; then return 1 fi - if [ $seg -ge 0 ] && [ $seg -lt 256 ]; then - continue - fi - return 1 done return 0 } @@ -4743,7 +4961,7 @@ issue() { _debug2 _saved_account_key_hash "$_saved_account_key_hash" if [ -z "$ACCOUNT_URL" ] || [ -z "$_saved_account_key_hash" ] || [ "$_saved_account_key_hash" != "$(__calcAccountKeyHash)" ]; then - if ! _regAccount "$_accountkeylength"; then + if ! _regAccount "$_accountkeylength" "$_eab_kid" "$_eab_hmac_key"; then _on_issue_err "$_post_hook" return 1 fi @@ -4935,7 +5153,7 @@ issue() { #for dns manual mode _savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize" - _authorizations_seg="$(echo "$response" | _json_decode | _egrep_o '"authorizations" *: *\[[^\[]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." @@ -5751,6 +5969,21 @@ $_authorizations_map" fi fi + # Warn when the scheduled renewal falls after the cert has already expired, + # e.g. a 1-day cert from an internal CA combined with the default 30-day + # schedule, which computes from the creation date and never looks at + # notAfter. Skip the warning for a fixed-date --valid-to: there + # Le_NextRenewTime equals the expiry by design and the non-renewable state + # was already reported above. https://github.com/acmesh-official/acme.sh/issues/6917 + if [ -z "$_valid_to" ] || _startswith "$_valid_to" "+"; then + _renew_chk_enddate="$(_enddate "$CERT_PATH")" + _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")" + if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then + _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")" + _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")" + fi + fi + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" @@ -5760,6 +5993,12 @@ $_authorizations_map" _toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword" fi + #convert to pkcs8 + Le_PKCS8Password="$(_readdomainconf Le_PKCS8Password)" + if [ "$Le_PKCS8Password" ]; then + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$Le_PKCS8Password" + fi + if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then _savedomainconf "Le_RealCertPath" "$_real_cert" _savedomainconf "Le_RealCACertPath" "$_real_ca" @@ -5858,7 +6097,6 @@ renew() { # If the window has started, renew now even if Le_NextRenewTime is in the future. # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only # Le_NextRenewTime for the renewal decision. - _ari_should_renew="" if [ "$NO_ARI" = "1" ]; then _debug "NO_ARI=1, skipping ARI suggestedWindow check" elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then @@ -5869,20 +6107,63 @@ renew() { _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 + if [ "$_ari_start" ] && [ "$_ari_end" ]; then _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _ari_end_t="$(_date2time "$(echo "$_ari_end" | sed 's/\.[0-9]*//')")" + _ari_explanation_url="$(echo "$_ari_resp" | _egrep_o '"explanationURL" *: *"[^"]*' | sed 's/.*"//')" _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")" + _debug "_ari_end_t" "$_ari_end_t" + _debug "_ari_explanation_url" "$_ari_explanation_url" + _debug "Le_NextRenewTime" "$Le_NextRenewTime" + # Update ARI if needed + if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then + _ari_old_time_str="$Le_NextRenewTimeStr" + _info "Current renewal time: $(__green "$_ari_old_time_str")" + _ari_window=$(_math "$_ari_end_t" - "$_ari_start_t") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")" + _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + _savedomainconf Le_NextRenewTime "$Le_NextRenewTime" + _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr" + fi + if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then + _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal." + if [ "$_ari_explanation_url" ]; then + _info "For more information on this renewal: $(__green "$_ari_explanation_url")" + fi fi fi fi fi - if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + _renew_retry_fixed="" + res="0" + _ensure_install "$Le_Domain" + res="$?" + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then + _ensure_deploy "$Le_Domain" + res="$?" + fi + if [ "$res" != "0" ]; then + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_ERROR ]; then + _send_notify "Renew $Le_Domain error" "There is an error." "$NOTIFY_HOOK" 1 + fi + fi + return 1 + fi + if [ "$_renew_retry_fixed" ]; then + _info "Install/deploy retry succeeded, no renewal is needed." + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_RENEW ]; then + _send_notify "Renew $Le_Domain success" "Good, the cert install/deploy retry succeeded." "$NOTIFY_HOOK" 0 + fi + fi + return 0 + fi _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -5925,11 +6206,8 @@ renew() { fi issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile" "$Le_ExtKeyUse" res="$?" - if [ "$res" != "0" ]; then - return "$res" - fi - if [ "$Le_DeployHook" ]; then + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then _deploy "$Le_Domain" "$Le_DeployHook" res="$?" fi @@ -5969,6 +6247,10 @@ renewAll() { _set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT} _debug "_set_level" "$_set_level" export _ACME_IN_RENEWALL=1 + if ! [ -d "$CERT_HOME" ]; then + _err "$CERT_HOME is not a directory, please check your configuration." + return 1 + fi for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do _debug di "$di" if ! [ -d "$di" ]; then @@ -5977,12 +6259,19 @@ renewAll() { fi d=$(basename "$di") _debug d "$d" + _d_ari="$di.ari" + _debug _d_ari "$_d_ari" ( if _endswith "$d" "$ECC_SUFFIX"; then _isEcc=$(echo "$d" | cut -d "$ECC_SEP" -f 2) d=$(echo "$d" | cut -d "$ECC_SEP" -f 1) fi renew "$d" "$_isEcc" "$_server" + rc="$?" + if [ "$rc" = "0" ] && [ "$_ari_explanation_url" ]; then + echo "$_ari_explanation_url" >"$_d_ari" + fi + return $rc ) rc="$?" _debug "Return code: $rc" @@ -5997,8 +6286,13 @@ renewAll() { _send_notify "Renew $d success" "Good, the cert is renewed." "$NOTIFY_HOOK" 0 fi fi + _renewal_explanation="" + if [ -f "$_d_ari" ]; then + _renewal_explanation=" ($(cat "$_d_ari"))" + rm -f "$_d_ari" + fi - _success_msg="${_success_msg} $d + _success_msg="${_success_msg} $d$_renewal_explanation " elif [ "$rc" = "$RENEW_SKIP" ]; then if [ $_error_level -gt $NOTIFY_LEVEL_SKIP ]; then @@ -6303,6 +6597,45 @@ _deploy() { _info "$(__green Success)" fi done + + _deploy_success_time="$(_time)" + _savedomainconf "Le_DeploySuccessTime" "$_deploy_success_time" + _savedomainconf "Le_DeploySuccessTimeStr" "$(_time2str "$_deploy_success_time")" +} + +_ensure_deploy() { + _d="$1" + if [ -z "$Le_DeployHook" ]; then + return 0 + fi + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _deploy_success_time="$(_readdomainconf Le_DeploySuccessTime)" + if [ -z "$_deploy_success_time" ]; then + _debug "Le_DeploySuccessTime is empty, skip deploy retry check." + return 0 + fi + case "$_deploy_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_DeploySuccessTime or Le_CertCreateTime is not a number, skip deploy retry check." + return 0 + ;; + esac + + if [ "$_deploy_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful deploy, retrying deploy hooks." + if _deploy "$_d" "$Le_DeployHook"; then + _info "Deploy retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Deploy retry failed." + return 1 + fi + + return 0 } #domain hooks @@ -6323,7 +6656,13 @@ deploy() { fi _debug2 DOMAIN_CONF "$DOMAIN_CONF" - . "$DOMAIN_CONF" + # The cert dir may exist without a domain conf (e.g. the conf was deleted, or + # the cert was placed here manually). Deploy can still proceed using env-provided + # settings, and _savedomainconf below will recreate the conf, so only source it + # when present instead of failing on a missing file. + if [ -f "$DOMAIN_CONF" ]; then + . "$DOMAIN_CONF" + fi _savedomainconf Le_DeployHook "$_hooks" @@ -6456,9 +6795,54 @@ _installcert() { _info "$(__green "Reload successful")" else _err "Reload error for: $_main_domain" + return 1 fi fi + _installcert_success_time="$(_time)" + _savedomainconf "Le_InstallCertSuccessTime" "$_installcert_success_time" + _savedomainconf "Le_InstallCertSuccessTimeStr" "$(_time2str "$_installcert_success_time")" +} + +_ensure_install() { + _d="$1" + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _real_cert="$(_readdomainconf Le_RealCertPath)" + _real_key="$(_readdomainconf Le_RealKeyPath)" + _real_ca="$(_readdomainconf Le_RealCACertPath)" + _reload_cmd="$(_readdomainconf Le_ReloadCmd)" + _real_fullchain="$(_readdomainconf Le_RealFullChainPath)" + if [ -z "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then + return 0 + fi + + _installcert_success_time="$(_readdomainconf Le_InstallCertSuccessTime)" + if [ -z "$_installcert_success_time" ]; then + _debug "Le_InstallCertSuccessTime is empty, skip install retry check." + return 0 + fi + case "$_installcert_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_InstallCertSuccessTime or Le_CertCreateTime is not a number, skip install retry check." + return 0 + ;; + esac + + if [ "$_installcert_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful install, retrying install cert." + if _installcert "$_d" "$_real_cert" "$_real_key" "$_real_ca" "$_real_fullchain" "$_reload_cmd"; then + _info "Install cert retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Install cert retry failed." + return 1 + fi + + return 0 } __read_password() { @@ -6505,8 +6889,10 @@ _install_win_taskscheduler() { _info "$PROJECT_NAME will not save your password." _info "Please input your Windows password for: $(__green "$_myname")" _password="$(__read_password)" - #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null - echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null + #schtasks.exe /ST requires the HH:mm format, so the minute must be zero-padded (issue 4950) + _st_minute="$(printf "%02d" "$_randomminute")" + #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null + echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null echo } @@ -6775,7 +7161,7 @@ _deactivate() { _err "Cannot get new order for domain." return 1 fi - _authorizations_seg="$(echo "$response" | _egrep_o '"authorizations" *: *\[[^\]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." @@ -7069,6 +7455,10 @@ _installalias() { _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" fi _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then + #the completion file does nothing when sourced by a non-bash shell + _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\"" + fi _profile="$(_detect_profile)" if [ "$_profile" ]; then @@ -7186,6 +7576,11 @@ install() { _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" + if [ -f "$PROJECT_ENTRY.completion" ]; then + cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" + _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi + if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then _installalias "$_c_home" fi @@ -7263,6 +7658,7 @@ uninstall() { _uninstallalias rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." } @@ -7477,6 +7873,7 @@ Commands: -ccr, --create-csr Create CSR, professional use. --create-domain-key Create an domain private key, professional use. --update-account Update account info. + --update-account-key Rotate account key. --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 @@ -7572,6 +7969,7 @@ Parameters: --config-home Specifies the home dir to save all the configurations. --useragent Specifies the user agent string. it will be saved for future use too. -m, --email Specifies the account email, only valid for the '--install' and '--update-account' command. + Multiple emails can be given as a comma-separated list: 'a@example.com,b@example.com' --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. @@ -7623,7 +8021,7 @@ Parameters: --revoke-reason <0-10> The reason for revocation, can be used in conjunction with the '--revoke' command. See: $_REVOKE_WIKI - --password Add a password to exported pfx file. Use with --to-pkcs12. + --password Add a password to the exported pfx or pkcs8 file. Use with '--to-pkcs12' or '--to-pkcs8'. " @@ -7653,7 +8051,9 @@ installOnline() { cd "$PROJECT_NAME-$_branch" chmod +x $PROJECT_ENTRY - if ./$PROJECT_ENTRY --install "$@"; then + ./$PROJECT_ENTRY --install "$@" + _install_rc="$?" + if [ "$_install_rc" = "0" ]; then _info "Install success!" fi @@ -7661,6 +8061,9 @@ installOnline() { rm -rf "$PROJECT_NAME-$_branch" rm -f "$localname" + # Propagate the install result so a failed upgrade is not reported as + # success. https://github.com/acmesh-official/acme.sh/issues/6477 + exit "$_install_rc" ) } @@ -8023,6 +8426,9 @@ _process() { --update-account | --updateaccount) _CMD="updateaccount" ;; + --update-account-key | --updateaccountkey) + _CMD="updateaccountkey" + ;; --register-account | --registeraccount) _CMD="registeraccount" ;; @@ -8560,6 +8966,15 @@ _process() { _debug2 LE_WORKING_DIR "$LE_WORKING_DIR" + # --days and --valid-to are mutually exclusive by design: --valid-to pins + # the cert lifetime and the renewal time follows the expiry, so a + # creation-based --days schedule can not apply. + if [ "$_days" ] && [ "$_valid_to" ]; then + _err "--days can not be used together with --valid-to." + _err "With --valid-to, the renewal time is derived from the expiry time automatically." + return 1 + fi + if [ "$DEBUG" ]; then version if [ "$_server" ]; then @@ -8607,6 +9022,9 @@ _process() { updateaccount) updateaccount ;; + updateaccountkey) + updateaccountkey "$_accountkeylength" + ;; deactivateaccount) deactivateaccount ;; @@ -8626,7 +9044,7 @@ _process() { toPkcs "$_domain" "$_password" "$_ecc" ;; toPkcs8) - toPkcs8 "$_domain" "$_ecc" + toPkcs8 "$_domain" "$_password" "$_ecc" ;; createAccountKey) createAccountKey "$_accountkeylength" diff --git a/acme.sh.completion b/acme.sh.completion new file mode 100644 index 00000000..26cb88da --- /dev/null +++ b/acme.sh.completion @@ -0,0 +1,341 @@ +# Bash completion for acme.sh: https://github.com/acmesh-official/acme.sh +# +# "acme.sh --install" copies this file to the acme.sh home dir and wires +# it into acme.sh.env, so the completion is loaded automatically in new +# bash sessions after installation. +# +# To use it without installing acme.sh, source it from ~/.bashrc, or copy +# it to /usr/share/bash-completion/completions/acme.sh +# +# Zsh users can load it with: +# autoload -U +X bashcompinit && bashcompinit +# . /path/to/acme.sh.completion + +# This file may also be sourced by non-bash shells via acme.sh.env, +# so silently do nothing if the "complete" builtin is not available. +if ! command -v complete >/dev/null 2>&1; then + return 0 2>/dev/null || exit 0 +fi + +# Add each word of $1 that starts with $cur to COMPREPLY. +# The words are read line by line, so that candidates like a wildcard +# domain "*.example.com" are never glob-expanded against the cwd. +_acme_sh_add_matches() { + local _word + while read -r _word; do + [ -n "$_word" ] || continue + case "$_word" in + "$cur"*) COMPREPLY=("${COMPREPLY[@]}" "$_word") ;; + esac + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +_acme_sh_dirs() { + local _dir + while IFS= read -r _dir; do + [ -n "$_dir" ] || continue + COMPREPLY=("${COMPREPLY[@]}" "$_dir") + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +# Complete the domains that already have a cert: every directory in the +# config home that contains a ".conf" file ("_ecc" suffix stripped). +_acme_sh_domains() { + local _dir _name _domains="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _dir in "$_acme_conf_home"/*/; do + [ -d "$_dir" ] || continue + _name="${_dir%/}" + _name="${_name##*/}" + _name="${_name%_ecc}" + if [ -f "${_dir}${_name}.conf" ]; then + case " $_domains " in + *" $_name "*) ;; + *) _domains="$_domains $_name" ;; + esac + fi + done + _acme_sh_add_matches "$_domains" +} + +# Complete hook names from a subfolder of the acme.sh home dir. +# $1: subfolder (dnsapi/deploy/notify), $2: file name prefix or empty. +_acme_sh_hooks() { + local _file _hooks="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _file in "$_acme_home/$1/$2"*.sh; do + [ -f "$_file" ] || continue + _file="${_file##*/}" + _hooks="$_hooks ${_file%.sh}" + done + _acme_sh_add_matches "$_hooks" +} + +_acme_sh_completion() { + local cur prev _acme_home _acme_conf_home + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD - 1]}" + fi + _acme_home="${LE_WORKING_DIR:-$HOME/.acme.sh}" + _acme_conf_home="${LE_CONFIG_HOME:-$_acme_home}" + + # The first argument is the command. + if [ "$COMP_CWORD" -eq 1 ]; then + _acme_sh_add_matches " + --help + --version + --install + --install-online + --uninstall + --upgrade + --issue + --deploy + --sign-csr + --show-csr + --install-cert + --renew + --renew-all + --revoke + --remove + --list + --list-profiles + --info + --to-pkcs12 + --to-pkcs8 + --create-account-key + --create-domain-key + --create-csr + --deactivate + --update-account + --register-account + --deactivate-account + --make-dns-persist-value + --install-cronjob + --uninstall-cronjob + --cron + --set-notify + --set-default-ca + --set-default-chain + " + return 0 + fi + + # Complete the value of the previous option. + case "$prev" in + -d | --domain | --challenge-alias | --domain-alias) + _acme_sh_domains + return 0 + ;; + --dns) + # The dns hook argument is optional, keep completing options if the + # current word already looks like one. + case "$cur" in + -*) ;; + *) + _acme_sh_hooks "dnsapi" "dns_" + return 0 + ;; + esac + ;; + --deploy-hook) + _acme_sh_hooks "deploy" "" + return 0 + ;; + --notify-hook) + _acme_sh_hooks "notify" "" + return 0 + ;; + --server) + _acme_sh_add_matches "letsencrypt letsencrypt_test zerossl sslcom google google_test actalis" + return 0 + ;; + -k | --keylength | -ak | --accountkeylength) + _acme_sh_add_matches "2048 3072 4096 8192 ec-256 ec-384 ec-521" + return 0 + ;; + --debug) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + esac + ;; + --log) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --nginx) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --auto-upgrade | --always-force-new-domain-key) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1" + return 0 + ;; + esac + ;; + --log-level) + _acme_sh_add_matches "1 2" + return 0 + ;; + --syslog) + _acme_sh_add_matches "0 3 6 7" + return 0 + ;; + --notify-level) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + --notify-mode) + _acme_sh_add_matches "0 1" + return 0 + ;; + --revoke-reason) + _acme_sh_add_matches "0 1 2 3 4 5 6 7 8 9 10" + return 0 + ;; + --cert-file | --key-file | --ca-file | --fullchain-file | --csr | --accountconf | --accountkey | --ca-bundle | --openssl-bin) + _acme_sh_files + return 0 + ;; + -w | --webroot | --home | --cert-home | --config-home | --ca-path) + _acme_sh_dirs + return 0 + ;; + -m | --email | --password | --useragent | --days | --valid-from | --valid-to | --httpport | --tlsport | --local-address | --dnssleep | --pre-hook | --post-hook | --renew-hook | --reloadcmd | --extended-key-usage | -b | --branch | --notify-source | --eab-kid | --eab-hmac-key | --preferred-chain | --cert-profile | --certificate-profile | --dns-persist-ca-name | --dns-persist-days) + # These options take a free-form value, offer nothing. + return 0 + ;; + esac + + # Complete the parameters. + _acme_sh_add_matches " + --accountconf + --accountkey + --accountkeylength + --alpn + --always-force-new-domain-key + --apache + --auto-upgrade + --branch + --ca-bundle + --ca-file + --ca-path + --cert-file + --cert-home + --cert-profile + --challenge-alias + --config-home + --csr + --days + --debug + --deploy-hook + --dns + --dns-persist + --dns-persist-ca-name + --dns-persist-days + --dns-persist-wildcard + --dnssleep + --domain + --domain-alias + --eab-hmac-key + --eab-kid + --ecc + --email + --extended-key-usage + --force + --force-color + --fullchain-file + --home + --httpport + --insecure + --key-file + --keylength + --listen-v4 + --listen-v6 + --listraw + --local-address + --log + --log-level + --nginx + --no-color + --no-cron + --no-profile + --notify-hook + --notify-level + --notify-mode + --notify-source + --ocsp-must-staple + --openssl-bin + --output-insecure + --password + --post-hook + --pre-hook + --preferred-chain + --reloadcmd + --renew-hook + --revoke-reason + --server + --staging + --standalone + --stateless + --stop-renew-on-error + --syslog + --tlsport + --treat-skip-as-success + --use-wget + --useragent + --valid-from + --valid-to + --webroot + --yes-I-know-dns-manual-mode-enough-go-ahead-please + " + return 0 +} + +complete -F _acme_sh_completion acme.sh diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index e5381b61..16b622bb 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -194,7 +194,8 @@ __cpanel_parse_response() { printf("%s%s=%s\n", prefix, $2, $3); } }' | - sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' + sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' | + sed -e 's/^"//' -e 's/"$//' # YAML double-quotes values starting with '*' (wildcard subdomains) } # Load parameter by prefix+name - fallback to default if not set, and save to config diff --git a/deploy/docker.sh b/deploy/docker.sh index 264963ae..276172aa 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -3,6 +3,8 @@ #DEPLOY_DOCKER_CONTAINER_LABEL="xxxxxxx" #DEPLOY_DOCKER_CONTAINER_KEY_FILE="/path/to/key.pem" +#DEPLOY_DOCKER_CONTAINER_KEY_MODE="0640" +#DEPLOY_DOCKER_CONTAINER_KEY_OWNER="1000:1000" #DEPLOY_DOCKER_CONTAINER_CERT_FILE="/path/to/cert.pem" #DEPLOY_DOCKER_CONTAINER_CA_FILE="/path/to/ca.pem" #DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/path/to/fullchain.pem" @@ -71,6 +73,18 @@ docker_deploy() { _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_FILE "$DEPLOY_DOCKER_CONTAINER_KEY_FILE" fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + fi + + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_CERT_FILE _debug2 DEPLOY_DOCKER_CONTAINER_CERT_FILE "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then @@ -112,6 +126,20 @@ docker_deploy() { if ! _docker_cp "$_cid" "$_ckey" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then return 1 fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _info "Setting key file owner to $DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if ! _docker_exec "$_cid" chown "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change owner of key file in container" + return 1 + fi + fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _info "Setting key file mode to $DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if ! _docker_exec "$_cid" chmod "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change mode of key file in container" + return 1 + fi + fi fi if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then @@ -189,10 +217,22 @@ _docker_exec() { _debug2 cjson "$cjson" execid="$(echo "$cjson" | cut -d '"' -f 4)" _debug execid "$execid" - ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": false,\"Tty\": false}")" + #Detach:true is required for podman's docker-compatible API: with + #Detach:false it streams the command output on the connection, so the + #non-empty response was misread as an error (issue #4977). The real + #result is checked via the exec inspect ExitCode below instead. + ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": true,\"Tty\": false}")" _debug2 ejson "$ejson" - if [ "$ejson" ]; then - _err "$ejson" + _et=0 + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + while _contains "$ijson" "\"Running\":true" && [ "$_et" -lt 10 ]; do + sleep 1 + _et="$(_math "$_et" + 1)" + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + done + _debug2 ijson "$ijson" + if ! echo "$ijson" | _egrep_o "\"ExitCode\": *0[,}]" >/dev/null 2>&1; then + _err "docker exec error: $ijson" return 1 fi else diff --git a/deploy/gcore_cdn.sh b/deploy/gcore_cdn.sh index fd17cc25..93e9e32c 100644 --- a/deploy/gcore_cdn.sh +++ b/deploy/gcore_cdn.sh @@ -57,7 +57,7 @@ gcore_cdn_deploy() { _request="{\"username\":\"$Le_Deploy_gcore_cdn_username\",\"password\":\"$Le_Deploy_gcore_cdn_password\"}" _debug _request "$_request" export _H1="Content-Type:application/json" - _response=$(_post "$_request" "https://api.gcore.com/auth/jwt/login") + _response=$(_post "$_request" "https://api.gcore.com/iam/auth/jwt/login") _debug _response "$_response" _regex=".*\"access\":\"\([-._0-9A-Za-z]*\)\".*$" _debug _regex "$_regex" diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 19509e3b..9736e6ff 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -43,7 +43,8 @@ # needing to reload HAProxy. Default is "no". # # Require the socat binary. DEPLOY_HAPROXY_STATS_SOCKET variable uses the socat -# address format. +# address format. The certificate can be deployed to a comma separated ',' list +# of hosts ("TCP4:10.0.0.1:1999,TCP4:10.0.0.2:1999") # # export DEPLOY_HAPROXY_MASTER_CLI="UNIX:/run/haproxy-master.sock" # @@ -193,7 +194,6 @@ haproxy_deploy() { _issuer="${_pem}.issuer" _ocsp="${_pem}.ocsp" _reload="${Le_Deploy_haproxy_reload}" - _statssock="${Le_Deploy_haproxy_stats_socket}" _info "Deploying PEM file" # Create a temporary PEM file @@ -272,12 +272,18 @@ haproxy_deploy() { _cafile_argument="" fi _debug _cafile_argument "${_cafile_argument}" - # if OpenSSL/LibreSSL is v1.1 or above, the format for the -header option has changed + # OpenSSL 1.1+ expects -header Host=value (one argument), while + # LibreSSL keeps the old two-argument form -header Host value at any + # version (3.x/4.x), so it must be detected by name, not by number. + _openssl_name=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f1) _openssl_version=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f2) + _debug _openssl_name "${_openssl_name}" _debug _openssl_version "${_openssl_version}" _openssl_major=$(echo "${_openssl_version}" | cut -d '.' -f1) _openssl_minor=$(echo "${_openssl_version}" | cut -d '.' -f2) - if [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then + if [ "${_openssl_name}" = "LibreSSL" ]; then + _header_sep=" " + elif [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then _header_sep="=" else _header_sep=" " @@ -327,62 +333,67 @@ haproxy_deploy() { # Update certificate over HAProxy stats socket or master CLI. if _exists socat; then - # look for the certificate on the stats socket, to chose between updating or creating one - _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" - _debug _socat_cert_cmd "${_socat_cert_cmd}" - eval "${_socat_cert_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _newcert="1" - _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." - # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. - _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" - _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" - eval "${_socat_crtlist_show_cmd}" + IFS=',' + for _statssock in ${Le_Deploy_haproxy_stats_socket}; do + # look for the certificate on the stats socket, to choose between updating or creating one + _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" + _debug _socat_cert_cmd "${_socat_cert_cmd}" + eval "${_socat_cert_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then - _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" - return "${_ret}" + _newcert="1" + _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." + # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. + _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" + _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" + eval "${_socat_crtlist_show_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" + return "${_ret}" + fi + # create a new certificate + _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" + _debug _socat_new_cmd "${_socat_new_cmd}" + eval "${_socat_new_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't create '${_pem}' in haproxy" + return "${_ret}" + fi + else + _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." fi - # create a new certificate - _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" - _debug _socat_new_cmd "${_socat_new_cmd}" - eval "${_socat_new_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Couldn't create '${_pem}' in haproxy" - return "${_ret}" - fi - else - _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." - fi - _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" - _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" - eval "${_socat_cert_set_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't update '${_pem}' in haproxy" - return "${_ret}" - fi - _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" - _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" - eval "${_socat_cert_commit_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't commit '${_pem}' in haproxy" - return ${_ret} - fi - if [ "${_newcert}" = "1" ]; then - # if this is a new certificate, it needs to be inserted into the crt-list` - _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" - _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" - eval "${_socat_cert_add_cmd}" + # printf %b, not "echo -e": dash's echo has no -e and sends a literal "-e " to the socket. + # "Transaction updated" is replied instead of "created" when an uncommitted transaction exists. + _socat_cert_set_cmd="printf '%b\n' '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -qE 'Transaction (created|updated)'" + _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" + eval "${_socat_cert_set_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then _err "Can't update '${_pem}' in haproxy" return "${_ret}" fi - fi + _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" + _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" + eval "${_socat_cert_commit_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't commit '${_pem}' in haproxy" + return ${_ret} + fi + if [ "${_newcert}" = "1" ]; then + # if this is a new certificate, it needs to be inserted into the crt-list` + _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" + _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" + eval "${_socat_cert_add_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't update '${_pem}' in haproxy" + return "${_ret}" + fi + fi + done else _err "'socat' is not available, couldn't update over ${_socketname}" fi diff --git a/deploy/panos.sh b/deploy/panos.sh index 00badffc..fcfd6fb5 100644 --- a/deploy/panos.sh +++ b/deploy/panos.sh @@ -296,9 +296,20 @@ panos_deploy() { _err "Unable to generate an API key. The user and pass may be invalid or not authorized to generate a new key. Please check the PANOS_USER and PANOS_PASS credentials and try again" return 1 else - deployer cert - deployer key - deployer commit + # A commit of a failed import would leave a mismatched cert/key pair + # on the firewall and can lock the admin out of the management + # interface, see https://github.com/acmesh-official/acme.sh/issues/4716 + if ! deployer cert; then + _err "Cert import failed. Aborting without committing." + return 1 + fi + if ! deployer key; then + _err "Key import failed. Aborting without committing. Warning: the firewall now has an uncommitted mismatched cert/key pair in its candidate config." + return 1 + fi + if ! deployer commit; then + return 1 + fi if [ "$_panos_template_stack" ]; then # try to get job status for 20 times in 30 sec interval i=0 diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 848380a5..82b0382c 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -170,10 +170,16 @@ ssh_deploy() { _info "Required commands batched and sent in single call to remote host" fi + _returnCode=0 _deploy_ssh_servers="$DEPLOY_SSH_SERVER" for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do - _ssh_deploy + if ! _ssh_deploy; then + # in case of an error, remember it, but keep going for the remaining servers + _returnCode=1 + fi done + + return $_returnCode } _ssh_deploy() { diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index e28a4036..502bc59b 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -276,7 +276,7 @@ synology_dsm_deploy() { if [ -n "$error_code" ]; then if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then _cleardeployconf SYNO_DEVICE_ID - _err "Failed to authenticate with SYNO_DEVICE_ID (may expired or invalid), please try again in a new terminal window." + _err "Failed to authenticate with SYNO_DEVICE_ID (may be expired or invalid), please try again in a new terminal window." elif [ "$error_code" == "404" ]; then _err "Failed to authenticate with provided 2FA-OTP code, please try again in a new terminal window." elif [ "$error_code" == "406" ]; then @@ -322,8 +322,8 @@ synology_dsm_deploy() { _savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN" _savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME" else - _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" - _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" + _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" "base64" + _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" "base64" _savedeployconf SYNO_DEVICE_ID "$SYNO_DEVICE_ID" _savedeployconf SYNO_DEVICE_NAME "$SYNO_DEVICE_NAME" fi diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index f3f50233..a21f8ef0 100755 --- a/dnsapi/dns_acmedns.sh +++ b/dnsapi/dns_acmedns.sh @@ -37,6 +37,16 @@ dns_acmedns_add() { ACMEDNS_PASSWORD="${ACMEDNS_PASSWORD:-$(_readdomainconf ACMEDNS_PASSWORD)}" ACMEDNS_SUBDOMAIN="${ACMEDNS_SUBDOMAIN:-$(_readdomainconf ACMEDNS_SUBDOMAIN)}" + #for compatibility: old versions stored ACMEDNS_UPDATE_URL in the account + #conf (issue 3899). Do not clear it here: it must stay available for the + #other domains that have not migrated to their domain conf yet. + if [ -z "$ACMEDNS_BASE_URL" ]; then + _acmedns_update_url="$(_readaccountconf_mutable ACMEDNS_UPDATE_URL)" + if [ "$_acmedns_update_url" ]; then + ACMEDNS_BASE_URL="$(echo "$_acmedns_update_url" | sed 's#/update$##')" + fi + fi + if [ "$ACMEDNS_BASE_URL" = "" ]; then ACMEDNS_BASE_URL="https://auth.acme-dns.io" fi @@ -71,7 +81,7 @@ dns_acmedns_add() { data="{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\", \"txt\": \"$txtvalue\"}" _debug data "$data" - response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST")" + response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST" "application/json")" _debug response "$response" if ! echo "$response" | grep "\"$txtvalue\"" >/dev/null; then diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 90196c69..62e54e0c 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -69,8 +69,8 @@ _ali_rest() { ign="$2" mtd="${3:-GET}" - signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _url_encode upper-hex)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) - signature=$(printf "%s" "$signature" | _url_encode upper-hex) + signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _ali_urlencode_upper)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) + signature=$(printf "%s" "$signature" | _ali_urlencode_upper) url="$endpoint?Signature=$signature" if [ "$mtd" = "GET" ]; then @@ -96,6 +96,20 @@ _ali_rest() { fi } +# stdin stdout +# The Aliyun signature requires percent-encoding with upper-case hex. +# Do not use "_url_encode upper-hex" here: this file is also bundled by +# third parties (e.g. Proxmox VE proxmox-acme) whose older copies of the +# acme.sh function library ignore the upper-hex argument and output +# lower-case hex, which invalidates the signature. +# https://github.com/acmesh-official/acme.sh/issues/6272 +_ali_urlencode_upper() { + { + _url_encode + echo + } | sed 's/%a/%A/g;s/%b/%B/g;s/%c/%C/g;s/%d/%D/g;s/%e/%E/g;s/%f/%F/g;s/%\(.\)a/%\1A/g;s/%\(.\)b/%\1B/g;s/%\(.\)c/%\1C/g;s/%\(.\)d/%\1D/g;s/%\(.\)e/%\1E/g;s/%\(.\)f/%\1F/g' +} + _ali_nonce() { if [ "$ACME_OPENSSL_BIN" ]; then "$ACME_OPENSSL_BIN" rand -hex 16 2>/dev/null && return 0 diff --git a/dnsapi/dns_autodns.sh b/dnsapi/dns_autodns.sh index ce566978..e26d699b 100644 --- a/dnsapi/dns_autodns.sh +++ b/dnsapi/dns_autodns.sh @@ -139,12 +139,21 @@ _get_autodns_zone() { return 1 } +# Escape the XML special characters (& < > ' ") so that credentials +# containing them do not break the request document (issue 5317). +_autodns_xml_encode() { + sed "s/&/\&/g;s//\>/g;s/'/\'/g;s/\"/\"/g" +} + _build_request_auth_xml() { + _autodns_user_xml="$(printf "%s" "$AUTODNS_USER" | _autodns_xml_encode)" + _autodns_password_xml="$(printf "%s" "$AUTODNS_PASSWORD" | _autodns_xml_encode)" + _autodns_context_xml="$(printf "%s" "$AUTODNS_CONTEXT" | _autodns_xml_encode)" printf " %s %s %s - " "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT" + " "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml" } # Arguments: diff --git a/dnsapi/dns_calrissia.sh b/dnsapi/dns_calrissia.sh new file mode 100644 index 00000000..01ca3092 --- /dev/null +++ b/dnsapi/dns_calrissia.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_calrissia_info='Calrissia.be DNS API +Site: calrissia.be +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_calrissia +Options: + CALRISSIA_TOKEN Personal access token +Issues: github.com/acmesh-official/acme.sh/issues/6809 +Author: Ward Hus +' + +CALRISSIA_API="https://my.calrissia.com/api" + +dns_calrissia_add() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + _info "Adding TXT record for $fulldomain" + + _body="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120,\"prio\":0}" + _response="$(_calrissia_request POST "/domain/$_domain_id/record" "$_body")" + + if ! _contains "$_response" '"id"'; then + _err "Failed to create TXT record: $_response" + return 1 + fi + + return 0 +} + +dns_calrissia_rm() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + + # Look the record up from the API instead of relying on local state. + # The record list is embedded in the domain object. + _response="$(_calrissia_request GET "/domain/$_domain_id")" + _debug2 "Response: $_response" + + # Split the record objects onto separate lines, then match on both the + # subdomain name and the TXT value to find the record id to delete. + _record_id="$(printf "%s" "$_response" | + tr '{}' '\n' | + grep "\"name\" *: *\"$_sub_domain\"" | + grep "\"content\" *: *\"$txtvalue\"" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -z "$_record_id" ]; then + _info "No matching TXT record found for $fulldomain; nothing to remove" + return 0 + fi + + _info "Removing TXT record id=$_record_id from domain id=$_domain_id" + if ! _response="$(_calrissia_request DELETE "/domain/$_domain_id/record/$_record_id")" || _contains "$_response" '"error"'; then + _err "Failed to remove TXT record: $_response" + return 1 + fi + return 0 +} + +#################### +# Private helpers # +#################### + +_calrissia_load_token() { + CALRISSIA_TOKEN="${CALRISSIA_TOKEN:-$(_readaccountconf_mutable CALRISSIA_TOKEN)}" + if [ -z "$CALRISSIA_TOKEN" ]; then + _err "CALRISSIA_TOKEN is not set. Generate one at https://identity.calrissia.com under API Keys." + return 1 + fi + _saveaccountconf_mutable CALRISSIA_TOKEN "$CALRISSIA_TOKEN" +} + +# Sets _domain, _domain_id, _sub_domain for a given FQDN. +_calrissia_get_root() { + _fqdn="$1" + + i=1 + while true; do + _candidate="$(printf "%s" "$_fqdn" | cut -d . -f "$i"-)" + [ -z "$_candidate" ] && return 1 + + _debug "Trying root domain: $_candidate" + _response="$(_calrissia_request GET "/domain?full_domain_name=$_candidate")" + _debug2 "Response: $_response" + + _domain_id="$(printf "%s" "$_response" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -n "$_domain_id" ]; then + if [ "$i" = "1" ]; then + # The FQDN itself is the zone apex, e.g. a challenge-alias domain. + _sub_domain="" + else + _sub_domain="$(printf "%s" "$_fqdn" | cut -d . -f "1-$((i - 1))")" + fi + _domain="$_candidate" + return 0 + fi + + i=$((i + 1)) + done +} + +_calrissia_request() { + _method="$1" + _path="$2" + _body="$3" + export _H1="Authorization: Bearer $CALRISSIA_TOKEN" + export _H2="Accept: application/json" + if [ "$_method" = "GET" ]; then + _get "$CALRISSIA_API$_path" + else + _post "$_body" "$CALRISSIA_API$_path" "" "$_method" "application/json" + fi +} diff --git a/dnsapi/dns_cn.sh b/dnsapi/dns_cn.sh index 79698e88..e06a2be6 100644 --- a/dnsapi/dns_cn.sh +++ b/dnsapi/dns_cn.sh @@ -15,7 +15,8 @@ CN_API="https://beta.api.core-networks.de" ######## Public functions ##################### dns_cn_add() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then @@ -58,7 +59,8 @@ dns_cn_add() { } dns_cn_rm() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then diff --git a/dnsapi/dns_da.sh b/dnsapi/dns_da.sh index 36251b05..d9cf6247 100755 --- a/dnsapi/dns_da.sh +++ b/dnsapi/dns_da.sh @@ -4,7 +4,7 @@ dns_da_info='DirectAdmin Server API Site: DirectAdmin.com/api.php Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_da Options: - DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443" + DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443". Special characters in the user/password must be percent-encoded, e.g. "@" -> "%40". DA_Api_Insecure Insecure TLS. 0: check for cert validity, 1: always accept Issues: github.com/TigerP/acme.sh/issues ' diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index 275babea..e5e4809a 100644 --- a/dnsapi/dns_desec.sh +++ b/dnsapi/dns_desec.sh @@ -4,7 +4,7 @@ dns_desec_info='deSEC.io Site: desec.readthedocs.io/en/latest/ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_desec Options: - DDNSS_Token API Token + DEDYN_TOKEN API Token Issues: github.com/acmesh-official/acme.sh/issues/2180 Author: Zheng Qian ' diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index ec3b07a4..6b10891c 100644 --- a/dnsapi/dns_dnsexit.sh +++ b/dnsapi/dns_dnsexit.sh @@ -5,14 +5,11 @@ Site: DNSExit.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsexit Options: DNSEXIT_API_KEY API Key - DNSEXIT_AUTH_USER Username - DNSEXIT_AUTH_PASS Password Issues: github.com/acmesh-official/acme.sh/issues/4719 Author: Samuel Jimenez ' DNSEXIT_API_URL="https://api.dnsexit.com/dns/" -DNSEXIT_HOSTS_URL="https://update.dnsexit.com/ipupdate/hosts.jsp" ######## Public functions ##################### #Usage: dns_dnsexit_add _acme-challenge.*.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" @@ -28,20 +25,7 @@ dns_dnsexit_add() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"add\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":0,\"overwrite\":false}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op add ',"ttl":0,"overwrite":false' } #Usage: fulldomain txtvalue @@ -58,54 +42,43 @@ dns_dnsexit_rm() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - _err "$response" - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"delete\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op delete '' } #################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -_get_root() { - domain=$1 +# The legacy zone-detection endpoint (update.dnsexit.com/ipupdate/hosts.jsp) +# was shut down by DNSExit and now returns 503, and the JSON API offers no +# zone-list call. So find the root zone by attempting the actual operation at +# each domain level: the API answers "code":0 only when the domain matches a +# zone of the account. https://github.com/acmesh-official/acme.sh/issues/6914 +#Usage: _dnsexit_zone_op +_dnsexit_zone_op() { + _op="$1" + _extra="$2" i=1 while true; do - _domain=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$_domain" + _domain=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100) + _debug _domain "$_domain" if [ -z "$_domain" ]; then + _err "Could not find the root zone of $fulldomain in your DNSExit account" return 1 fi - _debug login "$DNSEXIT_AUTH_USER" - _debug password "$DNSEXIT_AUTH_PASS" - _debug domain "$_domain" + _sub_domain="$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")" + if [ "$_sub_domain" = "$fulldomain" ]; then + _sub_domain="" + fi + _debug _sub_domain "$_sub_domain" - _dnsexit_http "login=$DNSEXIT_AUTH_USER&password=$DNSEXIT_AUTH_PASS&domain=$_domain" - - if _contains "$response" "0=$_domain"; then - _sub_domain="$(echo "$fulldomain" | sed "s/\\.$_domain\$//")" - return 0 - else - _debug "Go to next level of $_domain" + if _dnsexit_rest "{\"domain\":\"$_domain\",\"$_op\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"$_extra}}"; then + if _contains "$response" "\"code\":0" || _contains "$response" "\"code\": 0"; then + _debug2 _response "$response" + return 0 + fi + _debug "Zone $_domain was not accepted, trying the next level" "$response" fi i=$(_math "$i" + 1) done - - return 1 } _dnsexit_rest() { @@ -136,27 +109,7 @@ _dnsexit_rest() { return 0 } -_dnsexit_http() { - m=GET - param="$1" - _debug param "$param" - _debug get "$DNSEXIT_HOSTS_URL?$param" - - response="$(_get "$DNSEXIT_HOSTS_URL?$param")" - - _debug response "$response" - - if [ "$?" != "0" ]; then - _err "Error $param" - return 1 - fi - - _debug2 response "$response" - return 0 -} - get_account_info() { - DNSEXIT_API_KEY="${DNSEXIT_API_KEY:-$(_readaccountconf_mutable DNSEXIT_API_KEY)}" if test -z "$DNSEXIT_API_KEY"; then DNSEXIT_API_KEY='' @@ -166,23 +119,5 @@ get_account_info() { _saveaccountconf_mutable DNSEXIT_API_KEY "$DNSEXIT_API_KEY" - DNSEXIT_AUTH_USER="${DNSEXIT_AUTH_USER:-$(_readaccountconf_mutable DNSEXIT_AUTH_USER)}" - if test -z "$DNSEXIT_AUTH_USER"; then - DNSEXIT_AUTH_USER="" - _err 'DNSEXIT_AUTH_USER was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_USER "$DNSEXIT_AUTH_USER" - - DNSEXIT_AUTH_PASS="${DNSEXIT_AUTH_PASS:-$(_readaccountconf_mutable DNSEXIT_AUTH_PASS)}" - if test -z "$DNSEXIT_AUTH_PASS"; then - DNSEXIT_AUTH_PASS="" - _err 'DNSEXIT_AUTH_PASS was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_PASS "$DNSEXIT_AUTH_PASS" - return 0 } diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index 10a3821d..e262239a 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -5,6 +5,7 @@ Site: DNSimple.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple Options: DNSimple_OAUTH_TOKEN OAuth Token + DNSimple_ACCOUNT_ID Account ID. Optional, only needed when the token can access multiple accounts. Issues: github.com/pho3nixf1re/acme.sh/issues ' @@ -28,7 +29,7 @@ dns_dnsimple_add() { _saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -57,7 +58,7 @@ dns_dnsimple_rm() { fulldomain=$1 if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -122,13 +123,16 @@ _get_root() { # returns _account_id _get_account_id() { - _debug "retrive account id" - if ! _dnsimple_rest GET "whoami"; then - return 1 + DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf DNSimple_ACCOUNT_ID)}" + if [ "$DNSimple_ACCOUNT_ID" ]; then + _saveaccountconf DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" + _account_id="$DNSimple_ACCOUNT_ID" + _debug _account_id "$_account_id" + return 0 fi - if _contains "$response" "\"account\":null"; then - _err "no account associated with this token" + _debug "retrieve account id" + if ! _dnsimple_rest GET "whoami"; then return 1 fi @@ -137,7 +141,25 @@ _get_account_id() { return 1 fi + if _contains "$response" "\"account\":null"; then + # the whoami of a user token (dnsimple_u_*) carries no account, + # so list the accounts the token can access instead + # https://github.com/acmesh-official/acme.sh/issues/6491 + if ! _dnsimple_rest GET "accounts"; then + return 1 + fi + fi + _account_id=$(printf "%s" "$response" | _egrep_o "\"id\":[^,]*,\"email\":" | cut -d: -f2 | cut -d, -f1) + if [ -z "$_account_id" ]; then + _err "no account associated with this token" + return 1 + fi + if [ "$(echo "$_account_id" | wc -l)" -gt 1 ]; then + _err "The token has access to multiple accounts, please pick one and set it explicitly:" + _err "export DNSimple_ACCOUNT_ID=" + return 1 + fi _debug _account_id "$_account_id" return 0 diff --git a/dnsapi/dns_edgedns.sh b/dnsapi/dns_edgedns.sh index e88a1483..9ff1cc06 100755 --- a/dnsapi/dns_edgedns.sh +++ b/dnsapi/dns_edgedns.sh @@ -363,17 +363,12 @@ _edgedns_rest() { _edgedns_eg_timestamp() { _debug "Generating signature Timestamp" - _debug3 "Retriving ntp time" - _timeheaders="$(_get "https://www.ntp.org" "onlyheader")" - _debug3 "_timeheaders" "$_timeheaders" - _ntpdate="$(echo "$_timeheaders" | grep -i "Date:" | _head_n 1 | cut -d ':' -f 2- | tr -d "\r\n")" - _debug3 "_ntpdate" "$_ntpdate" - _ntpdate="$(echo "${_ntpdate}" | sed -e 's/^[[:space:]]*//')" - _debug3 "_NTPDATE" "$_ntpdate" - _ntptime="$(echo "${_ntpdate}" | _head_n 1 | cut -d " " -f 5 | tr -d "\r\n")" - _debug3 "_ntptime" "$_ntptime" - _eg_timestamp=$(date -u "+%Y%m%dT") - _eg_timestamp="$(printf "%s%s+0000" "$_eg_timestamp" "$_ntptime")" + #Akamai accepts a clock skew of +/-30s, so use the system clock directly. + #The previous code fetched the Date header from www.ntp.org, which is not + #a reliable time source (it served a wrong time for hours, issue 3973), + #cost an extra https round-trip for every API request, and combined the + #remote time of day with the LOCAL date, breaking around UTC midnight. + _eg_timestamp="$(date -u "+%Y%m%dT%H:%M:%S+0000")" _debug "_eg_timestamp" "$_eg_timestamp" } diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index ee66ee19..c92bdfa2 100755 --- a/dnsapi/dns_gd.sh +++ b/dnsapi/dns_gd.sh @@ -69,7 +69,12 @@ dns_gd_add() { return 1 fi - if ! _contains "$response" "$txtvalue"; then + if _contains "$response" "UNKNOWN_DOMAIN"; then + # GoDaddy sometimes returns UNKNOWN_DOMAIN when reading a record back even + # though the PUT above succeeded; skip the local readback check and let + # acme.sh's own DNS propagation check verify the record was published. + _info "GoDaddy API won't allow reading the record back; skipping local verification." + elif ! _contains "$response" "$txtvalue"; then _err "TXT record '${txtvalue}' for '${fulldomain}', value wasn't set!" return 1 fi @@ -145,8 +150,8 @@ dns_gd_rm() { # _domain=domain.com _get_root() { domain=$1 - i=2 - p=1 + i=1 + p=0 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) if [ -z "$h" ]; then @@ -154,17 +159,41 @@ _get_root() { return 1 fi - if ! _gd_rest GET "domains/$h"; then - return 1 + # The record name is whatever precedes the candidate zone. Do not assume + # _acme-challenge here: with DNS alias mode it can be any name, and the + # record may even sit at the zone apex (name "@"). + if [ "$p" = "0" ]; then + _probe_sub="@" + else + _probe_sub=$(printf "%s" "$domain" | cut -d . -f 1-"$p") fi - if _contains "$response" '"code":"NOT_FOUND"'; then - _debug "$h not found" - else - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + # Probe with the records endpoint instead of "GET domains/$h": since + # 2024-05 GoDaddy rejects the domain details call for accounts with + # fewer than 10 domains, while record-level calls keep working. + # https://github.com/acmesh-official/acme.sh/issues/4487 + if ! _gd_rest GET "domains/$h/records/TXT/$_probe_sub"; then + return 1 + fi + if _startswith "$response" '\['; then + _sub_domain="$_probe_sub" _domain="$h" return 0 fi + + # Some accounts get UNKNOWN_DOMAIN when reading records of a valid zone + # even though writes succeed (see issue #6517); fall back to the domain + # details call for them. + if ! _gd_rest GET "domains/$h"; then + return 1 + fi + if _contains "$response" '"domainId"'; then + _sub_domain="$_probe_sub" + _domain="$h" + return 0 + fi + + _debug "$h not found" p="$i" i=$(_math "$i" + 1) done diff --git a/dnsapi/dns_hostingde.sh b/dnsapi/dns_hostingde.sh index 41ccab2b..ed675b42 100644 --- a/dnsapi/dns_hostingde.sh +++ b/dnsapi/dns_hostingde.sh @@ -40,6 +40,11 @@ _hostingde_apiKey() { return 1 fi + # The endpoint is the base URL only; the api path is appended below. + # hosting.de's own docs show the full api URL, so strip it if pasted in. + # https://github.com/acmesh-official/acme.sh/issues/6896 + HOSTINGDE_ENDPOINT="$(echo "$HOSTINGDE_ENDPOINT" | sed 's|/api/dns/v1/json||; s|/*$||')" + _saveaccountconf_mutable HOSTINGDE_APIKEY "$HOSTINGDE_APIKEY" _saveaccountconf_mutable HOSTINGDE_ENDPOINT "$HOSTINGDE_ENDPOINT" } diff --git a/dnsapi/dns_huaweicloud.sh b/dnsapi/dns_huaweicloud.sh index ee2d2b8e..83fcc625 100644 --- a/dnsapi/dns_huaweicloud.sh +++ b/dnsapi/dns_huaweicloud.sh @@ -7,11 +7,11 @@ Options: HUAWEICLOUD_Username Username HUAWEICLOUD_Password Password HUAWEICLOUD_DomainName DomainName + HUAWEICLOUD_Region Region. E.g. "cn-north-4". Optional, defaults to "ap-southeast-1". Issues: github.com/acmesh-official/acme.sh/issues/3265 ' iam_api="https://iam.myhuaweicloud.com" -dns_api="https://dns.ap-southeast-1.myhuaweicloud.com" # Should work ######## Public functions ##################### @@ -30,6 +30,7 @@ dns_huaweicloud_add() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -37,8 +38,11 @@ dns_huaweicloud_add() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -65,6 +69,9 @@ dns_huaweicloud_add() { _saveaccountconf_mutable HUAWEICLOUD_Username "${HUAWEICLOUD_Username}" _saveaccountconf_mutable HUAWEICLOUD_Password "${HUAWEICLOUD_Password}" _saveaccountconf_mutable HUAWEICLOUD_DomainName "${HUAWEICLOUD_DomainName}" + if [ -n "${HUAWEICLOUD_Region}" ]; then + _saveaccountconf_mutable HUAWEICLOUD_Region "${HUAWEICLOUD_Region}" + fi return 0 } @@ -81,6 +88,7 @@ dns_huaweicloud_rm() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -88,8 +96,11 @@ dns_huaweicloud_rm() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -298,6 +309,7 @@ _get_token() { _username=$1 _password=$2 _domain_name=$3 + _region_name=$4 _debug "Getting Token" body="{ @@ -318,7 +330,7 @@ _get_token() { }, \"scope\": { \"project\": { - \"name\": \"ap-southeast-1\" + \"name\": \"${_region_name}\" } } } diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh index dba23846..460d4d28 100755 --- a/dnsapi/dns_inwx.sh +++ b/dnsapi/dns_inwx.sh @@ -307,13 +307,18 @@ _get_root() { return 1 fi - if _contains "$response" "$h"; then + # Anchor the match to the XML tag and escape dots so $h is compared + # literally: _contains uses grep, which treats "$h" as a regex, and a + # bare "g.berlight.de" would match "berlight.de" (the 'g' from + # "" plus '.' matching '>'). See issue #5129. + _hregex=$(printf "%s" "$h" | sed 's/\./\\./g') + if _contains "$response" "$_hregex"; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$h" return 0 fi # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode, - # encode each zone name via _idn() and compare — no python dependency. + # encode each zone name via _idn() and compare -- no python dependency. if _contains "$h" "xn--"; then _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' | sed 's/<[^>]*>//g' | while IFS= read -r _z; do diff --git a/dnsapi/dns_laodc.sh b/dnsapi/dns_laodc.sh new file mode 100644 index 00000000..9f2103b3 --- /dev/null +++ b/dnsapi/dns_laodc.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_laodc_info='LaoDC DNS API Server +Site: laodc.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_laodc +Options: + LaoDC_Key API Key +Issues: github.com/acmesh-official/acme.sh/issues/6973 +Author: @laodc +' + +# Usage: +# export LaoDC_Key="your-api-key" +# acme.sh --issue --dns dns_laodc -d example.la -d *.example.la --dnssleep 120 +# +# The credentials will be saved in ~/.acme.sh/account.conf + +LAODC_VER="0.1.2" +LAODC_API_ENDPOINT="https://dns.laodc.com/v1" + +######## Public functions ##################### + +# Usage: dns_laodc_add _acme-challenge.example.la ZPXvna6tBhq7XQMH7_t2WC2sg0F-BdmtmmpUJiK6Ho +dns_laodc_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using LaoDC DNS API" + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Adding acme record" + if _laodc_api "POST" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "201" ]; then + _info "Added, OK" + return 0 + else + _err "Add TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Add TXT record error." + return 1 +} + +dns_laodc_rm() { + fulldomain=$1 + txtvalue=$2 + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _root_domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Deleting acme record" + if _laodc_api "DELETE" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "204" ]; then + _info "Deleted, OK" + return 0 + else + _err "Delete TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Delete TXT record error." + return 1 +} + +#################### Private functions below ################################## +# _acme-challenge.www.domain.com +# returns +# _domain=domain.com +# _sub_domain=www +_get_root() { + fqdn=$1 + p=1 + i=1 + + while true; do + h=$(printf "%s" "$fqdn" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 # not valid domain + fi + + # Check API if domain exists + if _laodc_api "GET" "$h"; then + if [ "$_code" = "200" ]; then + _domain="$h" + + # DNS alias mode - @ is alias for fqdn + _sub_domain=$(printf "%s" "$fqdn" | cut -d . -f 1-"$p") + if [ "$i" = "1" ]; then + _sub_domain="@" + fi + + return 0 + fi + fi + + p="$i" + i=$(_math "$i" + 1) + done + + return 1 +} + +_laodc_validate_key() { + LaoDC_Key="${LaoDC_Key:-$(_readaccountconf_mutable LaoDC_Key)}" + + if [ -z "$LaoDC_Key" ]; then + LaoDC_Key="" + _err "You didn't specify a LaoDC API Key yet." + _err "Please export LaoDC_Key and try again." + return 1 + fi + + # Save the api key to the account conf file. + _saveaccountconf_mutable LaoDC_Key "$LaoDC_Key" +} + +_laodc_api() { + method=$1 + domain=$2 + subdomain=$3 + value=$4 + + export _H1="Content-Type: application/json" + export _H2="User-Agent: acme.sh/$VER laodc-dns-acme-sh/$LAODC_VER" + export _H3="Authorization: Bearer $LaoDC_Key" + + case $method in + GET) + if [ -n "$subdomain" ]; then + response="$(_get "$LAODC_API_ENDPOINT/$domain/$subdomain?type=TXT")" + else + response="$(_get "$LAODC_API_ENDPOINT/$domain")" + fi + ;; + POST) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\", \"ttl\": \"60\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "POST" "application/json")" + ;; + DELETE) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "DELETE" "application/json")" + ;; + esac + + _ret=$? + + # Unset immediately after request to prevent leaks + export _H1= + export _H2= + export _H3= + + if [ "$_ret" != "0" ]; then + _err "Error $domain" + return 1 + fi + + responseHeaders="$(cat "$HTTP_HEADER")" + + if echo "$responseHeaders" | grep -i "Content-Type: *application/json" >/dev/null 2>&1; then + response="$(echo "$response" | _json_decode | _normalizeJson)" + fi + + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + + _debug "http response code $_code" + _debug response "$response" + return 0 +} diff --git a/dnsapi/dns_muumuu.sh b/dnsapi/dns_muumuu.sh new file mode 100755 index 00000000..8ef0b8c8 --- /dev/null +++ b/dnsapi/dns_muumuu.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_muumuu_info='muumuu-domain.com +Site: muumuu-domain.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_muumuu +Options: + MUUMUU_PAT Personal Access Token (scopes: domains:read, dns:read, dns:write) +Issues: github.com/acmesh-official/acme.sh/issues/7011 +' + +MUUMUU_API="https://muumuu-domain.com/api/v2" + +######## Public functions ##################### + +dns_muumuu_add() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + _err "Please create a Personal Access Token at https://muumuu-domain.com" + _err "with scopes: domains:read, dns:read, dns:write" + return 1 + fi + _saveaccountconf_mutable MUUMUU_PAT "$MUUMUU_PAT" + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Adding TXT record for ${fulldomain}" + body="{\"fqdn\":\"${fulldomain}.\",\"type\":\"TXT\",\"value\":\"${txtvalue}\",\"ttl\":3600}" + if _muumuu_rest POST "/me/domains/${_domain_id}/dns-records" "$body"; then + if [ "$_muumuu_code" = "201" ]; then + _info "TXT record added successfully" + return 0 + fi + fi + + _err "Failed to add TXT record (HTTP ${_muumuu_code})" + return 1 +} + +dns_muumuu_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + return 1 + fi + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + + _info "Looking up TXT record for ${fulldomain}" + if ! _muumuu_rest GET "/me/domains/${_domain_id}/dns-records?type=TXT&fqdn=${fulldomain}."; then + _err "Failed to list TXT records" + return 1 + fi + + record_id=$(echo "$response" | _egrep_o "\"id\":[0-9]+[^}]*\"value\":\"${txtvalue}\"" | _egrep_o "\"id\":[0-9]+" | _head_n 1 | cut -d: -f2) + if [ -z "$record_id" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + _debug record_id "$record_id" + + if _muumuu_rest DELETE "/me/domains/${_domain_id}/dns-records/${record_id}"; then + if [ "$_muumuu_code" = "204" ]; then + _info "TXT record deleted successfully" + return 0 + fi + fi + + _err "Failed to delete TXT record (HTTP ${_muumuu_code})" + return 1 +} + +#################### Private functions below ################################## + +# _acme-challenge.www.example.com +# sets: +# _domain_id MU00000001 +# _sub_domain _acme-challenge.www +# _domain example.com +_muumuu_get_root() { + domain="$1" + i=1 + p=0 + h="" + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 + fi + if ! _muumuu_rest GET "/me/domains?fqdn=${h}&page-size=1"; then + return 1 + fi + if [ "$_muumuu_code" = "401" ] || [ "$_muumuu_code" = "403" ]; then + _err "Authentication failed (HTTP ${_muumuu_code}). Check MUUMUU_PAT." + return 1 + fi + if _contains "$response" "\"fqdn\":\"${h}\""; then + _domain_id=$(echo "$response" | _egrep_o "\"id\":\"MU[0-9]+\"" | _head_n 1 | cut -d: -f2 | tr -d '"') + _domain="$h" + if [ "$p" = "0" ]; then + _sub_domain="" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + fi + return 0 + fi + p="$i" + i=$(_math "$i" + 1) + done +} + +_muumuu_rest() { + _muumuu_method="$1" + _muumuu_path="$2" + _muumuu_data="$3" + _muumuu_url="${MUUMUU_API}${_muumuu_path}" + + export _H1="Authorization: Bearer ${MUUMUU_PAT}" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + export _H4="" + export _H5="" + + _secure_debug2 data "$_muumuu_data" + + if [ "$_muumuu_method" = "GET" ]; then + response="$(_get "$_muumuu_url")" + else + response="$(_post "$_muumuu_data" "$_muumuu_url" "" "$_muumuu_method")" + fi + _muumuu_ret="$?" + _muumuu_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug "HTTP code: ${_muumuu_code}" + _secure_debug2 response "$response" + + if [ "$_muumuu_ret" != "0" ]; then + _err "Error accessing ${_muumuu_url}" + return 1 + fi + + response="$(printf "%s" "$response" | _normalizeJson)" + return 0 +} diff --git a/dnsapi/dns_mythic_beasts.sh b/dnsapi/dns_mythic_beasts.sh index 1529e1e7..a49ab8ab 100755 --- a/dnsapi/dns_mythic_beasts.sh +++ b/dnsapi/dns_mythic_beasts.sh @@ -186,7 +186,7 @@ _oauth2() { _oauth2_std() { # HTTP Basic Authentication _H1="Authorization: Basic $(echo "$MB_AK:$MB_AS" | _base64)" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 body="grant_type=client_credentials" @@ -210,7 +210,7 @@ _oauth2_std() { } _oauth2_github() { - _H1="Accepts: application/json" + _H1="Accept: application/json" export _H1 body="{\"login\":{\"handle\":\"$MB_AK\",\"pass\":\"$MB_AS\",\"floating\":1}}" @@ -241,7 +241,7 @@ _mb_rest() { fi _H1="Authorization: Bearer $MB_TK" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 if [ "$data" ] || [ "$m" = "POST" ] || [ "$m" = "PUT" ] || [ "$m" = "DELETE" ]; then # body url [needbase64] [POST|PUT|DELETE] [ContentType] diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 1062c849..1ba6a6e5 100755 --- a/dnsapi/dns_namecom.sh +++ b/dnsapi/dns_namecom.sh @@ -153,10 +153,9 @@ _namecom_get_root() { i=2 p=1 - if ! _namecom_rest GET "domains"; then - return 1 - fi - + # Probe each candidate with GetDomain (GET /v4/domains/{domainName}) instead + # of listing all domains: the list is paginated at 1000 domains per page, so + # larger accounts never found their domain on the first page. # Need to exclude the last field (tld) numfields=$(echo "$domain" | _egrep_o "\." | wc -l) while [ "$i" -le "$numfields" ]; do @@ -166,7 +165,7 @@ _namecom_get_root() { return 1 fi - if _contains "$response" "$host"; then + if _namecom_rest GET "domains/$host" && _contains "$response" "\"domainName\":\"$host\""; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$host" return 0 diff --git a/dnsapi/dns_regru.sh b/dnsapi/dns_regru.sh index be5ae117..edf8b464 100644 --- a/dnsapi/dns_regru.sh +++ b/dnsapi/dns_regru.sh @@ -96,8 +96,8 @@ _get_root() { for ITEM in ${domains_list}; do IDN_ITEM=${ITEM} - case "${domain}" in - *${IDN_ITEM}*) + case ".${domain}" in + *.${IDN_ITEM}*) _domain="$(_idn "${ITEM}")" _debug _domain "${_domain}" return 0 diff --git a/dnsapi/dns_wedos.sh b/dnsapi/dns_wedos.sh new file mode 100644 index 00000000..d1f353e2 --- /dev/null +++ b/dnsapi/dns_wedos.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_wedos_info='WEDOS.com +Site: wedos.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_wedos +Options: + WEDOS_Username WAPI login (account email) + WEDOS_Wapipass WAPI password +Issues: github.com/acmesh-official/acme.sh/issues/7071 +Author: Jan Forman +' + +WEDOS_Api="https://api.wedos.com/wapi/json" + +######## Public functions ##################### + +#Usage: dns_wedos_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_add() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + _info "Adding TXT record: $_sub_domain.$_domain" + if ! _wedos_request "dns-row-add" "{\"domain\":\"$_domain\",\"name\":\"$_sub_domain\",\"ttl\":\"300\",\"type\":\"TXT\",\"rdata\":\"$txtvalue\"}"; then + _err "Failed to add TXT record" + return 1 + fi + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#Usage: dns_wedos_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_rm() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + # _get_root leaves the dns-rows-list response for $_domain in $response + _debug "Looking up row IDs for TXT value: $txtvalue" + _row_ids=$(echo "$response" | tr '{' '\n' | grep -F -- "\"rdata\":\"$txtvalue\"" | grep -F -- "\"name\":\"$_sub_domain\"" | _egrep_o '"ID": *"[0-9]*"' | tr -dc '0-9\n') + _debug _row_ids "$_row_ids" + + if [ -z "$_row_ids" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + + for _row_id in $_row_ids; do + _info "Removing TXT record ID $_row_id from $_domain" + if ! _wedos_request "dns-row-delete" "{\"domain\":\"$_domain\",\"row_id\":\"$_row_id\"}"; then + _err "Failed to delete TXT record" + return 1 + fi + done + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#################### Private functions below ################################## + +_wedos_init() { + WEDOS_Username="${WEDOS_Username:-$(_readaccountconf_mutable WEDOS_Username)}" + WEDOS_Wapipass="${WEDOS_Wapipass:-$(_readaccountconf_mutable WEDOS_Wapipass)}" + + if [ -z "$WEDOS_Username" ] || [ -z "$WEDOS_Wapipass" ]; then + WEDOS_Username="" + WEDOS_Wapipass="" + _err "You didn't specify the WEDOS WAPI credentials yet." + _err "Please export WEDOS_Username and WEDOS_Wapipass and try again." + return 1 + fi + + _saveaccountconf_mutable WEDOS_Username "$WEDOS_Username" + _saveaccountconf_mutable WEDOS_Wapipass "$WEDOS_Wapipass" + return 0 +} + +# WAPI auth token: sha1(login + sha1(password) + hour), where the hour is +# the current hour on the WEDOS servers (Europe/Prague timezone). +# The POSIX TZ string is used so no tzdata is required on the client. +_wedos_auth() { + if [ "$_wedos_utc" ]; then + # fallback: WAPI accepts 1 hour of skew, UTC+1 fits both CET and CEST + _wedos_hour=$(date -u +%H) + _wedos_hour=$(printf '%02d' "$(((${_wedos_hour#0} + 1) % 24))") + else + _wedos_hour=$(TZ='CET-1CEST,M3.5.0,M10.5.0/3' date +%H) + fi + _wedos_phash=$(printf '%s' "$WEDOS_Wapipass" | _digest sha1 hex) + printf '%s' "${WEDOS_Username}${_wedos_phash}${_wedos_hour}" | _digest sha1 hex +} + +#Usage: _wedos_request +#Returns 0 and sets $response on WAPI code 1000, returns 1 otherwise. +_wedos_request() { + _wedos_cmd="$1" + _wedos_data="$2" + + _wedos_token=$(_wedos_auth) + _secure_debug _wedos_token "$_wedos_token" + + _wedos_json="{\"request\":{\"user\":\"$WEDOS_Username\",\"auth\":\"$_wedos_token\",\"command\":\"$_wedos_cmd\",\"data\":$_wedos_data}}" + _debug2 "WAPI command: $_wedos_cmd" + _debug2 "WAPI data: $_wedos_data" + + # _post sends the global _H1.._H5 headers with every request; clear them so + # headers from earlier API calls are not leaked to the WAPI endpoint. + export _H1="" + export _H2="" + export _H3="" + export _H4="" + export _H5="" + + _wedos_body="request=$(printf '%s' "$_wedos_json" | _url_encode)" + response=$(_post "$_wedos_body" "$WEDOS_Api" "" "POST" "application/x-www-form-urlencoded") + if [ "$?" != "0" ]; then + _err "WAPI request failed for command '$_wedos_cmd'" + return 1 + fi + _debug2 "WAPI response: $response" + + _wedos_code=$(echo "$response" | _egrep_o '"code": *[0-9]*' | _head_n 1 | tr -dc '0-9') + _debug2 "WAPI result code: $_wedos_code" + if [ "$_wedos_code" = "1000" ]; then + return 0 + fi + + # some systems ignore the TZ variable (Haiku), sending a wrong auth hour; + # retry once with the UTC fallback in _wedos_auth + if [ "$_wedos_code" = "2050" ] && [ -z "$_wedos_utc" ]; then + _wedos_utc=1 + _wedos_request "$_wedos_cmd" "$_wedos_data" + return $? + fi + + # 2050 = bad credentials, 2051 = IP not whitelisted, 2052 = IP blocked + if [ "$_wedos_code" = "2050" ] || [ "$_wedos_code" = "2051" ] || [ "$_wedos_code" = "2052" ]; then + _wedos_result=$(echo "$response" | _egrep_o '"result": *"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _err "WAPI authentication error $_wedos_code: $_wedos_result" + _err "Check WEDOS_Username, WEDOS_Wapipass and the WAPI IP whitelist." + _wedos_autherr=1 + return 1 + fi + + _debug "WAPI error for command '$_wedos_cmd': $response" + return 1 +} + +# Determine the registered domain (_domain) and subdomain prefix (_sub_domain) +# by walking up the labels and calling dns-rows-list until WAPI accepts one. +# _acme-challenge.www.example.co.uk +# -> _sub_domain=_acme-challenge.www _domain=example.co.uk +# The full domain itself is tried first, so a zone apex (e.g. DNS alias mode +# pointing at the registered domain) resolves to an empty _sub_domain. +_get_root() { + _gr_full="$1" + _gr_i=1 + _wedos_autherr="" + while true; do + _gr_candidate=$(printf '%s' "$_gr_full" | cut -d . -f "${_gr_i}"-100) + _debug2 "Checking zone candidate: $_gr_candidate" + if [ -z "$_gr_candidate" ]; then + return 1 + fi + + if _wedos_request "dns-rows-list" "{\"domain\":\"$_gr_candidate\"}"; then + _domain="$_gr_candidate" + if [ "$_gr_i" = "1" ]; then + _sub_domain="" + else + _sub_domain=$(printf '%s' "$_gr_full" | cut -d . -f 1-"$((_gr_i - 1))") + fi + return 0 + fi + + # auth error hits every candidate, stop the walk + if [ "$_wedos_autherr" ]; then + return 1 + fi + + _gr_i=$((_gr_i + 1)) + done +} diff --git a/notify/aws_ses.sh b/notify/aws_ses.sh index 07e0c48c..735e6204 100644 --- a/notify/aws_ses.sh +++ b/notify/aws_ses.sh @@ -83,7 +83,43 @@ aws_ses_send() { response="$(aws_rest POST "" "" "$_data")" } +_use_container_role() { + # automatically set if running inside ECS + if [ -z "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" ]; then + _debug "No ECS environment variable detected" + return 1 + fi + _use_metadata "169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" +} + +_use_instance_role() { + _instance_role_name_url="http://169.254.169.254/latest/meta-data/iam/security-credentials/" + + if _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 401; then + _debug "Using IMDSv2" + _token_url="http://169.254.169.254/latest/api/token" + export _H1="X-aws-ec2-metadata-token-ttl-seconds: 21600" + _token="$(_post "" "$_token_url" "" "PUT")" + _secure_debug3 "_token" "$_token" + if [ -z "$_token" ]; then + _debug "Unable to fetch IMDSv2 token from instance metadata" + return 1 + fi + export _H1="X-aws-ec2-metadata-token: $_token" + fi + + if ! _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 200; then + _debug "Unable to fetch IAM role from instance metadata" + return 1 + fi + + _instance_role_name=$(_get "$_instance_role_name_url" "" 1) + _debug "_instance_role_name" "$_instance_role_name" + _use_metadata "$_instance_role_name_url$_instance_role_name" "$_token" +} + _use_metadata() { + export _H1="X-aws-ec2-metadata-token: $2" _aws_creds="$( _get "$1" "" 1 | _normalizeJson |