diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 84a17470..6dd7400f 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -973,3 +973,116 @@ jobs: + HardenedBSD: + runs-on: ubuntu-latest + needs: OpenEuler + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hardenedbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + prepare: pkg install -y socat curl + usesh: true + sync: nfs + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + OPNsense: + runs-on: ubuntu-latest + needs: HardenedBSD + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/opnsense-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + #The dns-01 cases need no inbound port, so the appliance's web GUI can + #keep the 80 port here, unlike the standalone workflow. + prepare: pkg install -y socat curl + usesh: true + sync: nfs + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/HardenedBSD.yml b/.github/workflows/HardenedBSD.yml new file mode 100644 index 00000000..f5576856 --- /dev/null +++ b/.github/workflows/HardenedBSD.yml @@ -0,0 +1,76 @@ +name: HardenedBSD +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/HardenedBSD.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/HardenedBSD.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + HardenedBSD: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + ACME_USE_WGET: 1 + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hardenedbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install -y socat curl wget + usesh: true + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/OPNsense.yml b/.github/workflows/OPNsense.yml new file mode 100644 index 00000000..d1d9570d --- /dev/null +++ b/.github/workflows/OPNsense.yml @@ -0,0 +1,86 @@ +name: OPNsense +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/OPNsense.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/OPNsense.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + OPNsense: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + ACME_USE_WGET: 1 + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/opnsense-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install -y socat curl wget + usesh: true + sync: nfs + run: | + #OPNsense is a firewall appliance whose web GUI holds the 80 port, + #where every --standalone case listens. configd has no "stop" + #action for it and the rc script cannot stop it either, so kill it. + #This belongs here and not in prepare: prepare runs before the + #cache-after-prepare reboot, which would bring the GUI back. And do + #NOT free the port by disabling the GUI's http redirect in + #config.xml: pf's automatic pass rule for the 80 port is generated + #from the web GUI settings, so dropping the redirect also drops the + #rule on the next boot, and the inbound challenge is filtered. + pkill lighttpd || true + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/OpenEuler.yml b/.github/workflows/OpenEuler.yml index 2b4bd0ab..35625a73 100644 --- a/.github/workflows/OpenEuler.yml +++ b/.github/workflows/OpenEuler.yml @@ -56,7 +56,15 @@ jobs: envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' nat: | "8080": "80" - prepare: dnf install -y curl socat cronie tar gzip + prepare: | + # openEuler ships every repo with both a baseurl and a metalink. + # The metalink mirror pool is split-brain (dl-cdn.openeuler.openatom.cn + # froze at the 2026-08-20 snapshot while repo.openeuler.org moved on), + # so dnf takes repomd.xml from the stale mirror and then 404s fetching + # the checksummed metadata it names from the fresh ones. Keep only the + # vendor baseurl, which is self-consistent. + sed -i '/^metalink=/d' /etc/yum.repos.d/*.repo + dnf install -y curl socat cronie tar gzip usesh: true sync: rsync copyback: false diff --git a/.github/workflows/vtag.yml b/.github/workflows/vtag.yml index e9f7e8df..6b5de15a 100644 --- a/.github/workflows/vtag.yml +++ b/.github/workflows/vtag.yml @@ -23,10 +23,22 @@ jobs: - name: Create the v-prefixed tag env: GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} run: | - if gh api "repos/${{ github.repository }}/git/ref/tags/v${{ github.ref_name }}" >/dev/null 2>&1; then - echo "Tag v${{ github.ref_name }} already exists, nothing to do." + if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then + echo "Tag v$TAG already exists, nothing to do." exit 0 fi - gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/tags/v${{ github.ref_name }}" -f sha="${{ github.sha }}" - echo "Created tag v${{ github.ref_name }} -> ${{ github.sha }}" + # Mirror the object the pushed tag actually points at: the commit + # for a lightweight tag, the tag object itself for an annotated or + # signed one. Pointing the mirror at the commit would strip the + # signature, so "git verify-tag v3.1.3" would fail while + # "git verify-tag 3.1.3" succeeds. + sha="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.sha)" + if [ -z "$sha" ] || [ "$sha" = "null" ]; then + echo "Could not resolve refs/tags/$TAG" + exit 1 + fi + gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha" + echo "Created tag v$TAG -> $sha" diff --git a/README.md b/README.md index 90280e94..8ffb54df 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Haiku Hurd OpenEuler + HardenedBSD + OPNsense

@@ -134,6 +136,8 @@ |27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD |28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd |29|[![OpenEuler](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler +|30|[![HardenedBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml)|HardenedBSD +|31|[![OPNsense](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml)|OPNsense > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) @@ -227,6 +231,31 @@ Cron entry example: acme.sh -h ``` +#### 🔏 Verify a Release + +Release tags from `3.1.5` on are signed with the maintainer's SSH key. The +signing happens on the maintainer's machine, so the private key is never +available to CI. The public half is [`allowed_signers`](allowed_signers) in +this repository. From a clone: + +```bash +git config gpg.ssh.allowedSignersFile allowed_signers +``` + +```bash +git verify-tag 3.1.5 +``` + +The signature covers the tag object, which pins the commit and therefore the +whole tree, so a good signature verifies every file at that release and no +separate tarball checksum is needed. Build a tarball from the verified tag: + +```bash +git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz +``` + +> ⚠️ Tags up to `3.1.4` predate the signing key and are unsigned. + --- ### 2️⃣ Issue a Certificate diff --git a/acme.sh b/acme.sh index 4d4f6cbe..1667adfb 100755 --- a/acme.sh +++ b/acme.sh @@ -1482,39 +1482,57 @@ _readKeyLengthFromCSR() { fi } +#port +#Reads a netstat or ss listing on stdin, prints the lines that show a socket +#listening on port. +#Linux and windows print the local address as "addr:port", aix, macos, the +#bsds and solaris print it as "addr.port", so both separators must match. +#The state is "LISTEN" nearly everywhere, "LISTENING" on windows and lower +#case "listen" on haiku, hence the substring match and the -i. +_filter_listen_port() { + _flp_port="$1" + if [ -z "$_flp_port" ]; then + return + fi + grep -i "LISTEN" | grep "[:.]$_flp_port " +} + +#port _ss() { _port="$1" if _exists "ss"; then _debug "Using: ss" - ss -ntpl 2>/dev/null | grep ":$_port " + ss -ntpl 2>/dev/null | _filter_listen_port "$_port" return 0 fi - if [ "$(uname)" = "AIX" ]; then - _debug "Using: AIX netstat" - netstat -an | grep "^tcp" | grep "LISTEN" | grep "\.$_port " + #aix, macos and the bsds have no "-p protocol" socket listing that works on + #all of them: on netbsd "-p" is "Show statistics about protocol" instead + #(netstat(1), NetBSD 10.1). Their default display does show "the state of + #all sockets" with -a, so use that and keep only the tcp lines. + case "$(uname)" in + AIX | Darwin | DragonFly | *BSD*) + _debug "Using: AIX/BSD netstat" + netstat -an | grep "^tcp" | _filter_listen_port "$_port" return 0 - fi + ;; + esac if _exists "netstat"; then _debug "Using: netstat" if netstat -help 2>&1 | grep "\-p proto" >/dev/null; then #for windows version netstat tool - netstat -an -p tcp | grep "LISTENING" | grep ":$_port " + netstat -an -p tcp | _filter_listen_port "$_port" + elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then + #for solaris + netstat -an -P tcp | _filter_listen_port "$_port" + elif netstat -help 2>&1 | grep "\-p" >/dev/null; then + #for full linux + netstat -ntpl | _filter_listen_port "$_port" else - if netstat -help 2>&1 | grep "\-p protocol" >/dev/null; then - netstat -an -p tcp | grep LISTEN | grep ":$_port " - elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then - #for solaris - netstat -an -P tcp | grep "\.$_port " | grep "LISTEN" - elif netstat -help 2>&1 | grep "\-p" >/dev/null; then - #for full linux - netstat -ntpl | grep ":$_port " - else - #for busybox (embedded linux; no pid support) - netstat -ntl 2>/dev/null | grep ":$_port " - fi + #for busybox (embedded linux; no pid support) + netstat -ntl 2>/dev/null | _filter_listen_port "$_port" fi return 0 fi @@ -2343,6 +2361,39 @@ _tail_c() { tail -c "$1" 2>/dev/null || tail -"$1"c } +#code +#Is this status the CA's front end failing rather than its ACME +#implementation answering? 502 and 504 mean the proxy could not reach the +#backend or gave up waiting for it, 503 that it is overloaded. The body of +#those is the proxy's html, not problem+json, so no ACME status can be read +#out of it and a caller looking for one abandons an order that is fine. +#Anything else, a 500 from the ACME implementation included, is a real +#answer and must be passed through to the caller. +_is_gateway_error() { + case "$1" in + 502 | 503 | 504) return 0 ;; + esac + return 1 +} + +#attempt +#Seconds to wait before retry number , for the cases where the CA +#gave us no Retry-After to go by. A flat two seconds let the whole twenty +#attempt budget burn out in forty eight seconds, which is shorter than the +#gateway outages a CA really has: ZeroSSL answered 502 and 504 for over a +#minute at a time through August 2026, so every renewal that started during +#one of those died instead of waiting it out. Backing off spends the same +#twenty attempts over about six minutes, which is still far below the ten +#minutes at which a Retry-After is read as the CA refusing outright. +_retry_backoff_sec() { + case "$1" in + 1) echo 2 ;; + 2) echo 5 ;; + 3) echo 10 ;; + *) echo 20 ;; + esac +} + # url payload needbase64 keyfile _send_signed_request() { url=$1 @@ -2406,8 +2457,9 @@ _send_signed_request() { nonce="$_CACHED_NONCE" _debug2 nonce "$nonce" if [ -z "$nonce" ]; then - _info "Could not get nonce, let's try again." - _sleep 2 + _sleep_nonce_sec="$(_retry_backoff_sec "$_request_retry_times")" + _info "Could not get nonce, let's try again. Sleeping for $_sleep_nonce_sec seconds." + _sleep "$_sleep_nonce_sec" continue fi @@ -2466,13 +2518,13 @@ _send_signed_request() { fi _retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r') - if [ "$code" = '503' ]; then + if _is_gateway_error "$code"; then _sleep_overload_retry_sec=$_retryafter if [ -z "$_sleep_overload_retry_sec" ]; then - _sleep_overload_retry_sec=5 + _sleep_overload_retry_sec="$(_retry_backoff_sec "$_request_retry_times")" fi if [ $_sleep_overload_retry_sec -le 600 ]; then - _info "It seems the CA server is currently overloaded, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds." + _info "The CA server answered $code, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds." _sleep $_sleep_overload_retry_sec continue else @@ -2499,6 +2551,15 @@ _send_signed_request() { } +#Reads a value from stdin, prints it escaped for use as the replacement text +#of a sed s command delimited by '|'. The backslash must go first: a bare one +#starts an escape sequence and backslash-digit is a backreference, both make +#sed error out. Then '&' (the whole-match reference) and the '|' delimiter. +#https://github.com/acmesh-official/acme.sh/issues/7213 +_sed_escape_rhs() { + sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g' +} + #setopt "file" "opt" "=" "value" [";"] _setopt() { __conf="$1" @@ -2514,34 +2575,50 @@ _setopt() { touch "$__conf" chmod 600 "$__conf" fi + __nl=" +" + case "$__val" in + *"$__nl"*) + #the conf format is line based and the file is sourced by the shell, so a + #value holding a line break cannot be represented in it (it would also + #make the replace sed below fail with an unterminated 's' command) + _err "The value of '$__opt' contains a line break, it cannot be saved to $__conf." + return 1 + ;; + esac if [ -n "$(_tail_c 1 <"$__conf")" ]; then echo >>"$__conf" fi if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then _debug3 OK - if _contains "$__val" "&"; then - __val="$(echo "$__val" | sed 's/&/\\&/g')" - fi - if _contains "$__val" "|"; then - __val="$(echo "$__val" | sed 's/|/\\|/g')" - fi + __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" text="$(cat "$__conf")" - printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf" + #capture first, write only on success: redirecting sed straight into the + #conf file truncates it before sed runs, so a failing sed (e.g. on an + #unescaped special character in the value) wiped the whole conf (#2426) + if __text="$(printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then + printf -- "%s\n" "$__text" >"$__conf" + else + _err "Cannot save '$__opt' to $__conf." + return 1 + fi elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then - if _contains "$__val" "&"; then - __val="$(echo "$__val" | sed 's/&/\\&/g')" - fi - if _contains "$__val" "|"; then - __val="$(echo "$__val" | sed 's/|/\\|/g')" - fi + __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" text="$(cat "$__conf")" - printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf" + if __text="$(printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then + printf -- "%s\n" "$__text" >"$__conf" + else + _err "Cannot save '$__opt' to $__conf." + return 1 + fi else _debug3 APP - echo "$__opt$__sep$__val$__end" >>"$__conf" + #printf, not echo: dash's builtin echo interprets backslash escapes in + #the value and would corrupt it + printf -- "%s\n" "$__opt$__sep$__val$__end" >>"$__conf" fi _debug3 "$(grep -n "^$__opt$__sep" "$__conf")" } @@ -2569,7 +2646,9 @@ _clear_conf() { _sdkey="$2" if [ "$_c_c_f" ]; then _conf_data="$(cat "$_c_c_f")" - echo "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f" + #printf, not echo: dash's builtin echo interprets backslash escapes and + #would corrupt saved values that contain them on every rewrite + printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f" else _err "Config file is empty, cannot clear" fi @@ -3911,6 +3990,11 @@ _on_before_issue() { if [ -z "$netprc" ]; then netprc="$(echo "$_netprc" | grep "$LOCAL_ANY_ADDRESS:$_checkport")" fi + if [ -z "$netprc" ]; then + #aix, macos, the bsds and solaris print the wildcard local address as + #"*.port", not "0.0.0.0:port", and it blocks $_checkaddr just the same + netprc="$(echo "$_netprc" | grep " [*][:.]$_checkport ")" + fi if [ "$netprc" ]; then _err "$netprc" _err "tcp port $_checkport is already used by $(echo "$netprc" | cut -d : -f 4)" @@ -7072,6 +7156,30 @@ _uninstall_win_taskscheduler() { fi } +#binpath +#Reads a crontab listing from stdin, prints it without the acme.sh cron +#entries that call binpath. +_filter_cron_bin() { + _fcb_bin="$1" + if [ -z "$_fcb_bin" ]; then + cat + return + fi + #a case pattern with a quoted variable matches binpath literally, which + #grep cannot do portably: Solaris /usr/bin/grep has no -F, and as a regex + #the dot of ~/.acme.sh would stand for any character + while IFS= read -r _fcb_line || [ -n "$_fcb_line" ]; do + case "$_fcb_line" in + *"$_fcb_bin --cron"*) + _debug3 "Dropping cron entry" "$_fcb_line" + ;; + *) + echo "$_fcb_line" + ;; + esac + done +} + #confighome installcronjob() { _c_home="$1" @@ -7141,7 +7249,26 @@ installcronjob() { return 1 fi fi - if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then + #An entry that calls LE_WORKING_DIR/PROJECT_ENTRY is dead once that copy is + #gone: ACME_PACKAGED installs never write it, and the package manager + #removes it when it takes over. The entry below would then keep the install + #from adding a working one and cron would fail silently every day, so drop + #the stale entries first. + _cron_stale="" + if [ ! -f "$LE_WORKING_DIR/$PROJECT_ENTRY" ] && [ "$_cron_entries" ]; then + _cron_kept="$(echo "$_cron_entries" | _filter_cron_bin "\"$LE_WORKING_DIR\"/$PROJECT_ENTRY")" + if [ "$_cron_kept" != "$_cron_entries" ]; then + _info "Removing the cron job that calls the missing $LE_WORKING_DIR/$PROJECT_ENTRY" + _cron_entries="$_cron_kept" + _cron_stale=1 + fi + fi + #>/dev/null: grep would print the matching crontab line to the console + _cron_add="" + if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron" >/dev/null; then + _cron_add=1 + fi + if [ "$_cron_add" ] || [ "$_cron_stale" ]; then if _exists uname && uname -a | grep SunOS >/dev/null; then _CRONTAB_STDIN="$_CRONTAB --" else @@ -7151,7 +7278,9 @@ installcronjob() { if [ "$_cron_entries" ]; then echo "$_cron_entries" fi - echo "$_cron_entry" + if [ "$_cron_add" ]; then + echo "$_cron_entry" + fi } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then diff --git a/allowed_signers b/allowed_signers new file mode 100644 index 00000000..8ed9d0a7 --- /dev/null +++ b/allowed_signers @@ -0,0 +1,20 @@ +# acme.sh release signing key. +# +# Release tags are signed with this key. Its private half is held by the +# maintainer and is never available to CI, so a compromise of the build +# pipeline cannot produce a tag that verifies against this file. +# +# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE +# +# To verify a release tag, from a clone of this repository: +# +# git config gpg.ssh.allowedSignersFile allowed_signers +# git verify-tag 3.1.5 +# +# A good signature covers the tag object, which pins the commit, which pins +# the whole tree -- so verifying the tag verifies every file at that +# release. Build a tarball from the verified tag with: +# +# git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz +# +github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB diff --git a/deploy/unifios.sh b/deploy/unifios.sh index 82b65c69..05298c9f 100644 --- a/deploy/unifios.sh +++ b/deploy/unifios.sh @@ -1,24 +1,29 @@ #!/usr/bin/env sh -# Deploy hook for UniFi OS Server (self-hosted). +# Deploy hook for UniFi OS, via the certificate REST API. # -# Supports: -# - UniFi OS Server on macOS -# - UniFi OS Server on Linux -# - UniFi OS Server on Windows should also work (runs under WSL2), but -# has not been tested. +# Works against any UniFi OS whose management UI exposes +# /api/userCertificates. Confirmed on: +# - UniFi OS Server (the separately-installed, self-hosted application) +# on macOS and on Linux. Windows should also work (it runs under +# WSL2), but has not been tested. +# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). +# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on +# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916). +# No lower version bound is claimed -- if the UI has a certificate +# manager, this hook should work. # -# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). +# `unifios` vs `unifi`: the split is the access method, not the product +# line. `unifi` writes files / a Java keystore and needs local or SSH +# access on the device; this hook drives the same REST API the web UI +# uses and works remotely. Use `unifi` where acme.sh runs on the device +# itself, this hook where it does not. # -# This is a different product from the Cloud Key / UDM hardware and -# self-hosted Unifi Controller covered by the `unifi` deploy hook above -# (that hook already covers Cloud Key running UnifiOS v2.0.0+/Gen2/2+) -- -# this hook targets the separately-installed, self-hosted "UniFi OS Server" -# application instead, which stores certificates in its own Postgres -# database via a REST API rather than a Java keystore, so the `unifi` -# hook's approach does not apply here. +# The API is served on the management port, which differs per install: +# UniFi OS Server listens on 11443 (hence the default below), while +# UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to +# "https://" there. # -# UniFi OS Server exposes a REST API on its management port (default -# 11443) that its own web UI uses for certificate management: +# Endpoints used, all as the web UI itself calls them: # POST /api/auth/login - session login (cookie + JWT) # GET /api/userCertificates - list uploaded certificates # POST /api/userCertificates - upload a new certificate @@ -41,8 +46,9 @@ # Uses core acme.sh helpers throughout (_post/_get, _json_encode, # _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or # python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all -# honored the same as every other hook. The management API's cert is -# self-signed (it's a management-only port, not meant for public exposure), +# honored the same as every other hook. The management API's cert may be +# self-signed -- it always is on a fresh install, and there is no reliable +# way to tell in advance whether an earlier run has already replaced it -- # so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see # acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS # verification for the rest of the acme.sh run, e.g. the connection to the @@ -63,9 +69,11 @@ # # Settings: # DEPLOY_UNIFIOS_HOST - base URL of the management API -# (default: "https://localhost:11443") -# DEPLOY_UNIFIOS_USERNAME - UniFi OS Server admin username (required) -# DEPLOY_UNIFIOS_PASSWORD - UniFi OS Server admin password (required) +# (default: "https://localhost:11443", i.e. a UniFi OS Server on the +# same machine as acme.sh; set it to "https://" for UniFi OS +# hardware or any remote target) +# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required) +# DEPLOY_UNIFIOS_PASSWORD - UniFi OS admin password (required) # # Example: # export DEPLOY_UNIFIOS_USERNAME="acmeuser" diff --git a/dnsapi/dns_azure.sh b/dnsapi/dns_azure.sh index f9d84706..4708e151 100644 --- a/dnsapi/dns_azure.sh +++ b/dnsapi/dns_azure.sh @@ -9,7 +9,7 @@ Options: AZUREDNS_APPID App ID. App ID of the service principal AZUREDNS_CLIENTSECRET Client Secret. Secret from creating the service principal AZUREDNS_MANAGEDIDENTITY Use Managed Identity. Use Managed Identity assigned to a resource instead of a service principal. "true"/"false" - AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional. + AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Not saved, provide it on every run. Optional. ' wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS @@ -47,13 +47,15 @@ dns_azure_add() { _saveaccountconf_mutable AZUREDNS_TENANTID "" _saveaccountconf_mutable AZUREDNS_APPID "" _saveaccountconf_mutable AZUREDNS_CLIENTSECRET "" - _saveaccountconf_mutable AZUREDNS_BEARERTOKEN "" + _clearaccountconf_mutable AZUREDNS_BEARERTOKEN else _info "You didn't ask to use Azure managed identity, checking service principal credentials or provided bearer token" AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}" AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}" AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}" - AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}" + #AZUREDNS_BEARERTOKEN is short-lived, so it is taken from the environment only and never + #read from or saved to the account conf. Versions up to 3.0.9 cached their internal access + #token under the same name, which must not be replayed as a user token (#7218). if [ -z "$AZUREDNS_BEARERTOKEN" ]; then if [ -z "$AZUREDNS_TENANTID" ]; then AZUREDNS_SUBSCRIPTIONID="" @@ -93,7 +95,7 @@ dns_azure_add() { _saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID" _saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID" _saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET" - _saveaccountconf_mutable AZUREDNS_BEARERTOKEN "$AZUREDNS_BEARERTOKEN" + _clearaccountconf_mutable AZUREDNS_BEARERTOKEN fi if [ -z "$AZUREDNS_BEARERTOKEN" ]; then @@ -175,7 +177,7 @@ dns_azure_rm() { AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}" AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}" AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}" - AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}" + #AZUREDNS_BEARERTOKEN comes from the environment only, see the note in dns_azure_add if [ -z "$AZUREDNS_BEARERTOKEN" ]; then if [ -z "$AZUREDNS_TENANTID" ]; then AZUREDNS_SUBSCRIPTIONID="" diff --git a/dnsapi/dns_easydns.sh b/dnsapi/dns_easydns.sh index 423def2b..2da45866 100644 --- a/dnsapi/dns_easydns.sh +++ b/dnsapi/dns_easydns.sh @@ -75,6 +75,11 @@ dns_easydns_rm() { EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}" EASYDNS_Key="${EASYDNS_Key:-$(_readaccountconf_mutable EASYDNS_Key)}" + if [ -z "$EASYDNS_Token" ] || [ -z "$EASYDNS_Key" ]; then + _err "You didn't specify an easydns.net token or api key. Signup at https://cp.easydns.com/manage/security/api/signup.php" + return 1 + fi + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -91,24 +96,21 @@ dns_easydns_rm() { return 1 fi - count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2) - _debug count "$count" - if [ "$count" = "0" ]; then + record_id=$(printf "%s\n" "$response" | tr '{' '\n' | grep "\"rdata\":\"$txtvalue\"" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \") + _debug "record_id" "$record_id" + + if [ -z "$record_id" ]; then _info "Don't need to remove." - else - record_id=$(printf "%s\n" "$response" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | head -n 1) - _debug "record_id" "$record_id" - if [ -z "$record_id" ]; then - _err "Can not get record id to remove." - return 1 - fi - if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then - _err "Delete record error." - return 1 - fi - _contains "$response" "\"status\":200" + return 0 fi + if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then + _err "Delete record error." + return 1 + fi + + _contains "$response" "\"status\":200" + } #################### Private functions below ################################## diff --git a/dnsapi/dns_jd.sh b/dnsapi/dns_jd.sh index 4b9067f2..58f52351 100644 --- a/dnsapi/dns_jd.sh +++ b/dnsapi/dns_jd.sh @@ -7,22 +7,23 @@ Options: JD_ACCESS_KEY_ID Access key ID JD_ACCESS_KEY_SECRET Access key secret JD_REGION Region. E.g. "cn-north-1" -Issues: github.com/acmesh-official/acme.sh/issues/2388 +Issues: github.com/acmesh-official/acme.sh/issues/7202 +Author: @skysaint ' _JD_ACCOUNT="https://uc.jdcloud.com/account/accesskey" -_JD_PROD="clouddnsservice" +_JD_PROD="domainservice" _JD_API="jdcloud-api.com" -_JD_API_VERSION="v1" +_JD_API_VERSION="v2" _JD_DEFAULT_REGION="cn-north-1" _JD_HOST="$_JD_PROD.$_JD_API" ######## Public functions ##################### -#Usage: dns_myapi_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +#Usage: dns_jd_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_jd_add() { fulldomain=$1 txtvalue=$2 @@ -58,24 +59,14 @@ dns_jd_add() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - #_debug "Getting getViewTree" + #_debug "Getting describeViewTree" _debug "Adding records" - _addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1},\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}" - #_addrr='{"req":{"hostRecord":"xx","hostValue":"\"value4\"","jcloudRes":false,"mxPriority":null,"port":null,"ttl":300,"type":"TXT","weight":null,"viewValue":-1},"regionId":"cn-north-1","domainId":"8824"}' - if jd_rest POST "domain/$_domain_id/RRAdd" "" "$_addrr"; then - _rid="$(echo "$response" | tr '{},' '\n' | grep '"id":' | cut -d : -f 2)" - if [ -z "$_rid" ]; then - _err "Can not find record id from the result." - return 1 - fi + _addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1}}" + #_addrr='{"req":{"hostRecord":"_acme-challenge","hostValue":"XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs","ttl":300,"type":"TXT","viewValue":-1}}' + if jd_rest POST "domain/$_domain_id/ResourceRecord" "" "$_addrr"; then _info "TXT record added successfully." - _srid="$(_readdomainconf "JD_CLOUD_RIDS")" - if [ "$_srid" ]; then - _rid="$_srid,$_rid" - fi - _savedomainconf "JD_CLOUD_RIDS" "$_rid" return 0 fi @@ -97,14 +88,7 @@ dns_jd_rm() { _JD_BASE_URI="$_JD_API_VERSION/regions/$JD_REGION" - _info "Getting existing records for $fulldomain" - _srid="$(_readdomainconf "JD_CLOUD_RIDS")" - _debug _srid "$_srid" - - if [ -z "$_srid" ]; then - _err "Not rid skip" - return 0 - fi + _info "Removing TXT record for $fulldomain" _debug "First detect the root zone" if ! _get_root "$fulldomain"; then @@ -115,16 +99,37 @@ dns_jd_rm() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - _cleardomainconf JD_CLOUD_RIDS + # List records, filter by hostRecord and use a large pageSize so it isn't missed on record-heavy zones. + if ! jd_rest GET "domain/$_domain_id/ResourceRecord" "pageSize=50&search=$_sub_domain"; then + _err "Failed to list resource records" + return 1 + fi - _aws_tmpl_xml="{\"ids\":[$_srid],\"action\":\"del\",\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}" + # Match record by hostRecord + type TXT + hostValue + _record_id="" + _matched="$(echo "$response" | tr '{' '\n' | grep "\"hostRecord\":\"$_sub_domain\"" | grep "\"type\":\"TXT\"" | grep "\"hostValue\":\"$txtvalue\"")" + _debug2 _matched "$_matched" - if jd_rest POST "domain/$_domain_id/RROperate" "" "$_aws_tmpl_xml" && _contains "$response" "\"code\":\"OK\""; then + if [ -z "$_matched" ]; then + _info "TXT record not found, nothing to remove." + return 0 + fi + + _record_id="$(echo "$_matched" | tr ',' '\n' | grep "\"id\":" | cut -d : -f 2 | tr -d '"' | _head_n 1)" + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + _info "Could not extract record id from response, nothing to remove." + return 0 + fi + + if jd_rest DELETE "domain/$_domain_id/ResourceRecord/$_record_id"; then _info "TXT record deleted successfully." return 0 fi - return 1 + _err "Failed to delete TXT record." + return 1 } #################### Private functions below ################################## @@ -134,13 +139,14 @@ _get_root() { i=1 p=1 + if ! jd_rest GET "domain"; then + _err "error get domain list" + return 1 + fi + while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) _debug2 "Checking domain: $h" - if ! jd_rest GET "domain"; then - _err "error get domain list" - return 1 - fi if [ -z "$h" ]; then #not valid _err "Invalid domain" @@ -168,6 +174,8 @@ _get_root() { return 1 } +# Use '%b' with printf to expand \n escapes in CanonicalRequest and StringToSign. +# Use '%s' for plain values that contain no escapes to avoid unintended expansion. #method uri qstr data jd_rest() { mtd="$1" @@ -220,7 +228,7 @@ jd_rest() { CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$RequestPayloadHash" _debug2 CanonicalRequest "$CanonicalRequest" - HashedCanonicalRequest="$(printf "$CanonicalRequest%s" | _digest "$Hash" hex)" + HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)" _debug2 HashedCanonicalRequest "$HashedCanonicalRequest" Algorithm="JDCLOUD2-HMAC-SHA256" @@ -246,19 +254,19 @@ jd_rest() { kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")" _secure_debug2 kSecretH "$kSecretH" - kDateH="$(printf "$RequestDateOnly%s" | _hmac "$Hash" "$kSecretH" hex)" + kDateH="$(printf '%s' "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)" _debug2 kDateH "$kDateH" - kRegionH="$(printf "$Region%s" | _hmac "$Hash" "$kDateH" hex)" + kRegionH="$(printf '%s' "$Region" | _hmac "$Hash" "$kDateH" hex)" _debug2 kRegionH "$kRegionH" - kServiceH="$(printf "$Service%s" | _hmac "$Hash" "$kRegionH" hex)" + kServiceH="$(printf '%s' "$Service" | _hmac "$Hash" "$kRegionH" hex)" _debug2 kServiceH "$kServiceH" - kSigningH="$(printf "%s" "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)" + kSigningH="$(printf '%s' "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)" _debug2 kSigningH "$kSigningH" - signature="$(printf "$StringToSign%s" | _hmac "$Hash" "$kSigningH" hex)" + signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)" _debug2 signature "$signature" Authorization="$Algorithm Credential=$JD_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature" diff --git a/dnsapi/dns_myloc.sh b/dnsapi/dns_myloc.sh new file mode 100755 index 00000000..49d800c1 --- /dev/null +++ b/dnsapi/dns_myloc.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_myloc_info='myloc.de +Site: myloc.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_myloc +Issues: github.com/acmesh-official/acme.sh/issues/5193 +Options: + MYLOC_token API token +' + +# updater for the (experimental) API of myloc.de / webtropia.com +# usage: acme.sh --issue -d example.com --dns dns_myloc --dnssleep 60 +# API documentation at https://apidoc.myloc.de/ +# As the API does not support quering available zones yet, the zone for a given +# fulldomain is searched recursively by removing prefixes one-by-one. + +_myloc_api="https://zkm.myloc.de/api" + +#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_myloc_add() { + _myloc_fulldomain=$1 + _myloc_txtvalue=$2 + + _myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}" + if [ -z "$_myloc_token" ]; then + _err "You didn't specify MYLOC_token" + return 1 + fi + + export _H1="Content-Type: application/json" + export _H2="Authorization: Bearer $_myloc_token" + + _myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")" + if [ $? -ne 0 ]; then + return 1 + fi + + # save token if the previous request was successful + _saveaccountconf_mutable MYLOC_token "$_myloc_token" + + _info "Adding record" + _myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\",\"ttl\":60}" + _debug "add record request $_myloc_record to ${_myloc_api}/dns/zone/${_myloc_zone}" + _myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "PUT")" + _myloc_status=$? + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug "add record response $_code $_myloc_response" + if [ $_myloc_status -ne 0 ]; then + _err "Add txt record curl error." + return 1 + elif [ "$_code" = "204" ] && [ -z "$_myloc_response" ]; then + _info "Add txt record success" + return 0 + elif _contains "$_myloc_response" "error" || _contains "$_myloc_response" "unexpected"; then + _err "Add txt record api error." + return 1 + else + _err "Add txt record unknown response." + return 1 + fi +} + +#_myloc_fulldomain _myloc_txtvalue +dns_myloc_rm() { + _myloc_fulldomain=$1 + _myloc_txtvalue=$2 + + _myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}" + if [ -z "$_myloc_token" ]; then + _err "You didn't specify MYLOC_token" + return 1 + fi + + export _H1="Content-Type: application/json" + export _H2="Authorization: Bearer $_myloc_token" + + _myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")" + if [ $? -ne 0 ]; then + return 1 + fi + + # save token if the previous request was successful + _saveaccountconf_mutable MYLOC_token "$_myloc_token" + + _info "Deleting record for $_myloc_fulldomain" + _myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\"}" + _debug "delete record $_myloc_record" + _myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "DELETE")" + _myloc_status=$? + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug "delete response $_code $_myloc_response" + if [ $_myloc_status -ne 0 ] || [ "$_code" != "204" ]; then + _err "Failed to delete record" + return 1 + fi + + return 0 +} + +# Usage: _myloc_get_zone "_acme-challenge.sub1.mydomain.com" +# Subdomains are walked until a zone is found or TLD is reached +_myloc_get_zone() { + _myloc_zone=$1 + + while [ "${_myloc_zone#*.}" != "$_myloc_zone" ]; do + _debug "Get zone trying $_myloc_zone" + _myloc_response="$(_get "${_myloc_api}/dns/zone/${_myloc_zone}")" + _myloc_status=$? + _debug "Get zone response $_myloc_response" + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + if [ $_myloc_status -eq 0 ] && [ "$_code" = "200" ]; then + _debug "Get zone success for $_myloc_zone" + echo "${_myloc_zone}" + return 0 + fi + _myloc_zone="${_myloc_zone#*.}" + done + + _err "Get zone failed for all candidates" + return 1 +}