diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index be9d3aae..1a37b8a9 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -232,7 +232,7 @@ jobs: 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 - copyback: false + sync: nfs run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" @@ -251,7 +251,11 @@ jobs: fi cd ../acmetest ./letest.sh - + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" @@ -283,7 +287,7 @@ jobs: 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_add socat curl libiconv usesh: true - copyback: false + sync: nfs run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" @@ -302,7 +306,11 @@ jobs: fi cd ../acmetest ./letest.sh - + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" @@ -335,7 +343,7 @@ jobs: prepare: | /usr/sbin/pkg_add curl socat usesh: true - copyback: false + sync: nfs run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" @@ -354,7 +362,11 @@ jobs: fi cd ../acmetest ./letest.sh - + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" @@ -387,7 +399,7 @@ jobs: prepare: | pkg install -y curl socat libnghttp2 usesh: true - copyback: false + sync: nfs run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" @@ -406,7 +418,11 @@ jobs: fi cd ../acmetest ./letest.sh - + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" @@ -440,8 +456,10 @@ jobs: - uses: vmactions/solaris-vm@v1 with: envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' - copyback: false - prepare: pkgutil -y -i socat + sync: nfs + prepare: | + pkgutil -U + pkgutil -y -i socat run: | pkg set-mediator -v -I default@1.1 openssl export PATH=/usr/gnu/bin:$PATH @@ -462,6 +480,11 @@ jobs: fi cd ../acmetest ./letest.sh + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" Omnios: @@ -491,7 +514,62 @@ jobs: - uses: vmactions/omnios-vm@v1 with: envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' - copyback: false + sync: nfs + prepare: pkg install socat + 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: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + OpenIndiana: + runs-on: ubuntu-latest + needs: Omnios + 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 }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error, since OpenIndiana doesn't accept the expired ISRG X1 root + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v4 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openindiana-vm@v1 + with: + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: nfs prepare: pkg install socat run: | if [ "${{ secrets.TokenName1}}" ] ; then @@ -511,5 +589,70 @@ jobs: fi cd ../acmetest ./letest.sh + - name: onError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + Haiku: + runs-on: ubuntu-latest + needs: OpenIndiana + 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 }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error, since OpenIndiana doesn't accept the expired ISRG X1 root + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v4 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/haiku-vm@v1 + with: + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + prepare: | + mkdir -p /boot/home/.cache + pkgman install -y cronie + + 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: onError + 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/DragonFlyBSD.yml b/.github/workflows/DragonFlyBSD.yml index 5c56168f..dda8c99f 100644 --- a/.github/workflows/DragonFlyBSD.yml +++ b/.github/workflows/DragonFlyBSD.yml @@ -63,9 +63,13 @@ jobs: prepare: | pkg install -y curl socat libnghttp2 usesh: true - copyback: false + sync: nfs run: | cd ../acmetest \ && ./letest.sh - + - name: onError + 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/FreeBSD.yml b/.github/workflows/FreeBSD.yml index 961907e8..21123c4a 100644 --- a/.github/workflows/FreeBSD.yml +++ b/.github/workflows/FreeBSD.yml @@ -68,9 +68,13 @@ jobs: "8080": "80" prepare: pkg install -y socat curl wget usesh: true - copyback: false + sync: nfs run: | cd ../acmetest \ && ./letest.sh - + - name: onError + 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/Haiku.yml b/.github/workflows/Haiku.yml new file mode 100644 index 00000000..1dbfc2c4 --- /dev/null +++ b/.github/workflows/Haiku.yml @@ -0,0 +1,83 @@ +name: Haiku +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Haiku.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Haiku.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Haiku: + strategy: + fail-fast: false + 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 + #- TEST_ACME_Server: "ZeroSSL.com" + # CA_ECDSA: "ZeroSSL ECC Domain Secure Site CA" + # CA: "ZeroSSL RSA Domain Secure Site CA" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + 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@v4 + - uses: vmactions/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/haiku-vm@v1 + with: + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: | + mkdir -p /boot/home/.cache + pkgman install -y cronie + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: onError + 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/Linux.yml b/.github/workflows/Linux.yml index c74e9d3e..f3352a41 100644 --- a/.github/workflows/Linux.yml +++ b/.github/workflows/Linux.yml @@ -26,7 +26,7 @@ jobs: Linux: strategy: matrix: - os: ["ubuntu:latest", "debian:latest", "almalinux:latest", "fedora:latest", "opensuse/leap:latest", "alpine:latest", "oraclelinux:8", "kalilinux/kali", "archlinux:latest", "mageia", "gentoo/stage3"] + os: ["ubuntu:latest", "debian:latest", "almalinux:latest", "fedora:latest", "opensuse/leap:latest", "alpine:latest", "oraclelinux:8", "kalilinux/kali", "archlinux:latest", "gentoo/stage3"] runs-on: ubuntu-latest env: TEST_LOCAL: 1 diff --git a/.github/workflows/NetBSD.yml b/.github/workflows/NetBSD.yml index a4f90f68..40421552 100644 --- a/.github/workflows/NetBSD.yml +++ b/.github/workflows/NetBSD.yml @@ -63,9 +63,13 @@ jobs: prepare: | /usr/sbin/pkg_add curl socat usesh: true - copyback: false + sync: nfs run: | cd ../acmetest \ && ./letest.sh - - + - name: onError + 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/Omnios.yml b/.github/workflows/Omnios.yml index 882cedf6..20eb24d7 100644 --- a/.github/workflows/Omnios.yml +++ b/.github/workflows/Omnios.yml @@ -67,9 +67,13 @@ jobs: nat: | "8080": "80" prepare: pkg install socat wget - copyback: false + sync: nfs run: | cd ../acmetest \ && ./letest.sh - + - name: onError + 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/OpenBSD.yml b/.github/workflows/OpenBSD.yml index d5697c10..fab6e4fd 100644 --- a/.github/workflows/OpenBSD.yml +++ b/.github/workflows/OpenBSD.yml @@ -68,9 +68,13 @@ jobs: "8080": "80" prepare: pkg_add socat curl wget libnghttp2 usesh: true - copyback: false + sync: nfs run: | cd ../acmetest \ && ./letest.sh - + - name: onError + 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/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml new file mode 100644 index 00000000..abad376c --- /dev/null +++ b/.github/workflows/OpenIndiana.yml @@ -0,0 +1,79 @@ +name: OpenIndiana +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/OpenIndiana.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/OpenIndiana.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + OpenIndiana: + 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 + #- TEST_ACME_Server: "ZeroSSL.com" + # CA_ECDSA: "ZeroSSL ECC Domain Secure Site CA" + # CA: "ZeroSSL RSA Domain Secure Site CA" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + 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@v4 + - uses: vmactions/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/openindiana-vm@v1 + with: + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install socat curl + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: onError + 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/PebbleStrict.yml b/.github/workflows/PebbleStrict.yml index b0326332..729874ce 100644 --- a/.github/workflows/PebbleStrict.yml +++ b/.github/workflows/PebbleStrict.yml @@ -65,7 +65,7 @@ jobs: run: | docker run --rm -itd --name=pebble \ -e PEBBLE_VA_ALWAYS_VALID=1 \ - -p 14000:14000 -p 15000:15000 letsencrypt/pebble:latest pebble -config /test/config/pebble-config.json -strict + -p 14000:14000 -p 15000:15000 ghcr.io/letsencrypt/pebble:latest -config /test/config/pebble-config.json -strict - 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/Solaris.yml b/.github/workflows/Solaris.yml index 95bcd8d1..2388da71 100644 --- a/.github/workflows/Solaris.yml +++ b/.github/workflows/Solaris.yml @@ -66,10 +66,16 @@ jobs: envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" - prepare: pkgutil -y -i socat curl wget - copyback: false + prepare: | + pkgutil -U + pkgutil -y -i socat curl wget + sync: nfs run: | cd ../acmetest \ && ./letest.sh - + - name: onError + 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/dockerhub.yml b/.github/workflows/dockerhub.yml index 435fd6b5..49173b4b 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -44,6 +44,8 @@ jobs: steps: - name: checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up QEMU uses: docker/setup-qemu-action@v2 - name: Extract Docker metadata diff --git a/.github/workflows/pr_dns.yml b/.github/workflows/pr_dns.yml index 58630e8b..558ebf48 100644 --- a/.github/workflows/pr_dns.yml +++ b/.github/workflows/pr_dns.yml @@ -11,6 +11,7 @@ on: jobs: welcome: runs-on: ubuntu-latest + if: github.actor != 'neilpang' steps: - uses: actions/github-script@v6 with: @@ -20,12 +21,14 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, body: `**Welcome** - First thing: don't send PR to the master branch, please send to the dev branch instead. - Please make sure you've read our [DNS API Dev Guide](../wiki/DNS-API-Dev-Guide) and [DNS-API-Test](../wiki/DNS-API-Test). - Then reply on this message, otherwise, your code will not be reviewed or merged. - Please also make sure to add/update the usage here: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2 - We look forward to reviewing your Pull request shortly ✨ - 注意: 必须通过了 [DNS-API-Test](../wiki/DNS-API-Test) 才会被 review. 无论是修改, 还是新加的 dns api, 都必须确保通过这个测试. + READ ME !!!!! + Read me !!!!!! + First thing: don't send PR to the master branch, please send to the dev branch instead. + Please read the [DNS API Dev Guide](../wiki/DNS-API-Dev-Guide). + You MUST pass the [DNS-API-Test](../wiki/DNS-API-Test). + Then reply on this message, otherwise, your code will not be reviewed or merged. + Please also make sure to add/update the usage here: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2 + 注意: 必须通过了 [DNS-API-Test](../wiki/DNS-API-Test) 才会被 review. 无论是修改, 还是新加的 dns api, 都必须确保通过这个测试. ` }) diff --git a/.github/workflows/pr_notify.yml b/.github/workflows/pr_notify.yml index b6b03c67..416ed721 100644 --- a/.github/workflows/pr_notify.yml +++ b/.github/workflows/pr_notify.yml @@ -13,6 +13,7 @@ on: jobs: welcome: runs-on: ubuntu-latest + if: github.actor != 'neilpang' steps: - uses: actions/github-script@v6 with: diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml new file mode 100644 index 00000000..59cd0e5b --- /dev/null +++ b/.github/workflows/wiki-monitor.yml @@ -0,0 +1,66 @@ +name: Notify via Issue on Wiki Edit + +on: + gollum: + +jobs: + notify: + runs-on: ubuntu-latest + if: github.actor != 'neilpang' + steps: + - name: Checkout wiki repository + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }}.wiki + path: wiki + fetch-depth: 0 + + - name: Generate wiki change message + run: | + actor="${{ github.actor }}" + sender_url=$(jq -r '.sender.html_url' "$GITHUB_EVENT_PATH") + page_name=$(jq -r '.pages[0].page_name' "$GITHUB_EVENT_PATH") + page_sha=$(jq -r '.pages[0].sha' "$GITHUB_EVENT_PATH") + page_url=$(jq -r '.pages[0].html_url' "$GITHUB_EVENT_PATH") + page_action=$(jq -r '.pages[0].action' "$GITHUB_EVENT_PATH") + page_summary=$(jq -r '.pages[0].summary' "$GITHUB_EVENT_PATH") + now="$(date '+%Y-%m-%d %H:%M:%S')" + + cd wiki + prev_sha=$(git rev-list $page_sha^ -- "$page_name.md" | head -n 1) + if [ -n "$prev_sha" ]; then + git diff $prev_sha $page_sha -- "$page_name.md" > ../wiki.diff || echo "(No diff found)" > ../wiki.diff + else + echo "(no diff)" > ../wiki.diff + fi + cd .. + { + echo "Wiki edited" + echo -n "User: " + echo "@$actor [$actor]($sender_url)" + echo "Time: $now" + echo "Page: [$page_name]($page_url) (Action: $page_action)" + echo "Comment: $page_summary" + echo "[Click here to Revert](${page_url}/_history)" + echo "" + echo "----" + echo "### diff:" + echo '```diff' + cat wiki.diff + echo '```' + } > wiki-change-msg.txt + + - name: Create issue to notify Neilpang + uses: peter-evans/create-issue-from-file@v5 + with: + title: "Wiki edited" + content-filepath: ./wiki-change-msg.txt + assignees: Neilpang + env: + TZ: Asia/Shanghai + + + + + + diff --git a/Dockerfile b/Dockerfile index 2ad50e6a..36b2adac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.17 +FROM alpine:3.22 RUN apk --no-cache add -f \ openssl \ @@ -13,20 +13,27 @@ RUN apk --no-cache add -f \ tar \ libidn \ jq \ + yq-go \ cronie -ENV LE_CONFIG_HOME /acme.sh +ENV LE_WORKING_DIR=/acmebin + +ENV LE_CONFIG_HOME=/acme.sh ARG AUTO_UPGRADE=1 -ENV AUTO_UPGRADE $AUTO_UPGRADE +ENV AUTO_UPGRADE=$AUTO_UPGRADE #Install -COPY ./ /install_acme.sh/ +COPY ./acme.sh /install_acme.sh/acme.sh +COPY ./deploy /install_acme.sh/deploy +COPY ./dnsapi /install_acme.sh/dnsapi +COPY ./notify /install_acme.sh/notify + RUN cd /install_acme.sh && ([ -f /install_acme.sh/acme.sh ] && /install_acme.sh/acme.sh --install || curl https://get.acme.sh | sh) && rm -rf /install_acme.sh/ -RUN ln -s /root/.acme.sh/acme.sh /usr/local/bin/acme.sh && crontab -l | grep acme.sh | sed 's#> /dev/null#> /proc/1/fd/1 2>/proc/1/fd/2#' | crontab - +RUN ln -s $LE_WORKING_DIR/acme.sh /usr/local/bin/acme.sh && crontab -l | grep acme.sh | sed 's#> /dev/null#> /proc/1/fd/1 2>/proc/1/fd/2#' | crontab - RUN for verb in help \ version \ @@ -60,7 +67,7 @@ RUN for verb in help \ set-default-ca \ set-default-chain \ ; do \ - printf -- "%b" "#!/usr/bin/env sh\n/root/.acme.sh/acme.sh --${verb} --config-home /acme.sh \"\$@\"" >/usr/local/bin/--${verb} && chmod +x /usr/local/bin/--${verb} \ + printf -- "%b" "#!/usr/bin/env sh\n$LE_WORKING_DIR/acme.sh --${verb} --config-home $LE_CONFIG_HOME \"\$@\"" >/usr/local/bin/--${verb} && chmod +x /usr/local/bin/--${verb} \ ; done RUN printf "%b" '#!'"/usr/bin/env sh\n \ @@ -68,7 +75,7 @@ if [ \"\$1\" = \"daemon\" ]; then \n \ exec crond -n -s -m off \n \ else \n \ exec -- \"\$@\"\n \ -fi\n" >/entry.sh && chmod +x /entry.sh +fi\n" >/entry.sh && chmod +x /entry.sh && chmod -R o+rwx $LE_WORKING_DIR && chmod -R o+rwx $LE_CONFIG_HOME VOLUME /acme.sh diff --git a/README.md b/README.md index 9a5c106b..740e5ef0 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,72 @@ -# An ACME Shell script: acme.sh - -[![FreeBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/FreeBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/FreeBSD.yml) -[![OpenBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenBSD.yml) -[![NetBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/NetBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/NetBSD.yml) -[![MacOS](https://github.com/acmesh-official/acme.sh/actions/workflows/MacOS.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/MacOS.yml) -[![Ubuntu](https://github.com/acmesh-official/acme.sh/actions/workflows/Ubuntu.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Ubuntu.yml) -[![Windows](https://github.com/acmesh-official/acme.sh/actions/workflows/Windows.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Windows.yml) -[![Solaris](https://github.com/acmesh-official/acme.sh/actions/workflows/Solaris.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Solaris.yml) -[![DragonFlyBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml) -[![Omnios](https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml) - -![Shellcheck](https://github.com/acmesh-official/acme.sh/workflows/Shellcheck/badge.svg) -![PebbleStrict](https://github.com/acmesh-official/acme.sh/workflows/PebbleStrict/badge.svg) -![DockerHub](https://github.com/acmesh-official/acme.sh/workflows/Build%20DockerHub/badge.svg) - - - -[![Join the chat at https://gitter.im/acme-sh/Lobby](https://badges.gitter.im/acme-sh/Lobby.svg)](https://gitter.im/acme-sh/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![Docker stars](https://img.shields.io/docker/stars/neilpang/acme.sh.svg)](https://hub.docker.com/r/neilpang/acme.sh "Click to view the image on Docker Hub") -[![Docker pulls](https://img.shields.io/docker/pulls/neilpang/acme.sh.svg)](https://hub.docker.com/r/neilpang/acme.sh "Click to view the image on Docker Hub") - - - -- An ACME protocol client written purely in Shell (Unix shell) language. -- Full ACME protocol implementation. -- Support ECDSA certs -- Support SAN and wildcard certs -- Simple, powerful and very easy to use. You only need 3 minutes to learn it. -- Bash, dash and sh compatible. -- Purely written in Shell with no dependencies on python. -- Just one script to issue, renew and install your certificates automatically. -- DOES NOT require `root/sudoer` access. -- Docker ready -- IPv6 ready -- Cron job notifications for renewal or error etc. - -It's probably the `easiest & smartest` shell script to automatically issue & renew the free certificates. - -Wiki: https://github.com/acmesh-official/acme.sh/wiki - -For Docker Fans: [acme.sh :two_hearts: Docker ](https://github.com/acmesh-official/acme.sh/wiki/Run-acme.sh-in-docker) - -Twitter: [@neilpangxa](https://twitter.com/neilpangxa) - - -# [中文说明](https://github.com/acmesh-official/acme.sh/wiki/%E8%AF%B4%E6%98%8E) - -# Who: +

+ + zerossl.com + +

+ +

🔐 acme.sh

+

An ACME Protocol Client Written Purely in Shell

+ +

+ FreeBSD + OpenBSD + NetBSD + MacOS + Ubuntu + Windows + Solaris + DragonFlyBSD + Omnios + OpenIndiana + Haiku +

+ +

+ Shellcheck + PebbleStrict + DockerHub +

+ +

+ Financial Contributors on Open Collective + Join the chat at Gitter + Docker stars + Docker pulls +

+ + +--- + +## ✨ Features + +- 🐚 An ACME protocol client written **purely in Shell** (Unix shell) language +- 📜 Full ACME protocol implementation +- 🔑 Support **ECDSA** certificates +- 🌐 Support **SAN** and **wildcard** certificates +- ⚡ Simple, powerful and very easy to use — only **3 minutes** to learn! +- 🔧 Compatible with **Bash**, **dash** and **sh** +- 🚫 No dependencies on Python +- 🔄 One script to issue, renew and install your certificates automatically +- 👤 **DOES NOT** require `root/sudoer` access +- 🐳 Docker ready +- 🌍 IPv6 ready +- 📧 Cron job notifications for renewal or error + +> 💡 It's probably the **easiest & smartest** shell script to automatically issue & renew free certificates. + +

+ 📚 Wiki • + 🐳 Docker Guide • + 🐦 Twitter +

+ +--- + +## 🌏 [中文说明](https://github.com/acmesh-official/acme.sh/wiki/%E8%AF%B4%E6%98%8E) + +--- + +## 🏆 Who Uses acme.sh? - [FreeBSD.org](https://blog.crashed.org/letsencrypt-in-freebsd-org/) - [ruby-china.org](https://ruby-china.org/topics/31983) - [Proxmox](https://pve.proxmox.com/wiki/Certificate_Management) @@ -60,7 +80,9 @@ Twitter: [@neilpangxa](https://twitter.com/neilpangxa) - [lnmp.org](https://lnmp.org/) - [more...](https://github.com/acmesh-official/acme.sh/wiki/Blogs-and-tutorials) -# Tested OS +--- + +## 🖥️ Tested OS | NO | Status| Platform| |----|-------|---------| @@ -74,8 +96,8 @@ Twitter: [@neilpangxa](https://twitter.com/neilpangxa) |8|[![NetBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/NetBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/NetBSD.yml)|NetBSD |9|[![DragonFlyBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml)|DragonFlyBSD |10|[![Omnios](https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml)|Omnios -|11|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)| Debian -|12|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|CentOS +|11|[![OpenIndiana](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenIndiana.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenIndiana.yml)|OpenIndiana +|12|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)| Debian |13|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|openSUSE |14|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Alpine Linux (with curl) |15|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Archlinux @@ -83,57 +105,67 @@ Twitter: [@neilpangxa](https://twitter.com/neilpangxa) |17|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Kali Linux |18|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Oracle Linux |19|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Mageia -|10|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Gentoo Linux -|11|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|ClearLinux -|22|-----| Cloud Linux https://github.com/acmesh-official/acme.sh/issues/111 -|23|-----| OpenWRT: Tested and working. See [wiki page](https://github.com/acmesh-official/acme.sh/wiki/How-to-run-on-OpenWRT) -|24|[![](https://acmesh-official.github.io/acmetest/status/proxmox.svg)](https://github.com/acmesh-official/letest#here-are-the-latest-status)| Proxmox: See Proxmox VE Wiki. Version [4.x, 5.0, 5.1](https://pve.proxmox.com/wiki/HTTPS_Certificate_Configuration_(Version_4.x,_5.0_and_5.1)#Let.27s_Encrypt_using_acme.sh), version [5.2 and up](https://pve.proxmox.com/wiki/Certificate_Management) +|20|[![Linux](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Linux.yml)|Gentoo Linux +|21|-----| Cloud Linux https://github.com/acmesh-official/acme.sh/issues/111 +|22|-----| OpenWRT: Tested and working. See [wiki page](https://github.com/acmesh-official/acme.sh/wiki/How-to-run-on-OpenWRT) +|23|[![](https://acmesh-official.github.io/acmetest/status/proxmox.svg)](https://github.com/acmesh-official/letest#here-are-the-latest-status)| Proxmox: See Proxmox VE Wiki. Version [4.x, 5.0, 5.1](https://pve.proxmox.com/wiki/HTTPS_Certificate_Configuration_(Version_4.x,_5.0_and_5.1)#Let.27s_Encrypt_using_acme.sh), version [5.2 and up](https://pve.proxmox.com/wiki/Certificate_Management) +|24|[![Haiku](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS -Check our [testing project](https://github.com/acmesh-official/acmetest): +> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) +> +> 🖥️ The testing VMs are supported by [vmactions.org](https://vmactions.org) -https://github.com/acmesh-official/acmetest +--- -# Supported CA +## 🏛️ Supported CA -- [ZeroSSL.com CA](https://github.com/acmesh-official/acme.sh/wiki/ZeroSSL.com-CA)(default) -- Letsencrypt.org CA -- [BuyPass.com CA](https://github.com/acmesh-official/acme.sh/wiki/BuyPass.com-CA) -- [SSL.com CA](https://github.com/acmesh-official/acme.sh/wiki/SSL.com-CA) -- [Google.com Public CA](https://github.com/acmesh-official/acme.sh/wiki/Google-Public-CA) -- [Pebble strict Mode](https://github.com/letsencrypt/pebble) -- Any other [RFC8555](https://tools.ietf.org/html/rfc8555)-compliant CA +| CA | Status | +|---|---| +| [ZeroSSL.com CA](https://github.com/acmesh-official/acme.sh/wiki/ZeroSSL.com-CA) | ⭐ **Default** | +| Letsencrypt.org CA | ✅ Supported | +| [SSL.com CA](https://github.com/acmesh-official/acme.sh/wiki/SSL.com-CA) | ✅ Supported | +| [Google.com Public CA](https://github.com/acmesh-official/acme.sh/wiki/Google-Public-CA) | ✅ Supported | +| [Actalis.com CA](https://github.com/acmesh-official/acme.sh/wiki/Actalis.com-CA) | ✅ Supported | +| [Pebble strict Mode](https://github.com/letsencrypt/pebble) | ✅ Supported | +| Any [RFC8555](https://tools.ietf.org/html/rfc8555)-compliant CA | ✅ Supported | -# Supported modes +--- -- Webroot mode -- Standalone mode -- Standalone tls-alpn mode -- Apache mode -- Nginx mode -- DNS mode -- [DNS alias mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode) -- [Stateless mode](https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode) +## ⚙️ Supported Modes +| Mode | Description | +|------|-------------| +| 📁 Webroot mode | Use existing webroot directory | +| 🖥️ Standalone mode | Built-in webserver on port 80 | +| 🔐 Standalone tls-alpn mode | Built-in webserver on port 443 | +| 🪶 Apache mode | Use Apache for verification | +| ⚡ Nginx mode | Use Nginx for verification | +| 🌐 DNS mode | Use DNS TXT records | +| 🔗 [DNS alias mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode) | Use DNS alias for verification | +| 📡 [Stateless mode](https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode) | Stateless verification | -# 1. How to install +--- -### 1. Install online +## 📖 Usage Guide -Check this project: https://github.com/acmesh-official/get.acme.sh +### 1️⃣ How to Install + +#### 📥 Install Online + +> Check this project: https://github.com/acmesh-official/get.acme.sh ```bash curl https://get.acme.sh | sh -s email=my@example.com ``` -Or: +**Or:** ```bash wget -O - https://get.acme.sh | sh -s email=my@example.com ``` - -### 2. Or, Install from git +#### 📦 Install from Git Clone this project and launch installation: @@ -143,11 +175,11 @@ cd ./acme.sh ./acme.sh --install -m my@example.com ``` -You `don't have to be root` then, although `it is recommended`. +> 💡 You `don't have to be root` then, although `it is recommended`. -Advanced Installation: https://github.com/acmesh-official/acme.sh/wiki/How-to-install +📚 **Advanced Installation:** https://github.com/acmesh-official/acme.sh/wiki/How-to-install -The installer will perform 3 actions: +**The installer will perform 3 actions:** 1. Create and copy `acme.sh` to your home dir (`$HOME`): `~/.acme.sh/`. All certs will be placed in this folder too. @@ -160,17 +192,19 @@ Cron entry example: 0 0 * * * "/home/user/.acme.sh"/acme.sh --cron --home "/home/user/.acme.sh" > /dev/null ``` -After the installation, you must close the current terminal and reopen it to make the alias take effect. +> ⚠️ After the installation, you must close the current terminal and reopen it to make the alias take effect. -Ok, you are ready to issue certs now. +✅ **You are ready to issue certs now!** -Show help message: +**Show help message:** ```sh -root@v1:~# acme.sh -h +acme.sh -h ``` -# 2. Just issue a cert +--- + +### 2️⃣ Issue a Certificate **Example 1:** Single domain. @@ -205,17 +239,21 @@ You must point and bind all the domains to the same webroot dir: `/home/wwwroot/ The certs will be placed in `~/.acme.sh/example.com/` -The certs will be renewed automatically every **60** days. +> 🔄 The certs will be renewed automatically every **30** days. + +> 🔐 The certs will default to **ECC** certificates. -More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +📚 **More examples:** https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +--- -# 3. Install the cert to Apache/Nginx etc. +### 3️⃣ Install the Certificate to Apache/Nginx After the cert is generated, you probably want to install/copy the cert to your Apache/Nginx or other servers. -You **MUST** use this command to copy the certs to the target files, **DO NOT** use the certs files in **~/.acme.sh/** folder, they are for internal use only, the folder structure may change in the future. -**Apache** example: +> ⚠️ **IMPORTANT:** You **MUST** use this command to copy the certs to the target files. **DO NOT** use the certs files in `~/.acme.sh/` folder — they are for internal use only, the folder structure may change in the future. + +#### 🪶 Apache Example: ```bash acme.sh --install-cert -d example.com \ --cert-file /path/to/certfile/in/apache/cert.pem \ @@ -224,7 +262,7 @@ acme.sh --install-cert -d example.com \ --reloadcmd "service apache2 force-reload" ``` -**Nginx** example: +#### ⚡ Nginx Example: ```bash acme.sh --install-cert -d example.com \ --key-file /path/to/keyfile/in/nginx/key.pem \ @@ -238,91 +276,89 @@ The ownership and permission info of existing files are preserved. You can pre-c Install/copy the cert/key to the production Apache or Nginx path. -The cert will be renewed every **60** days by default (which is configurable). Once the cert is renewed, the Apache/Nginx service will be reloaded automatically by the command: `service apache2 force-reload` or `service nginx force-reload`. +> 🔄 The cert will be renewed every **30** days by default (configurable). Once renewed, the Apache/Nginx service will be reloaded automatically. +> ⚠️ **IMPORTANT:** The `reloadcmd` is very important. The cert can be automatically renewed, but without a correct `reloadcmd`, the cert may not be flushed to your server (like nginx or apache), then your website will not be able to show the renewed cert. -**Please take care: The reloadcmd is very important. The cert can be automatically renewed, but, without a correct 'reloadcmd' the cert may not be flushed to your server(like nginx or apache), then your website will not be able to show renewed cert in 60 days.** +--- -# 4. Use Standalone server to issue cert +### 4️⃣ Use Standalone Server to Issue Certificate -**(requires you to be root/sudoer or have permission to listen on port 80 (TCP))** +> 🔐 Requires root/sudoer or permission to listen on port **80** (TCP) -Port `80` (TCP) **MUST** be free to listen on, otherwise you will be prompted to free it and try again. +> ⚠️ Port `80` (TCP) **MUST** be free to listen on, otherwise you will be prompted to free it and try again. ```bash acme.sh --issue --standalone -d example.com -d www.example.com -d cp.example.com ``` -More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +📚 **More examples:** https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert + +--- -# 5. Use Standalone ssl server to issue cert +### 5️⃣ Use Standalone TLS Server to Issue Certificate -**(requires you to be root/sudoer or have permission to listen on port 443 (TCP))** +> 🔐 Requires root/sudoer or permission to listen on port **443** (TCP) -Port `443` (TCP) **MUST** be free to listen on, otherwise you will be prompted to free it and try again. +> ⚠️ Port `443` (TCP) **MUST** be free to listen on, otherwise you will be prompted to free it and try again. ```bash acme.sh --issue --alpn -d example.com -d www.example.com -d cp.example.com ``` -More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +📚 **More examples:** https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +--- -# 6. Use Apache mode +### 6️⃣ Use Apache Mode -**(requires you to be root/sudoer, since it is required to interact with Apache server)** +> 🔐 Requires root/sudoer to interact with Apache server If you are running a web server, it is recommended to use the `Webroot mode`. Particularly, if you are running an Apache server, you can use Apache mode instead. This mode doesn't write any files to your web root folder. -Just set string "apache" as the second argument and it will force use of apache plugin automatically. - ```sh acme.sh --issue --apache -d example.com -d www.example.com -d cp.example.com ``` -**This apache mode is only to issue the cert, it will not change your apache config files. -You will need to configure your website config files to use the cert by yourself. -We don't want to mess with your apache server, don't worry.** +> 💡 **Note:** This Apache mode is only to issue the cert, it will **not** change your Apache config files. You will need to configure your website config files to use the cert by yourself. We don't want to mess with your Apache server, don't worry! -More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +📚 **More examples:** https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert -# 7. Use Nginx mode +--- -**(requires you to be root/sudoer, since it is required to interact with Nginx server)** - -If you are running a web server, it is recommended to use the `Webroot mode`. +### 7️⃣ Use Nginx Mode -Particularly, if you are running an nginx server, you can use nginx mode instead. This mode doesn't write any files to your web root folder. +> 🔐 Requires root/sudoer to interact with Nginx server -Just set string "nginx" as the second argument. +If you are running a web server, it is recommended to use the `Webroot mode`. -It will configure nginx server automatically to verify the domain and then restore the nginx config to the original version. +Particularly, if you are running an Nginx server, you can use Nginx mode instead. This mode doesn't write any files to your web root folder. -So, the config is not changed. +It will configure Nginx server automatically to verify the domain and then restore the Nginx config to the original version. So, the config is not changed. ```sh acme.sh --issue --nginx -d example.com -d www.example.com -d cp.example.com ``` -**This nginx mode is only to issue the cert, it will not change your nginx config files. -You will need to configure your website config files to use the cert by yourself. -We don't want to mess with your nginx server, don't worry.** +> 💡 **Note:** This Nginx mode is only to issue the cert, it will **not** change your Nginx config files. You will need to configure your website config files to use the cert by yourself. We don't want to mess with your Nginx server, don't worry! + +📚 **More examples:** https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert -More examples: https://github.com/acmesh-official/acme.sh/wiki/How-to-issue-a-cert +--- -# 8. Automatic DNS API integration +### 8️⃣ Automatic DNS API Integration If your DNS provider supports API access, we can use that API to automatically issue the certs. -You don't have to do anything manually! +> ✨ **You don't have to do anything manually!** -### Currently acme.sh supports most of the dns providers: +📚 **Currently acme.sh supports most DNS providers:** https://github.com/acmesh-official/acme.sh/wiki/dnsapi -https://github.com/acmesh-official/acme.sh/wiki/dnsapi +--- -# 9. Use DNS manual mode: +### 9️⃣ Use DNS Manual Mode See: https://github.com/acmesh-official/acme.sh/wiki/dns-manual-mode first. @@ -352,70 +388,74 @@ Then just rerun with `renew` argument: acme.sh --renew -d example.com ``` -Ok, it's done. +✅ **Done!** -**Take care, this is dns manual mode, it can not be renewed automatically. you will have to add a new txt record to your domain by your hand when you renew your cert.** +> ⚠️ **WARNING:** This is DNS manual mode — it **cannot** be renewed automatically. You will have to add a new TXT record to your domain manually when you renew your cert. **Please use DNS API mode instead.** -**Please use dns api mode instead.** +--- -# 10. Issue ECC certificates +### 🔟 Issue Certificates of Different Key Types (ECC or RSA) -Just set the `keylength` parameter with a prefix `ec-`. +Just set the `keylength` to a valid, supported value. -For example: +**Valid values for the `keylength` parameter:** -### Single domain ECC certificate +| Key Length | Description | +|------------|-------------| +| `ec-256` | prime256v1, "ECDSA P-256" ⭐ **Default** | +| `ec-384` | secp384r1, "ECDSA P-384" | +| `ec-521` | secp521r1, "ECDSA P-521" ⚠️ Not supported by Let's Encrypt yet | +| `2048` | RSA 2048-bit | +| `3072` | RSA 3072-bit | +| `4096` | RSA 4096-bit | -```bash -acme.sh --issue -w /home/wwwroot/example.com -d example.com --keylength ec-256 -``` +**Examples:** -### SAN multi domain ECC certificate +#### Single domain with ECDSA P-384 certificate ```bash -acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com --keylength ec-256 +acme.sh --issue -w /home/wwwroot/example.com -d example.com --keylength ec-384 ``` -Please look at the `keylength` parameter above. - -Valid values are: +#### SAN multi domain with RSA4096 certificate -1. **ec-256 (prime256v1, "ECDSA P-256", which is the default key type)** -2. **ec-384 (secp384r1, "ECDSA P-384")** -3. **ec-521 (secp521r1, "ECDSA P-521", which is not supported by Let's Encrypt yet.)** -4. **2048 (RSA2048)** -5. **3072 (RSA3072)** -6. **4096 (RSA4096)** +```bash +acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com --keylength 4096 +``` +--- -# 11. Issue Wildcard certificates +### 1️⃣1️⃣ Issue Wildcard Certificates -It's simple, just give a wildcard domain as the `-d` parameter. +It's simple! Just give a wildcard domain as the `-d` parameter: ```sh -acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf +acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf ``` -# 12. How to renew the certs +--- + +### 1️⃣2️⃣ How to Renew Certificates -No, you don't need to renew the certs manually. All the certs will be renewed automatically every **60** days. +> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days. -However, you can also force to renew a cert: +However, you can force a renewal: ```sh acme.sh --renew -d example.com --force ``` -or, for ECC cert: +**For ECC cert:** ```sh acme.sh --renew -d example.com --force --ecc ``` +--- -# 13. How to stop cert renewal +### 1️⃣3️⃣ How to Stop Certificate Renewal To stop renewal of a cert, you can execute the following to remove the cert from the renewal list: @@ -425,73 +465,78 @@ acme.sh --remove -d example.com [--ecc] The cert/key file is not removed from the disk. -You can remove the respective directory (e.g. `~/.acme.sh/example.com`) by yourself. +> 💡 You can remove the respective directory (e.g. `~/.acme.sh/example.com`) manually. +--- -# 14. How to upgrade `acme.sh` +### 1️⃣4️⃣ How to Upgrade acme.sh -acme.sh is in constant development, so it's strongly recommended to use the latest code. +> 🚀 acme.sh is in constant development — it's strongly recommended to use the latest code. -You can update acme.sh to the latest code: +**Update to latest:** ```sh acme.sh --upgrade ``` -You can also enable auto upgrade: +**Enable auto upgrade:** ```sh acme.sh --upgrade --auto-upgrade ``` -Then **acme.sh** will be kept up to date automatically. - -Disable auto upgrade: +**Disable auto upgrade:** ```sh acme.sh --upgrade --auto-upgrade 0 ``` +--- -# 15. Issue a cert from an existing CSR - -https://github.com/acmesh-official/acme.sh/wiki/Issue-a-cert-from-existing-CSR +### 1️⃣5️⃣ Issue a Certificate from an Existing CSR +📚 https://github.com/acmesh-official/acme.sh/wiki/Issue-a-cert-from-existing-CSR -# 16. Send notifications in cronjob +--- -https://github.com/acmesh-official/acme.sh/wiki/notify +### 1️⃣6️⃣ Send Notifications in Cronjob +📚 https://github.com/acmesh-official/acme.sh/wiki/notify -# 17. Under the Hood +--- -Speak ACME language using shell, directly to "Let's Encrypt". +### 1️⃣7️⃣ Under the Hood -TODO: +> 🔧 Speak ACME language using shell, directly to "Let's Encrypt". +--- -# 18. Acknowledgments +### 1️⃣8️⃣ Acknowledgments -1. Acme-tiny: https://github.com/diafygi/acme-tiny -2. ACME protocol: https://github.com/ietf-wg-acme/acme +| Project | Link | +|---------|------| +| 🙏 Acme-tiny | https://github.com/diafygi/acme-tiny | +| 📜 ACME protocol | https://github.com/ietf-wg-acme/acme | +--- -## Contributors +## 👥 Contributors -### Code Contributors +### 💻 Code Contributors This project exists thanks to all the people who contribute. + -### Financial Contributors +### 💰 Financial Contributors Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/acmesh/contribute)] -#### Individuals +#### 👤 Individuals -#### Organizations +#### 🏢 Organizations Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/acmesh/contribute)] @@ -506,20 +551,43 @@ Support this project with your organization. Your logo will show up here with a +--- + +### 1️⃣9️⃣ License & Others + +📄 **License:** GPLv3 + +⭐ Please **Star** and **Fork** this project! +🐛 [Issues](https://github.com/acmesh-official/acme.sh/issues) and 🔀 [Pull Requests](https://github.com/acmesh-official/acme.sh/pulls) are welcome. -# 19. License & Others +--- -License is GPLv3 +### 2️⃣0️⃣ Donate -Please Star and Fork me. +> 💝 Your donation makes **acme.sh** better! -[Issues](https://github.com/acmesh-official/acme.sh/issues) and [pull requests](https://github.com/acmesh-official/acme.sh/pulls) are welcome. +| Method | Link | +|--------|------| +| PayPal / Alipay(支付宝) / Wechat(微信) | [https://donate.acme.sh/](https://donate.acme.sh/) | +📜 [Donate List](https://github.com/acmesh-official/acme.sh/wiki/Donate-list) -# 20. Donate -Your donation makes **acme.sh** better: +--- -1. PayPal/Alipay(支付宝)/Wechat(微信): [https://donate.acme.sh/](https://donate.acme.sh/) +### 2️⃣1️⃣ About This Repository -[Donate List](https://github.com/acmesh-official/acme.sh/wiki/Donate-list) +> [!NOTE] +> This repository is officially maintained by ZeroSSL as part of our commitment to providing secure and reliable SSL/TLS solutions. We welcome contributions and feedback from the community! +> For more information about our services, including free and paid SSL/TLS certificates, visit https://zerossl.com. +> +> All donations made through this repository go directly to the original independent maintainer (Neil Pang), not to ZeroSSL. +

+ + + + + ZeroSSL + + +

diff --git a/acme.sh b/acme.sh index 9842e3f1..7d9ee2d2 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.0 +VER=3.1.3 PROJECT_NAME="acme.sh" @@ -23,9 +23,6 @@ _SUB_FOLDERS="$_SUB_FOLDER_DNSAPI $_SUB_FOLDER_DEPLOY $_SUB_FOLDER_NOTIFY" CA_LETSENCRYPT_V2="https://acme-v02.api.letsencrypt.org/directory" CA_LETSENCRYPT_V2_TEST="https://acme-staging-v02.api.letsencrypt.org/directory" -CA_BUYPASS="https://api.buypass.com/acme/directory" -CA_BUYPASS_TEST="https://api.test4.buypass.no/acme/directory" - CA_ZEROSSL="https://acme.zerossl.com/v2/DV90" _ZERO_EAB_ENDPOINT="https://api.zerossl.com/acme/eab-credentials-email" @@ -35,6 +32,8 @@ CA_SSLCOM_ECC="https://acme.ssl.com/sslcom-dv-ecc" CA_GOOGLE="https://dv.acme-v02.api.pki.goog/directory" CA_GOOGLE_TEST="https://dv.acme-v02.test-api.pki.goog/directory" +CA_ACTALIS="https://acme-api.actalis.com/acme/directory" + DEFAULT_CA=$CA_ZEROSSL DEFAULT_STAGING_CA=$CA_LETSENCRYPT_V2_TEST @@ -42,14 +41,13 @@ CA_NAMES=" ZeroSSL.com,zerossl LetsEncrypt.org,letsencrypt LetsEncrypt.org_test,letsencrypt_test,letsencrypttest -BuyPass.com,buypass -BuyPass.com_test,buypass_test,buypasstest SSL.com,sslcom Google.com,google Google.com_test,googletest,google_test +Actalis.com,actalis.com,actalis " -CA_SERVERS="$CA_ZEROSSL,$CA_LETSENCRYPT_V2,$CA_LETSENCRYPT_V2_TEST,$CA_BUYPASS,$CA_BUYPASS_TEST,$CA_SSLCOM_RSA,$CA_GOOGLE,$CA_GOOGLE_TEST" +CA_SERVERS="$CA_ZEROSSL,$CA_LETSENCRYPT_V2,$CA_LETSENCRYPT_V2_TEST,$CA_SSLCOM_RSA,$CA_GOOGLE,$CA_GOOGLE_TEST,$CA_ACTALIS" DEFAULT_USER_AGENT="$PROJECT_NAME/$VER ($PROJECT)" @@ -67,7 +65,7 @@ ID_TYPE_IP="ip" LOCAL_ANY_ADDRESS="0.0.0.0" -DEFAULT_RENEW=60 +DEFAULT_RENEW=30 NO_VALUE="no" @@ -180,6 +178,8 @@ _VALIDITY_WIKI="https://github.com/acmesh-official/acme.sh/wiki/Validity" _DNSCHECK_WIKI="https://github.com/acmesh-official/acme.sh/wiki/dnscheck" +_PROFILESELECTION_WIKI="https://github.com/acmesh-official/acme.sh/wiki/Profile-selection" + _DNS_MANUAL_ERR="The dns manual mode can not renew automatically, you must issue it again manually. You'd better use the other modes instead." _DNS_MANUAL_WARN="It seems that you are using dns manual mode. please take care: $_DNS_MANUAL_ERR" @@ -250,6 +250,13 @@ _dlg_versions() { socat -V 2>&1 else _debug "socat doesn't exist." + if _exists "python3"; then + python3 -V 2>&1 + elif _exists "python2"; then + python2 -V 2>&1 + elif _exists "python"; then + python -V 2>&1 + fi fi } @@ -436,14 +443,28 @@ _secure_debug3() { fi } +__USE_TR_TAG="" +if [ "$(echo "abc" | LANG=C tr a-z A-Z 2>/dev/null)" != "ABC" ]; then + __USE_TR_TAG="1" +fi +export __USE_TR_TAG + _upper_case() { - # shellcheck disable=SC2018,SC2019 - tr '[a-z]' '[A-Z]' + if [ "$__USE_TR_TAG" ]; then + LANG=C tr '[:lower:]' '[:upper:]' + else + # shellcheck disable=SC2018,SC2019 + LANG=C tr '[a-z]' '[A-Z]' + fi } _lower_case() { - # shellcheck disable=SC2018,SC2019 - tr '[A-Z]' '[a-z]' + if [ "$__USE_TR_TAG" ]; then + LANG=C tr '[:upper:]' '[:lower:]' + else + # shellcheck disable=SC2018,SC2019 + LANG=C tr '[A-Z]' '[a-z]' + fi } _startswith() { @@ -921,6 +942,9 @@ _sed_i() { if sed -h 2>&1 | grep "\-i\[SUFFIX]" >/dev/null 2>&1; then _debug "Using sed -i" sed -i "$options" "$filename" + elif sed -h 2>&1 | grep "\-i extension" >/dev/null 2>&1; then + _debug "Using FreeBSD sed -i" + sed -i "" "$options" "$filename" else _debug "No -i support in sed" text="$(cat "$filename")" @@ -1014,7 +1038,7 @@ _digest() { outputhex="$2" - if [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then + if [ "$alg" = "sha3-256" ] || [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then if [ "$outputhex" ]; then ${ACME_OPENSSL_BIN:-openssl} dgst -"$alg" -hex | cut -d = -f 2 | tr -d ' ' else @@ -1233,7 +1257,7 @@ _idn() { fi } -#_createcsr cn san_list keyfile csrfile conf acmeValidationv1 +#_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage _createcsr() { _debug _createcsr domain="$1" @@ -1242,6 +1266,7 @@ _createcsr() { csr="$4" csrconf="$5" acmeValidationv1="$6" + extusage="$7" _debug2 domain "$domain" _debug2 domainlist "$domainlist" _debug2 csrkey "$csrkey" @@ -1250,9 +1275,8 @@ _createcsr() { printf "[ req_distinguished_name ]\n[ req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n[ v3_req ]" >"$csrconf" - if [ "$Le_ExtKeyUse" ]; then - _savedomainconf Le_ExtKeyUse "$Le_ExtKeyUse" - printf "\nextendedKeyUsage=$Le_ExtKeyUse\n" >>"$csrconf" + if [ "$extusage" ]; then + printf "\nextendedKeyUsage=$extusage\n" >>"$csrconf" else printf "\nextendedKeyUsage=serverAuth,clientAuth\n" >>"$csrconf" fi @@ -1398,6 +1422,12 @@ _ss() { return 0 fi + if [ "$(uname)" = "AIX" ]; then + _debug "Using: AIX netstat" + netstat -an | grep "^tcp" | grep "LISTEN" | grep "\.$_port " + return 0 + fi + if _exists "netstat"; then _debug "Using: netstat" if netstat -help 2>&1 | grep "\-p proto" >/dev/null; then @@ -1443,7 +1473,7 @@ _toPkcs() { ${ACME_OPENSSL_BIN:-openssl} pkcs12 -export -out "$_cpfx" -inkey "$_ckey" -in "$_ccert" -certfile "$_cca" fi if [ "$?" = "0" ]; then - _savedomainconf "Le_PFXPassword" "$pfxPassword" + _savedomainconf "Le_PFXPassword" "$pfxPassword" "base64" fi } @@ -1802,6 +1832,10 @@ _time() { # 2022-04-01 08:10:33 to 1648800633 #or 2022-04-01T08:10:33Z to 1648800633 _date2time() { + #Mac/BSD + if date -u -j -f "%Y-%m-%d %H:%M:%S" "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then + return + fi #Linux if date -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then return @@ -1811,10 +1845,6 @@ _date2time() { if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then return fi - #Mac/BSD - if date -u -j -f "%Y-%m-%d %H:%M:%S" "$(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 return @@ -1874,6 +1904,11 @@ _inithttp() { if [ -z "$_ACME_CURL" ] && _exists "curl"; then _ACME_CURL="curl --silent --dump-header $HTTP_HEADER " + if [ "$ACME_USE_IPV6_REQUESTS" ]; then + _ACME_CURL="$_ACME_CURL --ipv6 " + elif [ "$ACME_USE_IPV4_REQUESTS" ]; then + _ACME_CURL="$_ACME_CURL --ipv4 " + fi if [ -z "$ACME_HTTP_NO_REDIRECTS" ]; then _ACME_CURL="$_ACME_CURL -L " fi @@ -1901,6 +1936,11 @@ _inithttp() { if [ -z "$_ACME_WGET" ] && _exists "wget"; then _ACME_WGET="wget -q" + if [ "$ACME_USE_IPV6_REQUESTS" ]; then + _ACME_WGET="$_ACME_WGET --inet6-only " + elif [ "$ACME_USE_IPV4_REQUESTS" ]; then + _ACME_WGET="$_ACME_WGET --inet4-only " + fi if [ "$ACME_HTTP_NO_REDIRECTS" ]; then _ACME_WGET="$_ACME_WGET --max-redirect 0 " fi @@ -2318,6 +2358,7 @@ _setopt() { fi if [ ! -f "$__conf" ]; then touch "$__conf" + chmod 600 "$__conf" fi if [ -n "$(_tail_c 1 <"$__conf")" ]; then echo >>"$__conf" @@ -2526,37 +2567,76 @@ _startserver() { _debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V6 "$Le_Listen_V6" - _NC="socat" - if [ "$Le_Listen_V6" ]; then - _NC="$_NC -6" - else - _NC="$_NC -4" - fi + if _exists "socat"; then + _NC="socat" + if [ "$Le_Listen_V6" ]; then + _NC="$_NC -6" + SOCAT_OPTIONS=TCP6-LISTEN + elif [ "$Le_Listen_V4" ]; then + _NC="$_NC -4" + SOCAT_OPTIONS=TCP4-LISTEN + else + SOCAT_OPTIONS=TCP-LISTEN + fi - if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then - _NC="$_NC -d -d -v" - fi + if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then + _NC="$_NC -d -d -v" + fi - SOCAT_OPTIONS=TCP-LISTEN:$Le_HTTPPort,crlf,reuseaddr,fork + SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork - #Adding bind to local-address - if [ "$ncaddr" ]; then - SOCAT_OPTIONS="$SOCAT_OPTIONS,bind=${ncaddr}" - fi + #Adding bind to local-address + if [ "$ncaddr" ]; then + SOCAT_OPTIONS="$SOCAT_OPTIONS,bind=${ncaddr}" + fi - _content_len="$(printf "%s" "$content" | wc -c)" - _debug _content_len "$_content_len" - _debug "_NC" "$_NC $SOCAT_OPTIONS" - export _SOCAT_ERR="$(_mktemp)" - $_NC $SOCAT_OPTIONS SYSTEM:"sleep 1; \ + _content_len="$(printf "%s" "$content" | wc -c)" + _debug _content_len "$_content_len" + _debug "_NC" "$_NC $SOCAT_OPTIONS" + export _SOCAT_ERR="$(_mktemp)" + $_NC $SOCAT_OPTIONS SYSTEM:"sleep 1; \ echo 'HTTP/1.0 200 OK'; \ echo 'Content-Length\: $_content_len'; \ echo ''; \ printf '%s' '$content';" 2>"$_SOCAT_ERR" & - serverproc="$!" + serverproc="$!" + else + _PYTHON="" + if _exists "python3"; then + _PYTHON="python3" + elif _exists "python2"; then + _PYTHON="python2" + elif _exists "python"; then + _PYTHON="python" + fi + if [ "$_PYTHON" ]; then + _debug "Using python: $_PYTHON" + _AF="socket.AF_INET" + _BIND_ADDR="0.0.0.0" + if [ "$Le_Listen_V6" ]; then + _AF="socket.AF_INET6" + _BIND_ADDR="::" + fi + if [ "$ncaddr" ]; then + _BIND_ADDR="$ncaddr" + fi + export _SOCAT_ERR="$(_mktemp)" + $_PYTHON -c "import socket,sys;s=socket.socket($_AF,socket.SOCK_STREAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((sys.argv[2],int(sys.argv[1])));s.listen(5);res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3]; +while True: + c,a=s.accept() + c.sendall(res.encode() if hasattr(res, 'encode') else res) + c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" & + serverproc="$!" + _NC="$_PYTHON" + else + _err "Please install socat or python first for standalone mode." + return 1 + fi + fi + if [ -f "$_SOCAT_ERR" ]; then if grep "Permission denied" "$_SOCAT_ERR" >/dev/null; then - _err "socat: $(cat $_SOCAT_ERR)" + _err "$_NC: $(cat $_SOCAT_ERR)" _err "Can not listen for user: $(whoami)" _err "Maybe try with root again?" rm -f "$_SOCAT_ERR" @@ -2746,6 +2826,7 @@ _clearAPI() { ACME_REVOKE_CERT="" ACME_NEW_NONCE="" ACME_AGREEMENT="" + ACME_RENEWAL_INFO="" } #server @@ -2758,7 +2839,7 @@ _initAPI() { _request_retry_times=0 while [ -z "$ACME_NEW_ACCOUNT" ] && [ "${_request_retry_times}" -lt "$MAX_API_RETRY_TIMES" ]; do _request_retry_times=$(_math "$_request_retry_times" + 1) - response=$(_get "$_api_server") + response=$(_get "$_api_server" "" 10) if [ "$?" != "0" ]; then _debug2 "response" "$response" _info "Cannot init API for: $_api_server." @@ -2790,6 +2871,9 @@ _initAPI() { ACME_AGREEMENT=$(echo "$response" | _egrep_o 'termsOfService" *: *"[^"]*"' | cut -d '"' -f 3) export ACME_AGREEMENT + ACME_RENEWAL_INFO=$(echo "$response" | _egrep_o 'renewalInfo" *: *"[^"]*"' | cut -d '"' -f 3) + export ACME_RENEWAL_INFO + _debug "ACME_KEY_CHANGE" "$ACME_KEY_CHANGE" _debug "ACME_NEW_AUTHZ" "$ACME_NEW_AUTHZ" _debug "ACME_NEW_ORDER" "$ACME_NEW_ORDER" @@ -2797,6 +2881,7 @@ _initAPI() { _debug "ACME_REVOKE_CERT" "$ACME_REVOKE_CERT" _debug "ACME_AGREEMENT" "$ACME_AGREEMENT" _debug "ACME_NEW_NONCE" "$ACME_NEW_NONCE" + _debug "ACME_RENEWAL_INFO" "$ACME_RENEWAL_INFO" if [ "$ACME_NEW_ACCOUNT" ] && [ "$ACME_NEW_ORDER" ]; then return 0 fi @@ -3504,7 +3589,7 @@ _on_before_issue() { _debug _chk_alt_domains "$_chk_alt_domains" #run pre hook if [ "$_chk_pre_hook" ]; then - _info "Runing pre hook:'$_chk_pre_hook'" + _info "Running pre hook:'$_chk_pre_hook'" if ! ( export Le_Domain="$_chk_main_domain" export Le_Alt="$_chk_alt_domains" @@ -3515,9 +3600,9 @@ _on_before_issue() { fi fi - if _hasfield "$_chk_web_roots" "$NO_VALUE"; then - if ! _exists "socat"; then - _err "Please install socat tools first." + if _hasfield "$_chk_web_roots" "$NO_VALUE" && [ "$_chk_web_roots" = "$NO_VALUE" ]; then + if ! _exists "socat" && ! _exists "python" && ! _exists "python2" && ! _exists "python3"; then + _err "Please install socat or python tools first." return 1 fi fi @@ -4407,6 +4492,8 @@ issue() { _preferred_chain="${15}" _valid_from="${16}" _valid_to="${17}" + _certificate_profile="${18}" + _extended_key_usage="${19}" if [ -z "$_ACME_IS_RENEW" ]; then _initpath "$_main_domain" "$_key_length" @@ -4426,7 +4513,7 @@ issue() { Le_NextRenewTime=$(_readdomainconf Le_NextRenewTime) _debug Le_NextRenewTime "$Le_NextRenewTime" if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then - _valid_to_saved=$(_readdomainconf Le_Valid_to) + _valid_to_saved=$(_readdomainconf Le_Valid_To) if [ "$_valid_to_saved" ] && ! _startswith "$_valid_to_saved" "+"; then _info "The domain is set to be valid to: $_valid_to_saved" _info "It cannot be renewed automatically" @@ -4482,6 +4569,11 @@ issue() { else _cleardomainconf "Le_Preferred_Chain" fi + if [ "$_certificate_profile" ]; then + _savedomainconf "Le_Certificate_Profile" "$_certificate_profile" + else + _cleardomainconf "Le_Certificate_Profile" + fi Le_API="$ACME_DIRECTORY" _savedomainconf "Le_API" "$Le_API" @@ -4493,6 +4585,7 @@ issue() { if ! _on_before_issue "$_web_roots" "$_main_domain" "$_alt_domains" "$_pre_hook" "$_local_addr"; then _err "_on_before_issue." + _on_issue_err "$_post_hook" return 1 fi @@ -4545,12 +4638,25 @@ issue() { return 1 fi fi - if ! _createcsr "$_main_domain" "$_alt_domains" "$CERT_KEY_PATH" "$CSR_PATH" "$DOMAIN_SSL_CONF"; then + _keyusage="$_extended_key_usage" + if [ "$Le_API" = "$CA_GOOGLE" ] || [ "$Le_API" = "$CA_GOOGLE_TEST" ]; then + if [ -z "$_keyusage" ]; then + #https://github.com/acmesh-official/acme.sh/issues/6610 + #google accepts serverauth only + _keyusage="serverAuth" + fi + fi + if ! _createcsr "$_main_domain" "$_alt_domains" "$CERT_KEY_PATH" "$CSR_PATH" "$DOMAIN_SSL_CONF" "" "$_keyusage"; then _err "Error creating CSR." _clearup _on_issue_err "$_post_hook" return 1 fi + if [ "$_extended_key_usage" ]; then + _savedomainconf "Le_ExtKeyUse" "$_extended_key_usage" + else + _cleardomainconf "Le_ExtKeyUse" + fi fi _savedomainconf "Le_Keylength" "$_key_length" @@ -4613,6 +4719,9 @@ issue() { if [ "$_notAfter" ]; then _newOrderObj="$_newOrderObj,\"notAfter\": \"$_notAfter\"" fi + if [ "$_certificate_profile" ]; then + _newOrderObj="$_newOrderObj,\"profile\": \"$_certificate_profile\"" + fi _debug "STEP 1, Ordering a Certificate" if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then _err "Error creating new order." @@ -4752,7 +4861,8 @@ $_authorizations_map" _debug keyauthorization "$keyauthorization" fi - entry="$(echo "$response" | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" + # Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018 + entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" _debug entry "$entry" if [ -z "$keyauthorization" -a -z "$entry" ]; then @@ -5002,9 +5112,11 @@ $_authorizations_map" _debug "Writing token: $token to $wellknown_path/$token" - mkdir -p "$wellknown_path" - - if ! printf "%s" "$keyauthorization" >"$wellknown_path/$token"; then + # Ensure .well-known is visible to web server user/group + # https://github.com/Neilpang/acme.sh/pull/32 + if ! (umask ugo+rx && + mkdir -p "$wellknown_path" && + printf "%s" "$keyauthorization" >"$wellknown_path/$token"); then _err "$d: Cannot write token to file: $wellknown_path/$token" _clearupwebbroot "$_currentRoot" "$removelevel" "$token" _clearup @@ -5178,6 +5290,16 @@ $_authorizations_map" return 1 fi break + elif _contains "$response" "\"ready\""; then + _info "Order status is 'ready', let's sleep and retry." + _retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r') + _debug "_retryafter" "$_retryafter" + if [ "$_retryafter" ]; then + _info "Sleeping for $_retryafter seconds then retrying" + _sleep $_retryafter + else + _sleep 2 + fi elif _contains "$response" "\"processing\""; then _info "Order status is 'processing', let's sleep and retry." _retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r') @@ -5376,10 +5498,10 @@ $_authorizations_map" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" #convert to pkcs12 + Le_PFXPassword="$(_readdomainconf Le_PFXPassword)" if [ "$Le_PFXPassword" ]; then _toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword" fi - export CERT_PFX_PATH if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then _savedomainconf "Le_RealCertPath" "$_real_cert" @@ -5447,10 +5569,6 @@ renew() { _info "Switching back to $CA_LETSENCRYPT_V2" Le_API="$CA_LETSENCRYPT_V2" ;; - "$CA_BUYPASS_TEST") - _info "Switching back to $CA_BUYPASS" - Le_API="$CA_BUYPASS" - ;; "$CA_GOOGLE_TEST") _info "Switching back to $CA_GOOGLE" Le_API="$CA_GOOGLE" @@ -5492,6 +5610,11 @@ renew() { Le_PostHook="$(_readdomainconf Le_PostHook)" Le_RenewHook="$(_readdomainconf Le_RenewHook)" Le_Preferred_Chain="$(_readdomainconf Le_Preferred_Chain)" + Le_Certificate_Profile="$(_readdomainconf Le_Certificate_Profile)" + Le_Valid_From="$(_readdomainconf Le_Valid_From)" + Le_Valid_To="$(_readdomainconf Le_Valid_To)" + Le_ExtKeyUse="$(_readdomainconf Le_ExtKeyUse)" + # When renewing from an old version, the empty Le_Keylength means 2048. # Note, do not use DEFAULT_DOMAIN_KEY_LENGTH as that value may change over # time but an empty value implies 2048 specifically. @@ -5499,7 +5622,14 @@ renew() { if [ -z "$Le_Keylength" ]; then Le_Keylength=2048 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" + if [ "$CA_LETSENCRYPT_V2" = "$Le_API" ]; then + #letsencrypt doesn't support ocsp anymore + if [ "$Le_OCSP_Staple" ]; then + export Le_OCSP_Staple="" + _cleardomainconf Le_OCSP_Staple + fi + 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" @@ -5666,6 +5796,10 @@ signcsr() { _local_addr="${11}" _challenge_alias="${12}" _preferred_chain="${13}" + _valid_f="${14}" + _valid_t="${15}" + _cert_prof="${16}" + _en_key_usage="${17}" _csrsubj=$(_readSubjectFromCSR "$_csrfile") if [ "$?" != "0" ]; then @@ -5709,7 +5843,7 @@ signcsr() { _info "Copying CSR to: $CSR_PATH" cp "$_csrfile" "$CSR_PATH" - issue "$_csrW" "$_csrsubj" "$_csrdomainlist" "$_csrkeylength" "$_real_cert" "$_real_key" "$_real_ca" "$_reload_cmd" "$_real_fullchain" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_addr" "$_challenge_alias" "$_preferred_chain" + issue "$_csrW" "$_csrsubj" "$_csrdomainlist" "$_csrkeylength" "$_real_cert" "$_real_key" "$_real_ca" "$_reload_cmd" "$_real_fullchain" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_addr" "$_challenge_alias" "$_preferred_chain" "$_valid_f" "$_valid_t" "$_cert_prof" "$_en_key_usage" } @@ -5760,9 +5894,10 @@ list() { _sep="|" if [ "$_raw" ]; then if [ -z "$_domain" ]; then - printf "%s\n" "Main_Domain${_sep}KeyLength${_sep}SAN_Domains${_sep}CA${_sep}Created${_sep}Renew" + printf "%s\n" "Main_Domain${_sep}KeyLength${_sep}SAN_Domains${_sep}Profile${_sep}CA${_sep}Created${_sep}Renew" fi - for di in "${CERT_HOME}"/*.*/; do + for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do + [ -d "$di" ] || continue d=$(basename "$di") _debug d "$d" ( @@ -5775,7 +5910,7 @@ list() { . "$DOMAIN_CONF" _ca="$(_getCAShortName "$Le_API")" if [ -z "$_domain" ]; then - printf "%s\n" "$Le_Domain${_sep}\"$Le_Keylength\"${_sep}$Le_Alt${_sep}$_ca${_sep}$Le_CertCreateTimeStr${_sep}$Le_NextRenewTimeStr" + printf "%s\n" "$Le_Domain${_sep}\"$Le_Keylength\"${_sep}$Le_Alt${_sep}$Le_Certificate_Profile${_sep}$_ca${_sep}$Le_CertCreateTimeStr${_sep}$Le_NextRenewTimeStr" else if [ "$_domain" = "$d" ]; then cat "$DOMAIN_CONF" @@ -5794,6 +5929,48 @@ list() { } +list_profiles() { + _initpath + _initAPI + + _l_server_url="$ACME_DIRECTORY" + _l_server_name="$(_getCAShortName "$_l_server_url")" + _info "Fetching profiles from $_l_server_name ($_l_server_url)..." + + response=$(_get "$_l_server_url" "" 10) + if [ "$?" != "0" ]; then + _err "Failed to connect to CA directory: $_l_server_url" + return 1 + fi + + normalized_response=$(echo "$response" | _normalizeJson) + profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *\{[^\}]*\}') + + if [ -z "$profiles_json" ]; then + _info "The CA '$_l_server_name' does not publish certificate profiles via its directory endpoint." + return 0 + fi + + # Strip the outer layer to get the key-value pairs + profiles_kv=$(echo "$profiles_json" | sed 's/"profiles" *: *{//' | sed 's/}$//' | tr ',' '\n') + + printf "\n%-15s %s\n" "name" "info" + printf -- "--------------------------------------------------------------------\n" + + _old_IFS="$IFS" + IFS=' +' + for pair in $profiles_kv; do + # Trim quotes and whitespace + _name=$(echo "$pair" | cut -d: -f1 | tr -d '" \t') + _info_url=$(echo "$pair" | cut -d: -f2- | sed 's/^ *//' | tr -d '"') + printf "%-15s %s\n" "$_name" "$_info_url" + done + IFS="$_old_IFS" + + return 0 +} + _deploy() { _d="$1" _hooks="$2" @@ -5818,7 +5995,7 @@ _deploy() { return 1 fi - if ! $d_command "$_d" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$CERT_FULLCHAIN_PATH"; then + if ! $d_command "$_d" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CERT_PFX_PATH"; then _err "Error deploying for domain: $_d" return 1 fi @@ -5981,7 +6158,7 @@ _installcert() { ); then _info "$(__green "Reload successful")" else - _err "Reload error for: $Le_Domain" + _err "Reload error for: $_main_domain" fi fi @@ -6061,7 +6238,7 @@ installcronjob() { _script="$(_readlink "$_SCRIPT_")" _debug _script "$_script" if [ -f "$_script" ]; then - _info "Usinging the current script from: $_script" + _info "Using the current script from: $_script" lesh="$_script" else _err "Cannot install cronjob, $PROJECT_ENTRY not found." @@ -6332,7 +6509,8 @@ _deactivate() { fi _debug "Trigger validation." vtype="$(_getIdType "$_d_domain")" - entry="$(echo "$response" | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" + # Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018 + entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" _debug entry "$entry" if [ -z "$entry" ]; then _err "$d: Cannot get domain token" @@ -6416,6 +6594,36 @@ deactivate() { done } +#cert +_getAKI() { + _cert="$1" + openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ' :' +} + +#cert +_getSerial() { + _cert="$1" + openssl x509 -in "$_cert" -serial -noout | cut -d = -f 2 +} + +#cert +_get_ARI() { + _cert="$1" + _aki=$(_getAKI "$_cert") + _ser=$(_getSerial "$_cert") + _debug2 "_aki" "$_aki" + _debug2 "_ser" "$_ser" + + _akiurl="$(echo "$_aki" | _h2b | _base64 | tr -d = | _url_encode)" + _debug2 "_akiurl" "$_akiurl" + _serurl="$(echo "$_ser" | _h2b | _base64 | tr -d = | _url_encode)" + _debug2 "_serurl" "$_serurl" + + _ARI_URL="$ACME_RENEWAL_INFO/$_akiurl.$_serurl" + _get "$_ARI_URL" + +} + # Detect profile file if not specified as environment variable _detect_profile() { if [ -n "$PROFILE" -a -f "$PROFILE" ]; then @@ -6464,6 +6672,7 @@ _initconf() { #NO_TIMESTAMP=1 " >"$ACCOUNT_CONF_PATH" + chmod 600 "$ACCOUNT_CONF_PATH" fi } @@ -6499,9 +6708,9 @@ _precheck() { return 1 fi - if ! _exists "socat"; then - _err "It is recommended to install socat first." - _err "We use socat for the standalone server, which is used for standalone mode." + if ! _exists "socat" && ! _exists "python" && ! _exists "python2" && ! _exists "python3"; then + _err "It is recommended to install socat or python first." + _err "We use socat or python for the standalone server, which is used for standalone mode." _err "If you don't want to use standalone mode, you may ignore this warning." fi @@ -6813,7 +7022,7 @@ _send_notify() { _nsource="$NOTIFY_SOURCE" if [ -z "$_nsource" ]; then - _nsource="$(hostname)" + _nsource="$(uname -n)" fi _nsubject="$_nsubject by $_nsource" @@ -6971,6 +7180,9 @@ Parameters: If no match, the default offered chain will be used. (default: empty) See: $_PREFERRED_CHAIN_WIKI + --cert-profile, --certificate-profile If the CA offers profiles, select the desired profile + See: $_PROFILESELECTION_WIKI + --valid-to Request the NotAfter field of the cert. See: $_VALIDITY_WIKI --valid-from Request the NotBefore field of the cert. @@ -7015,7 +7227,7 @@ Parameters: --accountconf Specifies a customized account config file. --home Specifies the home dir for $PROJECT_NAME. - --cert-home Specifies the home dir to save all the certs, only valid for '--install' command. + --cert-home Specifies the home dir to save all the certs. --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. @@ -7047,6 +7259,8 @@ Parameters: --auto-upgrade [0|1] Valid for '--upgrade' command, indicating whether to upgrade automatically in future. Defaults to 1 if argument is omitted. --listen-v4 Force standalone/tls server to listen at ipv4. --listen-v6 Force standalone/tls server to listen at ipv6. + --request-v4 Force client requests to use ipv4 to connect to the CA server. + --request-v6 Force client requests to use ipv6 to connect to the CA server. --openssl-bin Specifies a custom openssl bin location. --use-wget Force to use wget, if you have both curl and wget installed. --yes-I-know-dns-manual-mode-enough-go-ahead-please Force use of dns manual mode. @@ -7165,6 +7379,24 @@ _processAccountConf() { _saveaccountconf "ACME_USE_WGET" "$ACME_USE_WGET" fi + if [ "$_request_v6" ]; then + _saveaccountconf "ACME_USE_IPV6_REQUESTS" "$_request_v6" + _clearaccountconf "ACME_USE_IPV4_REQUESTS" + ACME_USE_IPV4_REQUESTS= + elif [ "$_request_v4" ]; then + _saveaccountconf "ACME_USE_IPV4_REQUESTS" "$_request_v4" + _clearaccountconf "ACME_USE_IPV6_REQUESTS" + ACME_USE_IPV6_REQUESTS= + elif [ "$ACME_USE_IPV6_REQUESTS" ]; then + _saveaccountconf "ACME_USE_IPV6_REQUESTS" "$ACME_USE_IPV6_REQUESTS" + _clearaccountconf "ACME_USE_IPV4_REQUESTS" + ACME_USE_IPV4_REQUESTS= + elif [ "$ACME_USE_IPV4_REQUESTS" ]; then + _saveaccountconf "ACME_USE_IPV4_REQUESTS" "$ACME_USE_IPV4_REQUESTS" + _clearaccountconf "ACME_USE_IPV6_REQUESTS" + ACME_USE_IPV6_REQUESTS= + fi + } _checkSudo() { @@ -7330,6 +7562,8 @@ _process() { _local_address="" _log_level="" _auto_upgrade="" + _request_v4="" + _request_v6="" _listen_v4="" _listen_v6="" _openssl_bin="" @@ -7346,6 +7580,8 @@ _process() { _preferred_chain="" _valid_from="" _valid_to="" + _certificate_profile="" + _extended_key_usage="" while [ ${#} -gt 0 ]; do case "${1}" in @@ -7449,6 +7685,9 @@ _process() { --set-default-chain) _CMD="setdefaultchain" ;; + --list-profiles) + _CMD="list_profiles" + ;; -d | --domain) _dvalue="$2" @@ -7664,6 +7903,10 @@ _process() { _valid_to="$2" shift ;; + --certificate-profile | --cert-profile) + _certificate_profile="$2" + shift + ;; --httpport) _httpport="$2" Le_HTTPPort="$_httpport" @@ -7734,7 +7977,7 @@ _process() { shift ;; --extended-key-usage) - Le_ExtKeyUse="$2" + _extended_key_usage="$2" shift ;; --ocsp-must-staple | --ocsp) @@ -7787,6 +8030,18 @@ _process() { fi AUTO_UPGRADE="$_auto_upgrade" ;; + --request-v4) + _request_v4="1" + ACME_USE_IPV4_REQUESTS="1" + _request_v6="" + ACME_USE_IPV6_REQUESTS="" + ;; + --request-v6) + _request_v6="1" + ACME_USE_IPV6_REQUESTS="1" + _request_v4="" + ACME_USE_IPV4_REQUESTS="" + ;; --listen-v4) _listen_v4="1" Le_Listen_V4="$_listen_v4" @@ -7939,13 +8194,13 @@ _process() { uninstall) uninstall "$_nocron" ;; upgrade) upgrade ;; issue) - issue "$_webroot" "$_domain" "$_altdomains" "$_keylength" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" "$_valid_from" "$_valid_to" + issue "$_webroot" "$_domain" "$_altdomains" "$_keylength" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" "$_valid_from" "$_valid_to" "$_certificate_profile" "$_extended_key_usage" ;; deploy) deploy "$_domain" "$_deploy_hook" "$_ecc" ;; signcsr) - signcsr "$_csr" "$_webroot" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" + signcsr "$_csr" "$_webroot" "$_cert_file" "$_key_file" "$_ca_file" "$_reloadcmd" "$_fullchain_file" "$_pre_hook" "$_post_hook" "$_renew_hook" "$_local_address" "$_challenge_alias" "$_preferred_chain" "$_valid_from" "$_valid_to" "$_certificate_profile" "$_extended_key_usage" ;; showcsr) showcsr "$_csr" "$_domain" @@ -8010,6 +8265,9 @@ _process() { setdefaultchain) setdefaultchain "$_preferred_chain" ;; + list_profiles) + list_profiles + ;; *) if [ "$_CMD" ]; then _err "Invalid command: $_CMD" diff --git a/deploy/ali_cdn.sh b/deploy/ali_cdn.sh index 70a2e532..3c28674e 100644 --- a/deploy/ali_cdn.sh +++ b/deploy/ali_cdn.sh @@ -83,6 +83,6 @@ _set_cdn_domain_ssl_certificate_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&Version=2018-05-10' } diff --git a/deploy/ali_dcdn.sh b/deploy/ali_dcdn.sh index 14ac500a..27d3a726 100644 --- a/deploy/ali_dcdn.sh +++ b/deploy/ali_dcdn.sh @@ -83,6 +83,6 @@ _set_dcdn_domain_ssl_certificate_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&Version=2018-01-15' } diff --git a/deploy/cachefly.sh b/deploy/cachefly.sh new file mode 100644 index 00000000..7841b20b --- /dev/null +++ b/deploy/cachefly.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh + +# Script to deploy certificate to CacheFly +# https://api.cachefly.com/api/2.5/docs#tag/Certificates/paths/~1certificates/post + +# This deployment required following variables +# export CACHEFLY_TOKEN="Your CacheFly API Token" + +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +CACHEFLY_API_BASE="https://api.cachefly.com/api/2.5" + +cachefly_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ -z "$CACHEFLY_TOKEN" ]; then + _err "CACHEFLY_TOKEN is not defined." + return 1 + else + _savedomainconf CACHEFLY_TOKEN "$CACHEFLY_TOKEN" + fi + + _info "Deploying certificate to CacheFly..." + + ## upload certificate + string_fullchain=$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n') + string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n') + + _request_body="{\"certificate\":\"$string_fullchain\",\"certificateKey\":\"$string_key\"}" + _debug _request_body "$_request_body" + _debug CACHEFLY_TOKEN "$CACHEFLY_TOKEN" + export _H1="Authorization: Bearer $CACHEFLY_TOKEN" + _response=$(_post "$_request_body" "$CACHEFLY_API_BASE/certificates" "" "POST" "application/json") + + if _contains "$_response" "message"; then + _err "Error in deploying $_cdomain certificate to CacheFly." + _err "$_response" + return 1 + fi + _debug response "$_response" + _info "Domain $_cdomain certificate successfully deployed to CacheFly." + return 0 +} diff --git a/deploy/directadmin.sh b/deploy/directadmin.sh new file mode 100644 index 00000000..3f60a088 --- /dev/null +++ b/deploy/directadmin.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env sh + +# Script to deploy certificate to DirectAdmin +# https://docs.directadmin.com/directadmin/customizing-workflow/api-all-about.html#creating-a-login-key +# https://docs.directadmin.com/changelog/version-1.24.4.html#cmd-api-catch-all-pop-passwords-frontpage-protected-dirs-ssl-certs + +# This deployment required following variables +# export DirectAdmin_SCHEME="https" # Optional, https or http, defaults to https +# export DirectAdmin_ENDPOINT="example.com:2222" +# export DirectAdmin_USERNAME="Your DirectAdmin Username" +# export DirectAdmin_KEY="Your DirectAdmin Login Key or Password" +# export DirectAdmin_MAIN_DOMAIN="Your DirectAdmin Main Domain, NOT Subdomain" + +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +directadmin_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ -z "$DirectAdmin_ENDPOINT" ]; then + _err "DirectAdmin_ENDPOINT is not defined." + return 1 + else + _savedomainconf DirectAdmin_ENDPOINT "$DirectAdmin_ENDPOINT" + fi + if [ -z "$DirectAdmin_USERNAME" ]; then + _err "DirectAdmin_USERNAME is not defined." + return 1 + else + _savedomainconf DirectAdmin_USERNAME "$DirectAdmin_USERNAME" + fi + if [ -z "$DirectAdmin_KEY" ]; then + _err "DirectAdmin_KEY is not defined." + return 1 + else + _savedomainconf DirectAdmin_KEY "$DirectAdmin_KEY" + fi + if [ -z "$DirectAdmin_MAIN_DOMAIN" ]; then + _err "DirectAdmin_MAIN_DOMAIN is not defined." + return 1 + else + _savedomainconf DirectAdmin_MAIN_DOMAIN "$DirectAdmin_MAIN_DOMAIN" + fi + + # Optional SCHEME + _getdeployconf DirectAdmin_SCHEME + # set default values for DirectAdmin_SCHEME + [ -n "${DirectAdmin_SCHEME}" ] || DirectAdmin_SCHEME="https" + + _info "Deploying certificate to DirectAdmin..." + + # upload certificate + string_cfullchain=$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n') + string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n') + + _request_body="{\"domain\":\"$DirectAdmin_MAIN_DOMAIN\",\"action\":\"save\",\"type\":\"paste\",\"certificate\":\"$string_key\n$string_cfullchain\n\"}" + _debug _request_body "$_request_body" + _debug DirectAdmin_ENDPOINT "$DirectAdmin_ENDPOINT" + _debug DirectAdmin_USERNAME "$DirectAdmin_USERNAME" + _debug DirectAdmin_KEY "$DirectAdmin_KEY" + _debug DirectAdmin_MAIN_DOMAIN "$DirectAdmin_MAIN_DOMAIN" + _response=$(_post "$_request_body" "$DirectAdmin_SCHEME://$DirectAdmin_USERNAME:$DirectAdmin_KEY@$DirectAdmin_ENDPOINT/CMD_API_SSL" "" "POST" "application/json") + + if _contains "$_response" "error=1"; then + _err "Error in deploying $_cdomain certificate to DirectAdmin Domain $DirectAdmin_MAIN_DOMAIN." + _err "$_response" + return 1 + fi + + _info "$_response" + _info "Domain $_cdomain certificate successfully deployed to DirectAdmin Domain $DirectAdmin_MAIN_DOMAIN." + + return 0 +} diff --git a/deploy/docker.sh b/deploy/docker.sh index c9815d5b..264963ae 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -18,6 +18,7 @@ docker_deploy() { _ccert="$3" _cca="$4" _cfullchain="$5" + _cpfx="$6" _debug _cdomain "$_cdomain" _getdeployconf DEPLOY_DOCKER_CONTAINER_LABEL _debug2 DEPLOY_DOCKER_CONTAINER_LABEL "$DEPLOY_DOCKER_CONTAINER_LABEL" @@ -88,6 +89,12 @@ docker_deploy() { _savedeployconf DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE "$DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE" fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_PFX_FILE + _debug2 DEPLOY_DOCKER_CONTAINER_PFX_FILE "$DEPLOY_DOCKER_CONTAINER_PFX_FILE" + if [ "$DEPLOY_DOCKER_CONTAINER_PFX_FILE" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_PFX_FILE "$DEPLOY_DOCKER_CONTAINER_PFX_FILE" + fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_RELOAD_CMD _debug2 DEPLOY_DOCKER_CONTAINER_RELOAD_CMD "$DEPLOY_DOCKER_CONTAINER_RELOAD_CMD" if [ "$DEPLOY_DOCKER_CONTAINER_RELOAD_CMD" ]; then @@ -125,6 +132,12 @@ docker_deploy() { fi fi + if [ "$DEPLOY_DOCKER_CONTAINER_PFX_FILE" ]; then + if ! _docker_cp "$_cid" "$_cpfx" "$DEPLOY_DOCKER_CONTAINER_PFX_FILE"; then + return 1 + fi + fi + if [ "$DEPLOY_DOCKER_CONTAINER_RELOAD_CMD" ]; then _info "Reloading: $DEPLOY_DOCKER_CONTAINER_RELOAD_CMD" if ! _docker_exec "$_cid" "$DEPLOY_DOCKER_CONTAINER_RELOAD_CMD"; then diff --git a/deploy/edgio.sh b/deploy/edgio.sh new file mode 100644 index 00000000..1acd0c8f --- /dev/null +++ b/deploy/edgio.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to edgio using its API +# https://docs.edg.io/guides/v7/develop/rest_api/authentication +# https://docs.edg.io/rest_api/#tag/tls-certs/operation/postConfigV01TlsCerts + +# This deployment required following variables +# export EDGIO_CLIENT_ID="Your Edgio Client ID" +# export EDGIO_CLIENT_SECRET="Your Edgio Client Secret" +# export EDGIO_ENVIRONMENT_ID="Your Edgio Environment ID" + +# If have more than one Environment ID +# export EDGIO_ENVIRONMENT_ID="ENVIRONMENT_ID_1 ENVIRONMENT_ID_2" + +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +edgio_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ -z "$EDGIO_CLIENT_ID" ]; then + _err "EDGIO_CLIENT_ID is not defined." + return 1 + else + _savedomainconf EDGIO_CLIENT_ID "$EDGIO_CLIENT_ID" + fi + + if [ -z "$EDGIO_CLIENT_SECRET" ]; then + _err "EDGIO_CLIENT_SECRET is not defined." + return 1 + else + _savedomainconf EDGIO_CLIENT_SECRET "$EDGIO_CLIENT_SECRET" + fi + + if [ -z "$EDGIO_ENVIRONMENT_ID" ]; then + _err "EDGIO_ENVIRONMENT_ID is not defined." + return 1 + else + _savedomainconf EDGIO_ENVIRONMENT_ID "$EDGIO_ENVIRONMENT_ID" + fi + + _info "Getting access token" + _data="client_id=$EDGIO_CLIENT_ID&client_secret=$EDGIO_CLIENT_SECRET&grant_type=client_credentials&scope=app.config" + _debug Get_access_token_data "$_data" + _response=$(_post "$_data" "https://id.edgio.app/connect/token" "" "POST" "application/x-www-form-urlencoded") + _debug Get_access_token_response "$_response" + _access_token=$(echo "$_response" | _json_decode | _egrep_o '"access_token":"[^"]*' | cut -d : -f 2 | tr -d '"') + _debug _access_token "$_access_token" + if [ -z "$_access_token" ]; then + _err "Error in getting access token" + return 1 + fi + + _info "Uploading certificate" + string_ccert=$(sed 's/$/\\n/' "$_ccert" | tr -d '\n') + string_cca=$(sed 's/$/\\n/' "$_cca" | tr -d '\n') + string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n') + + for ENVIRONMENT_ID in $EDGIO_ENVIRONMENT_ID; do + _data="{\"environment_id\":\"$ENVIRONMENT_ID\",\"primary_cert\":\"$string_ccert\",\"intermediate_cert\":\"$string_cca\",\"private_key\":\"$string_key\"}" + _debug Upload_certificate_data "$_data" + _H1="Authorization: Bearer $_access_token" + _response=$(_post "$_data" "https://edgioapis.com/config/v0.1/tls-certs" "" "POST" "application/json") + if _contains "$_response" "message"; then + _err "Error in deploying $_cdomain certificate to Edgio ENVIRONMENT_ID $ENVIRONMENT_ID." + _err "$_response" + return 1 + fi + _debug Upload_certificate_response "$_response" + _info "Domain $_cdomain certificate successfully deployed to Edgio ENVIRONMENT_ID $ENVIRONMENT_ID." + done + + return 0 +} diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index c8491d92..19509e3b 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -357,7 +357,7 @@ haproxy_deploy() { _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'" - _debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" + _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" eval "${_socat_cert_set_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then diff --git a/deploy/kemplm.sh b/deploy/kemplm.sh new file mode 100755 index 00000000..e44e06dc --- /dev/null +++ b/deploy/kemplm.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env sh + +#Here is a script to deploy cert to a Kemp Loadmaster. + +#returns 0 means success, otherwise error. + +#DEPLOY_KEMP_TOKEN="token" +#DEPLOY_KEMP_URL="https://kemplm.example.com" + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +kemplm_deploy() { + _domain="$1" + _key_file="$2" + _cert_file="$3" + _ca_file="$4" + _fullchain_file="$5" + + _debug _domain "$_domain" + _debug _key_file "$_key_file" + _debug _cert_file "$_cert_file" + _debug _ca_file "$_ca_file" + _debug _fullchain_file "$_fullchain_file" + + if ! _exists jq; then + _err "jq not found" + return 1 + fi + + # Rename wildcard certs, kemp accepts only alphanumeric names so we delete '*.' from filename + _kemp_domain=$(echo "${_domain}" | sed 's/\*\.//') + _debug _kemp_domain "$_kemp_domain" + + # Read config from saved values or env + _getdeployconf DEPLOY_KEMP_TOKEN + _getdeployconf DEPLOY_KEMP_URL + + _debug DEPLOY_KEMP_URL "$DEPLOY_KEMP_URL" + _secure_debug DEPLOY_KEMP_TOKEN "$DEPLOY_KEMP_TOKEN" + + if [ -z "$DEPLOY_KEMP_TOKEN" ]; then + _err "Kemp Loadmaster token is not found, please define DEPLOY_KEMP_TOKEN." + return 1 + fi + if [ -z "$DEPLOY_KEMP_URL" ]; then + _err "Kemp Loadmaster URL is not found, please define DEPLOY_KEMP_URL." + return 1 + fi + + # Save current values + _savedeployconf DEPLOY_KEMP_TOKEN "$DEPLOY_KEMP_TOKEN" + _savedeployconf DEPLOY_KEMP_URL "$DEPLOY_KEMP_URL" + + # Check if certificate is already installed + _info "Check if certificate is already present" + _list_request="{\"cmd\": \"listcert\", \"apikey\": \"${DEPLOY_KEMP_TOKEN}\"}" + _debug3 _list_request "${_list_request}" + _kemp_cert_count=$(HTTPS_INSECURE=1 _post "${_list_request}" "${DEPLOY_KEMP_URL}/accessv2" | jq -r '.cert[] | .name' | grep -c "${_kemp_domain}") + _debug2 _kemp_cert_count "${_kemp_cert_count}" + + _kemp_replace_cert=1 + if [ "${_kemp_cert_count}" -eq 0 ]; then + _kemp_replace_cert=0 + _info "Certificate does not exist on Kemp Loadmaster" + else + _info "Certificate already exists on Kemp Loadmaster" + fi + _debug _kemp_replace_cert "${_kemp_replace_cert}" + + # Upload new certificate to Kemp Loadmaster + _kemp_upload_cert=$(_mktemp) + cat "${_fullchain_file}" "${_key_file}" | base64 | tr -d '\n' >"${_kemp_upload_cert}" + + _info "Uploading certificate to Kemp Loadmaster" + _add_data=$(cat "${_kemp_upload_cert}") + _add_request="{\"cmd\": \"addcert\", \"apikey\": \"${DEPLOY_KEMP_TOKEN}\", \"replace\": ${_kemp_replace_cert}, \"cert\": \"${_kemp_domain}\", \"data\": \"${_add_data}\"}" + _debug3 _add_request "${_add_request}" + _kemp_post_result=$(HTTPS_INSECURE=1 _post "${_add_request}" "${DEPLOY_KEMP_URL}/accessv2") + _retval=$? + _debug2 _kemp_post_result "${_kemp_post_result}" + if [ "${_retval}" -eq 0 ]; then + _kemp_post_status=$(echo "${_kemp_post_result}" | jq -r '.status') + _kemp_post_message=$(echo "${_kemp_post_result}" | jq -r '.message') + if [ "${_kemp_post_status}" = "ok" ]; then + _info "Upload successful" + else + _err "Upload failed: ${_kemp_post_message}" + fi + else + _err "Upload failed" + _retval=1 + fi + + rm "${_kemp_upload_cert}" + + return $_retval +} diff --git a/deploy/keyhelp.sh b/deploy/keyhelp.sh new file mode 100644 index 00000000..97f9c21c --- /dev/null +++ b/deploy/keyhelp.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env sh + +# Script to deploy certificate to KeyHelp +# This deployment required following variables +# export DEPLOY_KEYHELP_BASEURL="https://keyhelp.example.com" +# export DEPLOY_KEYHELP_USERNAME="Your KeyHelp Username" +# export DEPLOY_KEYHELP_PASSWORD="Your KeyHelp Password" +# export DEPLOY_KEYHELP_DOMAIN_ID="Depoly certificate to this Domain ID" + +# Open the 'Edit domain' page, and you will see id=xxx at the end of the URL. This is the Domain ID. +# https://DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit&id=xxx + +# If have more than one domain name +# export DEPLOY_KEYHELP_DOMAIN_ID="111 222 333" + +keyhelp_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ -z "$DEPLOY_KEYHELP_BASEURL" ]; then + _err "DEPLOY_KEYHELP_BASEURL is not defined." + return 1 + else + _savedomainconf DEPLOY_KEYHELP_BASEURL "$DEPLOY_KEYHELP_BASEURL" + fi + + if [ -z "$DEPLOY_KEYHELP_USERNAME" ]; then + _err "DEPLOY_KEYHELP_USERNAME is not defined." + return 1 + else + _savedomainconf DEPLOY_KEYHELP_USERNAME "$DEPLOY_KEYHELP_USERNAME" + fi + + if [ -z "$DEPLOY_KEYHELP_PASSWORD" ]; then + _err "DEPLOY_KEYHELP_PASSWORD is not defined." + return 1 + else + _savedomainconf DEPLOY_KEYHELP_PASSWORD "$DEPLOY_KEYHELP_PASSWORD" + fi + + if [ -z "$DEPLOY_KEYHELP_DOMAIN_ID" ]; then + _err "DEPLOY_KEYHELP_DOMAIN_ID is not defined." + return 1 + else + _savedomainconf DEPLOY_KEYHELP_DOMAIN_ID "$DEPLOY_KEYHELP_DOMAIN_ID" + fi + + # Optional DEPLOY_KEYHELP_ENFORCE_HTTPS + _getdeployconf DEPLOY_KEYHELP_ENFORCE_HTTPS + # set default values for DEPLOY_KEYHELP_ENFORCE_HTTPS + [ -n "${DEPLOY_KEYHELP_ENFORCE_HTTPS}" ] || DEPLOY_KEYHELP_ENFORCE_HTTPS="1" + + _info "Logging in to keyhelp panel" + username_encoded="$(printf "%s" "${DEPLOY_KEYHELP_USERNAME}" | _url_encode)" + password_encoded="$(printf "%s" "${DEPLOY_KEYHELP_PASSWORD}" | _url_encode)" + _H1="Content-Type: application/x-www-form-urlencoded" + _response=$(_get "$DEPLOY_KEYHELP_BASEURL/index.php?submit=1&username=$username_encoded&password=$password_encoded" "TRUE") + _cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2)" + + # If cookies is not empty then logon successful + if [ -z "$_cookie" ]; then + _err "Fail to get cookie." + return 1 + fi + _debug "cookie" "$_cookie" + + _info "Uploading certificate" + _date=$(date +"%Y%m%d") + encoded_key="$(_url_encode <"$_ckey")" + encoded_ccert="$(_url_encode <"$_ccert")" + encoded_cca="$(_url_encode <"$_cca")" + certificate_name="$_cdomain-$_date" + + _request_body="submit=1&certificate_name=$certificate_name&add_type=upload&text_private_key=$encoded_key&text_certificate=$encoded_ccert&text_ca_certificate=$encoded_cca" + _H1="Cookie: $_cookie" + _response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=ssl_certificates&action=add" "" "POST") + _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') + _info "_message" "$_message" + if [ -z "$_message" ]; then + _err "Fail to upload certificate." + return 1 + fi + + for DOMAIN_ID in $DEPLOY_KEYHELP_DOMAIN_ID; do + _info "Apply certificate to domain id $DOMAIN_ID" + _response=$(_get "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit&id=$DOMAIN_ID") + cert_value=$(echo "$_response" | grep "$certificate_name" | sed -n 's/.*value="\([^"]*\).*/\1/p') + target_type=$(echo "$_response" | grep 'target_type' | grep 'checked' | sed -n 's/.*value="\([^"]*\).*/\1/p') + if [ "$target_type" = "directory" ]; then + path=$(echo "$_response" | awk '/name="path"/{getline; print}' | sed -n 's/.*value="\([^"]*\).*/\1/p') + fi + echo "$_response" | grep "is_prefer_https" | grep "checked" >/dev/null + if [ $? -eq 0 ]; then + is_prefer_https=1 + else + is_prefer_https=0 + fi + echo "$_response" | grep "hsts_enabled" | grep "checked" >/dev/null + if [ $? -eq 0 ]; then + hsts_enabled=1 + else + hsts_enabled=0 + fi + _debug "cert_value" "$cert_value" + if [ -z "$cert_value" ]; then + _err "Fail to get certificate id." + return 1 + fi + + _request_body="submit=1&id=$DOMAIN_ID&target_type=$target_type&path=$path&is_prefer_https=$is_prefer_https&hsts_enabled=$hsts_enabled&certificate_type=custom&certificate_id=$cert_value&enforce_https=$DEPLOY_KEYHELP_ENFORCE_HTTPS" + _response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit" "" "POST") + _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') + _info "_message" "$_message" + if [ -z "$_message" ]; then + _err "Fail to apply certificate." + return 1 + fi + done + + _info "Domain $_cdomain certificate successfully deployed to KeyHelp Domain ID $DEPLOY_KEYHELP_DOMAIN_ID." + return 0 +} diff --git a/deploy/keyhelp_api.sh b/deploy/keyhelp_api.sh new file mode 100644 index 00000000..75e9d951 --- /dev/null +++ b/deploy/keyhelp_api.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env sh + +keyhelp_api_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + + # Read config from saved values or env + _getdeployconf DEPLOY_KEYHELP_HOST + _getdeployconf DEPLOY_KEYHELP_API_KEY + + _debug DEPLOY_KEYHELP_HOST "$DEPLOY_KEYHELP_HOST" + _secure_debug DEPLOY_KEYHELP_API_KEY "$DEPLOY_KEYHELP_API_KEY" + + if [ -z "$DEPLOY_KEYHELP_HOST" ]; then + _err "KeyHelp host not found, please define DEPLOY_KEYHELP_HOST." + return 1 + fi + if [ -z "$DEPLOY_KEYHELP_API_KEY" ]; then + _err "KeyHelp api key not found, please define DEPLOY_KEYHELP_API_KEY." + return 1 + fi + + # Save current values + _savedeployconf DEPLOY_KEYHELP_HOST "$DEPLOY_KEYHELP_HOST" + _savedeployconf DEPLOY_KEYHELP_API_KEY "$DEPLOY_KEYHELP_API_KEY" + + _request_key="$(tr '\n' ':' <"$_ckey" | sed 's/:/\\n/g')" + _request_cert="$(tr '\n' ':' <"$_ccert" | sed 's/:/\\n/g')" + _request_ca="$(tr '\n' ':' <"$_cca" | sed 's/:/\\n/g')" + + _request_body="{ + \"name\": \"$_cdomain\", + \"components\": { + \"private_key\": \"$_request_key\", + \"certificate\": \"$_request_cert\", + \"ca_certificate\": \"$_request_ca\" + } + }" + + _hosts="$(echo "$DEPLOY_KEYHELP_HOST" | tr "," " ")" + _keys="$(echo "$DEPLOY_KEYHELP_API_KEY" | tr "," " ")" + _i=1 + + for _host in $_hosts; do + _key="$(_getfield "$_keys" "$_i" " ")" + _i="$(_math "$_i" + 1)" + + export _H1="X-API-Key: $_key" + + _put_url="$_host/api/v2/certificates/name/$_cdomain" + if _post "$_request_body" "$_put_url" "" "PUT" "application/json" >/dev/null; then + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + else + _err "Cannot make PUT request to $_put_url" + return 1 + fi + + if [ "$_code" = "404" ]; then + _info "$_cdomain not found, creating new entry at $_host" + + _post_url="$_host/api/v2/certificates" + if _post "$_request_body" "$_post_url" "" "POST" "application/json" >/dev/null; then + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + else + _err "Cannot make POST request to $_post_url" + return 1 + fi + fi + + if _startswith "$_code" "2"; then + _info "$_cdomain set at $_host" + else + _err "HTTP status code is $_code" + return 1 + fi + done + + return 0 +} diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh new file mode 100644 index 00000000..ef920f64 --- /dev/null +++ b/deploy/multideploy.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env sh + +################################################################################ +# ACME.sh 3rd party deploy plugin for multiple (same) services +################################################################################ +# Authors: tomo2403 (creator), https://github.com/tomo2403 +# Updated: 2025-03-01 +# Issues: https://github.com/acmesh-official/acme.sh/issues and mention @tomo2403 +################################################################################ +# Usage (shown values are the examples): +# 1. Set optional environment variables +# - export MULTIDEPLOY_FILENAME="multideploy.yaml" - "multideploy.yml" will be automatically used if not set" +# +# 2. Run command: +# acme.sh --deploy --deploy-hook multideploy -d example.com +################################################################################ +# Dependencies: +# - yq +################################################################################ +# Return value: +# 0 means success, otherwise error. +################################################################################ + +MULTIDEPLOY_VERSION="1.0" + +# Description: This function handles the deployment of certificates to multiple services. +# It processes the provided certificate files and deploys them according to the +# configuration specified in the multideploy file. +# +# Parameters: +# _cdomain - The domain name for which the certificate is issued. +# _ckey - The private key file for the certificate. +# _ccert - The certificate file. +# _cca - The CA (Certificate Authority) file. +# _cfullchain - The full chain certificate file. +# _cpfx - The PFX (Personal Information Exchange) file. +multideploy_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + _cpfx="$6" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + _debug _cpfx "$_cpfx" + + MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf MULTIDEPLOY_FILENAME)}" + if [ -z "$MULTIDEPLOY_FILENAME" ]; then + MULTIDEPLOY_FILENAME="multideploy.yml" + _info "MULTIDEPLOY_FILENAME is not set, so I will use 'multideploy.yml'." + else + _savedeployconf "MULTIDEPLOY_FILENAME" "$MULTIDEPLOY_FILENAME" + _debug2 "MULTIDEPLOY_FILENAME" "$MULTIDEPLOY_FILENAME" + fi + + if ! file=$(_preprocess_deployfile "$MULTIDEPLOY_FILENAME"); then + _err "Failed to preprocess deploy file." + return 1 + fi + _debug3 "File" "$file" + + # Deploy to services + _deploy_services "$file" + _exitCode="$?" + + return "$_exitCode" +} + +# Description: +# This function preprocesses the deploy file by checking if 'yq' is installed, +# verifying the existence of the deploy file, and ensuring only one deploy file is present. +# Arguments: +# $@ - Posible deploy file names. +# Usage: +# _preprocess_deployfile "" "?" +_preprocess_deployfile() { + # Check if yq is installed + if ! command -v yq >/dev/null 2>&1; then + _err "yq is not installed! Please install yq and try again." + return 1 + fi + _debug3 "yq is installed." + + # Check if deploy file exists + for file in "$@"; do + _debug3 "Checking file" "$DOMAIN_PATH/$file" + if [ -f "$DOMAIN_PATH/$file" ]; then + _debug3 "File found" + if [ -n "$found_file" ]; then + _err "Multiple deploy files found. Please keep only one deploy file." + return 1 + fi + found_file="$file" + else + _debug3 "File not found" + fi + done + + if [ -z "$found_file" ]; then + _err "Deploy file not found. Go to https://github.com/acmesh-official/acme.sh/wiki/deployhooks#36-deploying-to-multiple-services-with-the-same-hooks to see how to create one." + return 1 + fi + if ! _check_deployfile "$DOMAIN_PATH/$found_file"; then + _err "Deploy file is not valid: $DOMAIN_PATH/$found_file" + return 1 + fi + + echo "$DOMAIN_PATH/$found_file" +} + +# Description: +# This function checks the deploy file for version compatibility and the existence of the specified configuration and services. +# Arguments: +# $1 - The path to the deploy configuration file. +# $2 - The name of the deploy configuration to use. +# Usage: +# _check_deployfile "" +_check_deployfile() { + _deploy_file="$1" + _debug2 "check: Deploy file" "$_deploy_file" + + # Check version + _deploy_file_version=$(yq -r '.version' "$_deploy_file") + if [ "$MULTIDEPLOY_VERSION" != "$_deploy_file_version" ]; then + _err "As of $PROJECT_NAME $VER, the deploy file needs version $MULTIDEPLOY_VERSION! Your current deploy file is of version $_deploy_file_version." + return 1 + fi + _debug2 "check: Deploy file version is compatible: $_deploy_file_version" + + # Extract all services from config + _services=$(yq -r '.services[].name' "$_deploy_file") + + if [ -z "$_services" ]; then + _err "Config does not have any services to deploy to." + return 1 + fi + _debug2 "check: Config has services." + echo "$_services" | while read -r _service; do + _debug3 " - $_service" + done + + # Check if extracted services exist in services list + echo "$_services" | while read -r _service; do + _debug2 "check: Checking service: $_service" + # Check if service exists + _service_config=$(yq -r ".services[] | select(.name == \"$_service\")" "$_deploy_file") + if [ -z "$_service_config" ] || [ "$_service_config" = "null" ]; then + _err "Service '$_service' not found." + return 1 + fi + + _service_hook=$(echo "$_service_config" | yq -r ".hook" -) + if [ -z "$_service_hook" ] || [ "$_service_hook" = "null" ]; then + _err "Service '$_service' does not have a hook." + return 1 + fi + + _service_environment=$(echo "$_service_config" | yq -r ".environment" -) + if [ -z "$_service_environment" ] || [ "$_service_environment" = "null" ]; then + _err "Service '$_service' does not have an environment." + return 1 + fi + done +} + +# Description: This function takes a list of environment variables in YAML format, +# parses them, and exports each key-value pair as environment variables. +# Arguments: +# $1 - A string containing the list of environment variables in YAML format. +# Usage: +# _export_envs "$env_list" +_export_envs() { + _env_list="$1" + + _secure_debug3 "Exporting envs" "$_env_list" + + echo "$_env_list" | yq -r 'to_entries | .[] | .key + "=" + .value' | while IFS='=' read -r _key _value; do + # Using eval to expand nested variables in the configuration file + _value=$(eval 'echo "'"$_value"'"') + _savedeployconf "$_key" "$_value" + _secure_debug3 "Saved $_key" "$_value" + done +} + +# Description: +# This function takes a YAML formatted string of environment variables, parses it, +# and clears each environment variable. It logs the process of clearing each variable. +# +# Note: Environment variables for a hook may be optional and differ between +# services using the same hook. +# If one service sets optional environment variables and another does not, the +# variables may persist and affect subsequent deployments. +# Clearing these variables after each service ensures that only the +# environment variables explicitly specified for each service in the deploy +# file are used. +# Arguments: +# $1 - A YAML formatted string containing environment variable key-value pairs. +# Usage: +# _clear_envs "" +_clear_envs() { + _env_list="$1" + + _secure_debug3 "Clearing envs" "$_env_list" + env_pairs=$(echo "$_env_list" | yq -r 'to_entries | .[] | .key + "=" + .value') + + echo "$env_pairs" | while IFS='=' read -r _key _value; do + _debug3 "Deleting key" "$_key" + _cleardomainconf "SAVED_$_key" + unset -v "$_key" + done +} + +# Description: +# This function deploys services listed in the deploy configuration file. +# Arguments: +# $1 - The path to the deploy configuration file. +# $2 - The list of services to deploy. +# Usage: +# _deploy_services "" "" +_deploy_services() { + _deploy_file="$1" + _debug3 "Deploy file" "$_deploy_file" + + _tempfile=$(mktemp) + trap 'rm -f $_tempfile' EXIT + + yq -r '.services[].name' "$_deploy_file" >"$_tempfile" + _debug3 "Services" "$(cat "$_tempfile")" + + _failedServices="" + _failedCount=0 + while read -r _service <&3; do + _debug2 "Service" "$_service" + _hook=$(yq -r ".services[] | select(.name == \"$_service\").hook" "$_deploy_file") + _envs=$(yq -r ".services[] | select(.name == \"$_service\").environment" "$_deploy_file") + + _export_envs "$_envs" + if ! _deploy_service "$_service" "$_hook"; then + _failedServices="$_service, $_failedServices" + _failedCount=$((_failedCount + 1)) + fi + _clear_envs "$_envs" + done 3<"$_tempfile" + + _debug3 "Failed services" "$_failedServices" + _debug2 "Failed count" "$_failedCount" + if [ -n "$_failedServices" ]; then + _info "$(__red "Deployment failed") for services: $_failedServices" + else + _debug "All services deployed successfully." + fi + + return "$_failedCount" +} + +# Description: Deploys a service using the specified hook. +# Arguments: +# $1 - The name of the service to deploy. +# $2 - The hook to use for deployment. +# Usage: +# _deploy_service +_deploy_service() { + _name="$1" + _hook="$2" + + _debug2 "SERVICE" "$_name" + _debug2 "HOOK" "$_hook" + + _info "$(__green "Deploying") to '$_name' using '$_hook'" + _deploy "$_cdomain" "$_hook" +} diff --git a/deploy/netlify.sh b/deploy/netlify.sh new file mode 100644 index 00000000..8d25f74c --- /dev/null +++ b/deploy/netlify.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env sh + +# Script to deploy certificate to Netlify +# https://docs.netlify.com/api/get-started/#authentication +# https://open-api.netlify.com/#tag/sniCertificate + +# This deployment required following variables +# export Netlify_ACCESS_TOKEN="Your Netlify Access Token" +# export Netlify_SITE_ID="Your Netlify Site ID" + +# If have more than one SITE ID +# export Netlify_SITE_ID="SITE_ID_1 SITE_ID_2" + +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +netlify_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if [ -z "$Netlify_ACCESS_TOKEN" ]; then + _err "Netlify_ACCESS_TOKEN is not defined." + return 1 + else + _savedomainconf Netlify_ACCESS_TOKEN "$Netlify_ACCESS_TOKEN" + fi + if [ -z "$Netlify_SITE_ID" ]; then + _err "Netlify_SITE_ID is not defined." + return 1 + else + _savedomainconf Netlify_SITE_ID "$Netlify_SITE_ID" + fi + + _info "Deploying certificate to Netlify..." + + ## upload certificate + string_ccert=$(sed 's/$/\\n/' "$_ccert" | tr -d '\n') + string_cca=$(sed 's/$/\\n/' "$_cca" | tr -d '\n') + string_key=$(sed 's/$/\\n/' "$_ckey" | tr -d '\n') + + for SITE_ID in $Netlify_SITE_ID; do + _request_body="{\"certificate\":\"$string_ccert\",\"key\":\"$string_key\",\"ca_certificates\":\"$string_cca\"}" + _debug _request_body "$_request_body" + _debug Netlify_ACCESS_TOKEN "$Netlify_ACCESS_TOKEN" + export _H1="Authorization: Bearer $Netlify_ACCESS_TOKEN" + _response=$(_post "$_request_body" "https://api.netlify.com/api/v1/sites/$SITE_ID/ssl" "" "POST" "application/json") + + if _contains "$_response" "\"error\""; then + _err "Error in deploying $_cdomain certificate to Netlify SITE_ID $SITE_ID." + _err "$_response" + return 1 + fi + _debug response "$_response" + _info "Domain $_cdomain certificate successfully deployed to Netlify SITE_ID $SITE_ID." + done + + return 0 +} diff --git a/deploy/panos.sh b/deploy/panos.sh index 0dc1b2f0..c54d21fe 100644 --- a/deploy/panos.sh +++ b/deploy/panos.sh @@ -7,20 +7,27 @@ # # Firewall admin with superuser and IP address is required. # -# REQURED: +# REQUIRED: # export PANOS_HOST="" # export PANOS_USER="" #User *MUST* have Commit and Import Permissions in XML API for Admin Role # export PANOS_PASS="" # # OPTIONAL -# export PANOS_TEMPLATE="" #Template Name of panorama managed devices +# export PANOS_TEMPLATE="" # Template Name of panorama managed devices +# export PANOS_TEMPLATE_STACK="" # set a Template Stack if certificate should also be pushed automatically +# export PANOS_VSYS="Shared" # name of the vsys to import the certificate +# export PANOS_CERTNAME="" # use a custom certificate name to work around Panorama's 31-character limit # # The script will automatically generate a new API key if # no key is found, or if a saved key has expired or is invalid. +_COMMIT_WAIT_INTERVAL=30 # query commit status every 30 seconds +_COMMIT_WAIT_ITERATIONS=20 # query commit status 20 times (20*30 = 600 seconds = 10 minutes) + # This function is to parse the XML response from the firewall parse_response() { type=$2 + _debug "API Response: $1" if [ "$type" = 'keygen' ]; then status=$(echo "$1" | sed 's/^.*\(['\'']\)\([a-z]*\)'\''.*/\2/g') if [ "$status" = "success" ]; then @@ -30,6 +37,13 @@ parse_response() { message="PAN-OS Key could not be set." fi else + if [ "$type" = 'commit' ]; then + job_id=$(echo "$1" | sed 's/^.*\(\)\(.*\)<\/job>.*/\2/g') + _commit_job_id=$job_id + elif [ "$type" = 'job_status' ]; then + job_status=$(echo "$1" | tr -d '\n' | sed 's/^.*\([^<]*\)<\/result>.*/\1/g') + _commit_job_status=$job_status + fi status=$(echo "$1" | tr -d '\n' | sed 's/^.*"\([a-z]*\)".*/\1/g') message=$(echo "$1" | tr -d '\n' | sed 's/.*\(\|\|\)\([^<]*\).*/\2/g') _debug "Firewall message: $message" @@ -44,13 +58,13 @@ parse_response() { #This function is used to deploy to the firewall deployer() { content="" - type=$1 # Types are keytest, keygen, cert, key, commit + type=$1 # Types are keytest, keygen, cert, key, commit, job_status, push panos_url="https://$_panos_host/api/" + export _H1="Content-Type: application/x-www-form-urlencoded" #Test API Key by performing a lookup if [ "$type" = 'keytest' ]; then _debug "**** Testing saved API Key ****" - _H1="Content-Type: application/x-www-form-urlencoded" # Get Version Info to test key content="type=version&key=$_panos_key" ## Exclude all scopes for the empty commit @@ -61,7 +75,6 @@ deployer() { # Generate API Key if [ "$type" = 'keygen' ]; then _debug "**** Generating new API Key ****" - _H1="Content-Type: application/x-www-form-urlencoded" content="type=keygen&user=$_panos_user&password=$_panos_pass" # content="$content${nl}--$delim${nl}Content-Disposition: form-data; type=\"keygen\"; user=\"$_panos_user\"; password=\"$_panos_pass\"${nl}Content-Type: application/octet-stream${nl}${nl}" fi @@ -77,25 +90,31 @@ deployer() { if [ "$type" = 'cert' ]; then panos_url="${panos_url}?type=import" content="--$delim${nl}Content-Disposition: form-data; name=\"category\"\r\n\r\ncertificate" - content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_cdomain" + content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_panos_certname" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"key\"\r\n\r\n$_panos_key" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"format\"\r\n\r\npem" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_cfullchain")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_cfullchain")" if [ "$_panos_template" ]; then content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"target-tpl\"\r\n\r\n$_panos_template" fi + if [ "$_panos_vsys" ]; then + content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"target-tpl-vsys\"\r\n\r\n$_panos_vsys" + fi fi if [ "$type" = 'key' ]; then panos_url="${panos_url}?type=import" content="--$delim${nl}Content-Disposition: form-data; name=\"category\"\r\n\r\nprivate-key" - content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_cdomain" + content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"certificate-name\"\r\n\r\n$_panos_certname" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"key\"\r\n\r\n$_panos_key" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"format\"\r\n\r\npem" content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"passphrase\"\r\n\r\n123456" - content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_cdomain.key")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_ckey")" + content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"file\"; filename=\"$(basename "$_panos_certname.key")\"${nl}Content-Type: application/octet-stream${nl}${nl}$(cat "$_ckey")" if [ "$_panos_template" ]; then content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"target-tpl\"\r\n\r\n$_panos_template" fi + if [ "$_panos_vsys" ]; then + content="$content${nl}--$delim${nl}Content-Disposition: form-data; name=\"target-tpl-vsys\"\r\n\r\n$_panos_vsys" + fi fi #Close multipart content="$content${nl}--$delim--${nl}${nl}" @@ -106,7 +125,6 @@ deployer() { # Commit changes if [ "$type" = 'commit' ]; then _debug "**** Committing changes ****" - export _H1="Content-Type: application/x-www-form-urlencoded" #Check for force commit - will commit ALL uncommited changes to the firewall. Use with caution! if [ "$FORCE" ]; then _debug "Force switch detected. Committing ALL changes to the firewall." @@ -118,6 +136,20 @@ deployer() { content="type=commit&action=partial&key=$_panos_key&cmd=$cmd" fi + # Query job status + if [ "$type" = 'job_status' ]; then + echo "**** Querying job $_commit_job_id status ****" + cmd=$(printf "%s" "$_commit_job_id" | _url_encode) + content="type=op&key=$_panos_key&cmd=$cmd" + fi + + # Push changes + if [ "$type" = 'push' ]; then + echo "**** Pushing changes ****" + cmd=$(printf "%s" "$_panos_template_stack$_panos_user" | _url_encode) + content="type=commit&action=all&key=$_panos_key&cmd=$cmd" + fi + response=$(_post "$content" "$panos_url" "" "POST") parse_response "$response" "$type" # Saving response to variables @@ -126,6 +158,8 @@ deployer() { if [ "$response_status" = "success" ]; then _debug "Successfully deployed $type" return 0 + elif [ "$_commit_job_status" ]; then + _debug "Commit Job Status = $_commit_job_status" else _err "Deploy of type $type failed. Try deploying with --debug to troubleshoot." _debug "$message" @@ -191,11 +225,41 @@ panos_deploy() { _getdeployconf PANOS_TEMPLATE fi + # PANOS_TEMPLATE_STACK + if [ "$PANOS_TEMPLATE_STACK" ]; then + _debug "Detected ENV variable PANOS_TEMPLATE_STACK. Saving to file." + _savedeployconf PANOS_TEMPLATE_STACK "$PANOS_TEMPLATE_STACK" 1 + else + _debug "Attempting to load variable PANOS_TEMPLATE_STACK from file." + _getdeployconf PANOS_TEMPLATE_STACK + fi + + # PANOS_TEMPLATE_STACK + if [ "$PANOS_VSYS" ]; then + _debug "Detected ENV variable PANOS_VSYS. Saving to file." + _savedeployconf PANOS_VSYS "$PANOS_VSYS" 1 + else + _debug "Attempting to load variable PANOS_VSYS from file." + _getdeployconf PANOS_VSYS + fi + + # PANOS_CERTNAME + if [ "$PANOS_CERTNAME" ]; then + _debug "Detected ENV variable PANOS_CERTNAME. Saving to file." + _savedeployconf PANOS_CERTNAME "$PANOS_CERTNAME" 1 + else + _debug "Attempting to load variable PANOS_CERTNAME from file." + _getdeployconf PANOS_CERTNAME + fi + #Store variables _panos_host=$PANOS_HOST _panos_user=$PANOS_USER _panos_pass=$PANOS_PASS _panos_template=$PANOS_TEMPLATE + _panos_template_stack=$PANOS_TEMPLATE_STACK + _panos_vsys=$PANOS_VSYS + _panos_certname=$PANOS_CERTNAME #Test API Key if found. If the key is invalid, the variable _panos_key will be unset. if [ "$_panos_host" ] && [ "$_panos_key" ]; then @@ -214,6 +278,12 @@ panos_deploy() { _err "No password found. If this is your first time deploying, please set PANOS_PASS in ENV variables. You can delete it after you have successfully deployed the certs." return 1 else + # Use certificate name based on the first domain on the certificate if no custom certificate name is set + if [ -z "$_panos_certname" ]; then + _panos_certname="$_cdomain" + _savedeployconf PANOS_CERTNAME "$_panos_certname" 1 + fi + # Generate a new API key if no valid API key is found if [ -z "$_panos_key" ]; then _debug "**** Generating new PANOS API KEY ****" @@ -229,6 +299,20 @@ panos_deploy() { deployer cert deployer key deployer commit + if [ "$_panos_template_stack" ]; then + # try to get job status for 20 times in 30 sec interval + i=0 + while [ "$i" -lt $_COMMIT_WAIT_ITERATIONS ]; do + deployer job_status + if [ "$_commit_job_status" = "OK" ]; then + echo "Commit finished!" + break + fi + sleep $_COMMIT_WAIT_INTERVAL + i=$((i + 1)) + done + deployer push + fi fi fi } diff --git a/deploy/proxmoxbs.sh b/deploy/proxmoxbs.sh new file mode 100644 index 00000000..e8528e8f --- /dev/null +++ b/deploy/proxmoxbs.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env sh + +# Deploy certificates to a proxmox backup server using the API. +# +# Environment variables that can be set are: +# `DEPLOY_PROXMOXBS_SERVER`: The hostname of the proxmox backup server. Defaults to +# _cdomain. +# `DEPLOY_PROXMOXBS_SERVER_PORT`: The port number the management interface is on. +# Defaults to 8007. +# `DEPLOY_PROXMOXBS_USER`: The user we'll connect as. Defaults to root. +# `DEPLOY_PROXMOXBS_USER_REALM`: The authentication realm the user authenticates +# with. Defaults to pam. +# `DEPLOY_PROXMOXBS_API_TOKEN_NAME`: The name of the API token created for the +# user account. Defaults to acme. +# `DEPLOY_PROXMOXBS_API_TOKEN_KEY`: The API token. Required. + +proxmoxbs_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug2 _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + # "Sane" defaults. + _getdeployconf DEPLOY_PROXMOXBS_SERVER + if [ -z "$DEPLOY_PROXMOXBS_SERVER" ]; then + _target_hostname="$_cdomain" + else + _target_hostname="$DEPLOY_PROXMOXBS_SERVER" + _savedeployconf DEPLOY_PROXMOXBS_SERVER "$DEPLOY_PROXMOXBS_SERVER" + fi + _debug2 DEPLOY_PROXMOXBS_SERVER "$_target_hostname" + + _getdeployconf DEPLOY_PROXMOXBS_SERVER_PORT + if [ -z "$DEPLOY_PROXMOXBS_SERVER_PORT" ]; then + _target_port="8007" + else + _target_port="$DEPLOY_PROXMOXBS_SERVER_PORT" + _savedeployconf DEPLOY_PROXMOXBS_SERVER_PORT "$DEPLOY_PROXMOXBS_SERVER_PORT" + fi + _debug2 DEPLOY_PROXMOXBS_SERVER_PORT "$_target_port" + + # Complete URL. + _target_url="https://${_target_hostname}:${_target_port}/api2/json/nodes/localhost/certificates/custom" + _debug TARGET_URL "$_target_url" + + # More "sane" defaults. + _getdeployconf DEPLOY_PROXMOXBS_USER + if [ -z "$DEPLOY_PROXMOXBS_USER" ]; then + _proxmoxbs_user="root" + else + _proxmoxbs_user="$DEPLOY_PROXMOXBS_USER" + _savedeployconf DEPLOY_PROXMOXBS_USER "$DEPLOY_PROXMOXBS_USER" + fi + _debug2 DEPLOY_PROXMOXBS_USER "$_proxmoxbs_user" + + _getdeployconf DEPLOY_PROXMOXBS_USER_REALM + if [ -z "$DEPLOY_PROXMOXBS_USER_REALM" ]; then + _proxmoxbs_user_realm="pam" + else + _proxmoxbs_user_realm="$DEPLOY_PROXMOXBS_USER_REALM" + _savedeployconf DEPLOY_PROXMOXBS_USER_REALM "$DEPLOY_PROXMOXBS_USER_REALM" + fi + _debug2 DEPLOY_PROXMOXBS_USER_REALM "$_proxmoxbs_user_realm" + + _getdeployconf DEPLOY_PROXMOXBS_API_TOKEN_NAME + if [ -z "$DEPLOY_PROXMOXBS_API_TOKEN_NAME" ]; then + _proxmoxbs_api_token_name="acme" + else + _proxmoxbs_api_token_name="$DEPLOY_PROXMOXBS_API_TOKEN_NAME" + _savedeployconf DEPLOY_PROXMOXBS_API_TOKEN_NAME "$DEPLOY_PROXMOXBS_API_TOKEN_NAME" + fi + _debug2 DEPLOY_PROXMOXBS_API_TOKEN_NAME "$_proxmoxbs_api_token_name" + + # This is required. + _getdeployconf DEPLOY_PROXMOXBS_API_TOKEN_KEY + if [ -z "$DEPLOY_PROXMOXBS_API_TOKEN_KEY" ]; then + _err "API key not provided." + return 1 + else + _proxmoxbs_api_token_key="$DEPLOY_PROXMOXBS_API_TOKEN_KEY" + _savedeployconf DEPLOY_PROXMOXBS_API_TOKEN_KEY "$DEPLOY_PROXMOXBS_API_TOKEN_KEY" + fi + _debug2 DEPLOY_PROXMOXBS_API_TOKEN_KEY "$_proxmoxbs_api_token_key" + + # PBS API Token header value. Used in "Authorization: PBSAPIToken". + _proxmoxbs_header_api_token="${_proxmoxbs_user}@${_proxmoxbs_user_realm}!${_proxmoxbs_api_token_name}:${_proxmoxbs_api_token_key}" + _debug2 "Auth Header" "$_proxmoxbs_header_api_token" + + # Ugly. I hate putting heredocs inside functions because heredocs don't + # account for whitespace correctly but it _does_ work and is several times + # cleaner than anything else I had here. + # + # This dumps the json payload to a variable that should be passable to the + # _psot function. + _json_payload=$( + cat </dev/null 2>&1; then + _debug "Using RSA certificate." + else + _info "Verifying ECC certificate support." + + _ul_version="$(_get_unleashed_version)" + if [ -z "$_ul_version" ]; then + _err "Your controller doesn't support ECC certificates. Please deploy an RSA certificate." + return 1 + fi + + _ul_version_major="$(echo "$_ul_version" | cut -d . -f 1)" + _ul_version_minor="$(echo "$_ul_version" | cut -d . -f 2)" + if [ "$_ul_version_major" -lt "200" ]; then + _err "ZoneDirector doesn't support ECC certificates. Please deploy an RSA certificate." + return 1 + elif [ "$_ul_version_minor" -lt "13" ]; then + _err "Unleashed $_ul_version_major.$_ul_version_minor doesn't support ECC certificates. Please deploy an RSA certificate or upgrade to Unleashed 200.13+." + return 1 + fi + + _debug "ECC certificates OK for Unleashed $_ul_version_major.$_ul_version_minor." + fi + _info "Uploading certificate" _post_upload "uploadcert" "$_cfullchain" @@ -145,6 +169,10 @@ _response_cookie() { _response_header 'Set-Cookie' | sed 's/;.*//' } +_get_unleashed_version() { + _post '' "$_base_url/_cmdstat.jsp" | _egrep_o "version-num=\"[^\"]*\"" | cut -d '"' -f 2 +} + _post_upload() { _post_action="$1" _post_file="$2" diff --git a/deploy/strongswan.sh b/deploy/strongswan.sh index 14567d17..80353c54 100644 --- a/deploy/strongswan.sh +++ b/deploy/strongswan.sh @@ -33,7 +33,7 @@ strongswan_deploy() { return 1 fi _info _confdir "${_confdir}" - __deploy_cert "$@" "stroke" "${_confdir}" + __deploy_cert "stroke" "${_confdir}" "$@" ${_ipsec} reload fi # For modern vici mode @@ -50,7 +50,7 @@ strongswan_deploy() { _err "no swanctl config dir is found" return 1 fi - __deploy_cert "$@" "vici" "${_confdir}" + __deploy_cert "vici" "${_confdir}" "$@" ${_swanctl} --load-creds fi if [ -z "${_swanctl}" ] && [ -z "${_ipsec}" ]; then @@ -63,13 +63,13 @@ strongswan_deploy() { #################### Private functions below ################################## __deploy_cert() { - _cdomain="${1}" - _ckey="${2}" - _ccert="${3}" - _cca="${4}" - _cfullchain="${5}" - _swan_mode="${6}" - _confdir="${7}" + _swan_mode="${1}" + _confdir="${2}" + _cdomain="${3}" + _ckey="${4}" + _ccert="${5}" + _cca="${6}" + _cfullchain="${7}" _debug _cdomain "${_cdomain}" _debug _ckey "${_ckey}" _debug _ccert "${_ccert}" diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 0d01e199..3bfc9b02 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -186,8 +186,8 @@ synology_dsm_deploy() { if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then _getdeployconf SYNO_LOCAL_HOSTNAME _debug SYNO_LOCAL_HOSTNAME "${SYNO_LOCAL_HOSTNAME:-}" - if [ "$SYNO_LOCAL_HOSTNAME" != "1" ] && [ "$SYNO_LOCAL_HOSTNAME" == "$SYNO_HOSTNAME" ]; then - if [ "$SYNO_HOSTNAME" != "localhost" ] && [ "$SYNO_HOSTNAME" != "127.0.0.1" ]; then + if [ "$SYNO_HOSTNAME" != "localhost" ] && [ "$SYNO_HOSTNAME" != "127.0.0.1" ]; then + if [ "$SYNO_LOCAL_HOSTNAME" != "1" ]; then _err "SYNO_USE_TEMP_ADMIN=1 only support local deployment, though if you are sure that the hostname $SYNO_HOSTNAME is targeting to your **current local machine**, execute 'export SYNO_LOCAL_HOSTNAME=1' then rerun." return 1 fi @@ -320,7 +320,7 @@ synology_dsm_deploy() { _cleardeployconf SYNO_DEVICE_ID _cleardeployconf SYNO_DEVICE_NAME _savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN" - _savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_HOSTNAME" + _savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME" else _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" @@ -411,7 +411,7 @@ _temp_admin_create() { _username="$1" _password="$2" synouser --del "$_username" >/dev/null 2>/dev/null - synouser --add "$_username" "$_password" "" 0 "scruelt@hotmail.com" 0 >/dev/null + synouser --add "$_username" "$_password" "" 0 "" 0 >/dev/null } _temp_admin_cleanup() { diff --git a/deploy/truenas.sh b/deploy/truenas.sh index 407395a3..6a008bd7 100644 --- a/deploy/truenas.sh +++ b/deploy/truenas.sh @@ -217,7 +217,7 @@ truenas_deploy() { _app_id=$(echo "$_app_id_list" | sed -n "${i}p") _app_config="$(_post "\"$_app_id\"" "$_api_url/app/config" "" "POST" "application/json")" # Check if the app use the same certificate TrueNAS web UI - _app_active_cert_config=$(echo "$_app_config" | _json_decode | jq -r ".ix_certificates[\"$_active_cert_id\"]") + _app_active_cert_config=$(echo "$_app_config" | tr -d '\000-\037' | _json_decode | jq -r ".ix_certificates[\"$_active_cert_id\"]") if [ "$_app_active_cert_config" != "null" ]; then _info "Updating certificate from $_active_cert_id to $_cert_id for app: $_app_id" #Replace the old certificate id with the new one in path diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh new file mode 100644 index 00000000..df34f927 --- /dev/null +++ b/deploy/truenas_ws.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env sh + +# TrueNAS deploy script for SCALE/CORE using websocket +# It is recommend to use a wildcard certificate +# +# Websocket Documentation: https://www.truenas.com/docs/api/scale_websocket_api.html +# +# Tested with TrueNAS Scale - Electric Eel 24.10 +# Changes certificate in the following services: +# - Web UI +# - FTP +# - iX Apps +# +# The following environment variables must be set: +# ------------------------------------------------ +# +# # API KEY +# # Use the folowing URL to create a new API token: /ui/apikeys +# export DEPLOY_TRUENAS_APIKEY="/dev/null 2>&1 # fail quietly if we're not running as root + fi + # Update unifi service for certificate cipher compatibility + _unifi_system_properties="${DEPLOY_UNIFI_SYSTEM_PROPERTIES:-/usr/lib/unifi/data/system.properties}" if ${ACME_OPENSSL_BIN:-openssl} pkcs12 \ -in "$_import_pkcs12" \ -password pass:aircontrolenterprise \ -nokeys | ${ACME_OPENSSL_BIN:-openssl} x509 -text \ -noout | grep -i "signature" | grep -iq ecdsa >/dev/null 2>&1; then - cp -f /usr/lib/unifi/data/system.properties /usr/lib/unifi/data/system.properties_original - _info "Updating system configuration for cipher compatibility." - _info "Saved original system config to /usr/lib/unifi/data/system.properties_original" - sed -i '/unifi\.https\.ciphers/d' /usr/lib/unifi/data/system.properties - echo "unifi.https.ciphers=ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES128-GCM-SHA256" >>/usr/lib/unifi/data/system.properties - sed -i '/unifi\.https\.sslEnabledProtocols/d' /usr/lib/unifi/data/system.properties - echo "unifi.https.sslEnabledProtocols=TLSv1.3,TLSv1.2" >>/usr/lib/unifi/data/system.properties - _info "System configuration updated." + if [ -f "$(dirname "${DEPLOY_UNIFI_KEYSTORE}")/system.properties" ]; then + _unifi_system_properties="$(dirname "${DEPLOY_UNIFI_KEYSTORE}")/system.properties" + else + _unifi_system_properties="/usr/lib/unifi/data/system.properties" + fi + if [ -f "${_unifi_system_properties}" ]; then + cp -f "${_unifi_system_properties}" "${_unifi_system_properties}"_original + _info "Updating system configuration for cipher compatibility." + _info "Saved original system config to ${_unifi_system_properties}_original" + sed -i '/unifi\.https\.ciphers/d' "${_unifi_system_properties}" + echo "unifi.https.ciphers=ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES128-GCM-SHA256" >>"${_unifi_system_properties}" + sed -i '/unifi\.https\.sslEnabledProtocols/d' "${_unifi_system_properties}" + echo "unifi.https.sslEnabledProtocols=TLSv1.3,TLSv1.2" >>"${_unifi_system_properties}" + _info "System configuration updated." + fi fi rm "$_import_pkcs12" # Restarting unifi-core will bring up unifi, doing it out of order results in # a certificate error, and breaks wifiman. - # Restart if we aren't doing unifi-core, otherwise stop for later restart. - if systemctl -q is-active unifi; then - if [ ! -f "${DEPLOY_UNIFI_CORE_CONFIG:-/data/unifi-core/config}/unifi-core.key" ]; then - _reload_cmd="${_reload_cmd:+$_reload_cmd && }systemctl restart unifi" - else - _reload_cmd="${_reload_cmd:+$_reload_cmd && }systemctl stop unifi" - fi + # Restart if we aren't doing Unifi OS (e.g. unifi-core service), otherwise stop for later restart. + _unifi_reload="${DEPLOY_UNIFI_RELOAD:-systemctl restart unifi}" + if [ ! -f "${DEPLOY_UNIFI_CORE_CONFIG:-/data/unifi-core/config}/unifi-core.key" ]; then + _reload_cmd="${_reload_cmd:+$_reload_cmd && }$_unifi_reload" + else + _info "Stopping Unifi Controller for later restart." + _unifi_stop=$(echo "${_unifi_reload}" | sed -e 's/restart/stop/') + $_unifi_stop + _reload_cmd="${_reload_cmd:+$_reload_cmd && }$_unifi_reload" + _info "Unifi Controller stopped." fi _services_updated="${_services_updated} unifi" _info "Install Unifi Controller certificate success!" @@ -181,13 +209,24 @@ unifi_deploy() { return 1 fi # Cloud Key expects to load the keystore from /etc/ssl/private/unifi.keystore.jks. - # Normally /usr/lib/unifi/data/keystore is a symlink there (so the keystore was - # updated above), but if not, we don't know how to handle this installation: - if ! cmp -s "$_unifi_keystore" "${_cloudkey_certdir}/unifi.keystore.jks"; then - _err "Unsupported Cloud Key configuration: keystore not found at '${_cloudkey_certdir}/unifi.keystore.jks'" - return 1 + # It appears that unifi won't start if this is a symlink, so we'll copy it instead. + + # if ! cmp -s "$_unifi_keystore" "${_cloudkey_certdir}/unifi.keystore.jks"; then + # _err "Unsupported Cloud Key configuration: keystore not found at '${_cloudkey_certdir}/unifi.keystore.jks'" + # return 1 + # fi + + _info "Updating ${_cloudkey_certdir}/unifi.keystore.jks" + if [ -e "${_cloudkey_certdir}/unifi.keystore.jks" ]; then + if [ -L "${_cloudkey_certdir}/unifi.keystore.jks" ]; then + rm -f "${_cloudkey_certdir}/unifi.keystore.jks" + else + mv "${_cloudkey_certdir}/unifi.keystore.jks" "${_cloudkey_certdir}/unifi.keystore.jks_original" + fi fi + cp "${_unifi_keystore}" "${_cloudkey_certdir}/unifi.keystore.jks" + cat "$_cfullchain" >"${_cloudkey_certdir}/cloudkey.crt" cat "$_ckey" >"${_cloudkey_certdir}/cloudkey.key" (cd "$_cloudkey_certdir" && tar -cf cert.tar cloudkey.crt cloudkey.key unifi.keystore.jks) @@ -215,14 +254,14 @@ unifi_deploy() { # Save the existing certs in case something goes wrong. cp -f "${_unifi_core_config}"/unifi-core.crt "${_unifi_core_config}"/unifi-core_original.crt cp -f "${_unifi_core_config}"/unifi-core.key "${_unifi_core_config}"/unifi-core_original.key - _info "Previous certificate and key saved to ${_unifi_core_config}/unifi-core_original.crt/key." + _info "Previous certificate and key saved to ${_unifi_core_config}/unifi-core_original.crt.key." cat "$_cfullchain" >"${_unifi_core_config}/unifi-core.crt" cat "$_ckey" >"${_unifi_core_config}/unifi-core.key" - if systemctl -q is-active unifi-core; then - _reload_cmd="${_reload_cmd:+$_reload_cmd && }systemctl restart unifi-core" - fi + _unifi_os_reload="${DEPLOY_UNIFI_OS_RELOAD:-systemctl restart unifi-core}" + _reload_cmd="${_reload_cmd:+$_reload_cmd && }$_unifi_os_reload" + _info "Install UnifiOS certificate success!" _services_updated="${_services_updated} unifi-core" elif [ "$DEPLOY_UNIFI_CORE_CONFIG" ]; then @@ -261,6 +300,8 @@ unifi_deploy() { _savedeployconf DEPLOY_UNIFI_CLOUDKEY_CERTDIR "$DEPLOY_UNIFI_CLOUDKEY_CERTDIR" _savedeployconf DEPLOY_UNIFI_CORE_CONFIG "$DEPLOY_UNIFI_CORE_CONFIG" _savedeployconf DEPLOY_UNIFI_RELOAD "$DEPLOY_UNIFI_RELOAD" + _savedeployconf DEPLOY_UNIFI_OS_RELOAD "$DEPLOY_UNIFI_OS_RELOAD" + _savedeployconf DEPLOY_UNIFI_SYSTEM_PROPERTIES "$DEPLOY_UNIFI_SYSTEM_PROPERTIES" return 0 } diff --git a/deploy/vault.sh b/deploy/vault.sh index 03a0de83..89994f4b 100644 --- a/deploy/vault.sh +++ b/deploy/vault.sh @@ -80,10 +80,15 @@ vault_deploy() { if [ -n "$VAULT_RENEW_TOKEN" ]; then URL="$VAULT_ADDR/v1/auth/token/renew-self" _info "Renew the Vault token to default TTL" - if ! _post "" "$URL" >/dev/null; then + _response=$(_post "" "$URL") + if [ "$?" != "0" ]; then _err "Failed to renew the Vault token" return 1 fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Failed to renew the Vault token: $_response" + return 1 + fi fi URL="$VAULT_ADDR/v1/$VAULT_PREFIX/$_cdomain" @@ -91,29 +96,85 @@ vault_deploy() { if [ -n "$VAULT_FABIO_MODE" ]; then _info "Writing certificate and key to $URL in Fabio mode" if [ -n "$VAULT_KV_V2" ]; then - _post "{ \"data\": {\"cert\": \"$_cfullchain\", \"key\": \"$_ckey\"} }" "$URL" >/dev/null || return 1 + _response=$(_post "{ \"data\": {\"cert\": \"$_cfullchain\", \"key\": \"$_ckey\"} }" "$URL") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error: $_response" + return 1 + fi else - _post "{\"cert\": \"$_cfullchain\", \"key\": \"$_ckey\"}" "$URL" >/dev/null || return 1 + _response=$(_post "{\"cert\": \"$_cfullchain\", \"key\": \"$_ckey\"}" "$URL") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error: $_response" + return 1 + fi fi else if [ -n "$VAULT_KV_V2" ]; then _info "Writing certificate to $URL/cert.pem" - _post "{\"data\": {\"value\": \"$_ccert\"}}" "$URL/cert.pem" >/dev/null || return 1 + _response=$(_post "{\"data\": {\"value\": \"$_ccert\"}}" "$URL/cert.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing cert.pem: $_response" + return 1 + fi + _info "Writing key to $URL/cert.key" - _post "{\"data\": {\"value\": \"$_ckey\"}}" "$URL/cert.key" >/dev/null || return 1 + _response=$(_post "{\"data\": {\"value\": \"$_ckey\"}}" "$URL/cert.key") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing cert.key: $_response" + return 1 + fi + _info "Writing CA certificate to $URL/ca.pem" - _post "{\"data\": {\"value\": \"$_cca\"}}" "$URL/ca.pem" >/dev/null || return 1 + _response=$(_post "{\"data\": {\"value\": \"$_cca\"}}" "$URL/ca.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing ca.pem: $_response" + return 1 + fi + _info "Writing full-chain certificate to $URL/fullchain.pem" - _post "{\"data\": {\"value\": \"$_cfullchain\"}}" "$URL/fullchain.pem" >/dev/null || return 1 + _response=$(_post "{\"data\": {\"value\": \"$_cfullchain\"}}" "$URL/fullchain.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing fullchain.pem: $_response" + return 1 + fi else _info "Writing certificate to $URL/cert.pem" - _post "{\"value\": \"$_ccert\"}" "$URL/cert.pem" >/dev/null || return 1 + _response=$(_post "{\"value\": \"$_ccert\"}" "$URL/cert.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing cert.pem: $_response" + return 1 + fi + _info "Writing key to $URL/cert.key" - _post "{\"value\": \"$_ckey\"}" "$URL/cert.key" >/dev/null || return 1 + _response=$(_post "{\"value\": \"$_ckey\"}" "$URL/cert.key") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing cert.key: $_response" + return 1 + fi + _info "Writing CA certificate to $URL/ca.pem" - _post "{\"value\": \"$_cca\"}" "$URL/ca.pem" >/dev/null || return 1 + _response=$(_post "{\"value\": \"$_cca\"}" "$URL/ca.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing ca.pem: $_response" + return 1 + fi + _info "Writing full-chain certificate to $URL/fullchain.pem" - _post "{\"value\": \"$_cfullchain\"}" "$URL/fullchain.pem" >/dev/null || return 1 + _response=$(_post "{\"value\": \"$_cfullchain\"}" "$URL/fullchain.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing fullchain.pem: $_response" + return 1 + fi fi # To make it compatible with the wrong ca path `chain.pem` which was used in former versions @@ -121,11 +182,20 @@ vault_deploy() { _err "The CA certificate has moved from chain.pem to ca.pem, if you don't depend on chain.pem anymore, you can delete it to avoid this warning" _info "Updating CA certificate to $URL/chain.pem for backward compatibility" if [ -n "$VAULT_KV_V2" ]; then - _post "{\"data\": {\"value\": \"$_cca\"}}" "$URL/chain.pem" >/dev/null || return 1 + _response=$(_post "{\"data\": {\"value\": \"$_cca\"}}" "$URL/chain.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing chain.pem: $_response" + return 1 + fi else - _post "{\"value\": \"$_cca\"}" "$URL/chain.pem" >/dev/null || return 1 + _response=$(_post "{\"value\": \"$_cca\"}" "$URL/chain.pem") + if [ "$?" != "0" ]; then return 1; fi + if echo "$_response" | grep -q '"errors":\['; then + _err "Vault error writing chain.pem: $_response" + return 1 + fi fi fi fi - } diff --git a/deploy/zyxel_gs1900.sh b/deploy/zyxel_gs1900.sh new file mode 100644 index 00000000..37cf6005 --- /dev/null +++ b/deploy/zyxel_gs1900.sh @@ -0,0 +1,500 @@ +#!/usr/bin/env sh + +# Deploy certificates to Zyxel GS1900 series switches +# +# This script uses the https web administration interface in order +# to upload updated certificates to Zyxel GS1900 series switches. +# Only a few models have been tested but untested switches from the +# same model line may work as well. If you test and confirm a switch +# as working please submit a pull request updating this compatibility +# list! +# +# Known Issues: +# 1. This is a consumer grade switch and is a bit underpowered +# the longer the RSA key size the slower your switch web UI +# will be. RSA 2048 will work, RSA 4096 will work but you may +# experience performance problems. +# 2. You must use RSA certificates. The switch will reject EC-256 +# and EC-384 certificates in firmware 2.80 +# See: https://community.zyxel.com/en/discussion/21506/bug-cannot-import-ssl-cert-on-gs1900-8-and-gs1900-24e-firmware-v2-80/ +# +# Current GS1900 Switch Compatibility: +# GS1900-8 - Working as of firmware V2.80 +# GS1900-8HP - Untested +# GS1900-10HP - Untested +# GS1900-16 - Untested +# GS1900-24 - Untested +# GS1900-24E - Working as of firmware V2.80 +# GS1900-24EP - Untested +# GS1900-24HP - Untested +# GS1900-48 - Untested +# GS1900-48HP - Untested +# +# Prerequisite Setup Steps: +# 1. Install at least firmware V2.80 on your switch +# 2. Enable HTTPS web management on your switch +# +# Usage: +# 1. Ensure the switch has firmware V2.80 or later. +# 2. Ensure the switch has HTTPS management enabled. +# 3. Set the appropriate environment variables for your environment. +# +# DEPLOY_ZYXEL_SWITCH - The switch hostname. (Default: _cdomain) +# DEPLOY_ZYXEL_SWITCH_USER - The webadmin user. (Default: admin) +# DEPLOY_ZYXEL_SWITCH_PASSWORD - The webadmin password for the switch. +# DEPLOY_ZYXEL_SWITCH_REBOOT - If "1" reboot after update. (Default: "0") +# +# 4. Run the deployment plugin: +# acme.sh --deploy --deploy-hook zyxel_gs1900 -d example.com +# +# returns 0 means success, otherwise error. + +#domain keyfile certfile cafile fullchain +zyxel_gs1900_deploy() { + _zyxel_gs1900_minimum_firmware_version="v2.80" + + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug2 _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + _getdeployconf DEPLOY_ZYXEL_SWITCH + _getdeployconf DEPLOY_ZYXEL_SWITCH_USER + _getdeployconf DEPLOY_ZYXEL_SWITCH_PASSWORD + _getdeployconf DEPLOY_ZYXEL_SWITCH_REBOOT + + if [ -z "$DEPLOY_ZYXEL_SWITCH" ]; then + DEPLOY_ZYXEL_SWITCH="$_cdomain" + fi + + if [ -z "$DEPLOY_ZYXEL_SWITCH_USER" ]; then + DEPLOY_ZYXEL_SWITCH_USER="admin" + fi + + if [ -z "$DEPLOY_ZYXEL_SWITCH_PASSWORD" ]; then + DEPLOY_ZYXEL_SWITCH_PASSWORD="1234" + fi + + if [ -z "$DEPLOY_ZYXEL_SWITCH_REBOOT" ]; then + DEPLOY_ZYXEL_SWITCH_REBOOT="0" + fi + + _savedeployconf DEPLOY_ZYXEL_SWITCH "$DEPLOY_ZYXEL_SWITCH" + _savedeployconf DEPLOY_ZYXEL_SWITCH_USER "$DEPLOY_ZYXEL_SWITCH_USER" + _savedeployconf DEPLOY_ZYXEL_SWITCH_PASSWORD "$DEPLOY_ZYXEL_SWITCH_PASSWORD" + _savedeployconf DEPLOY_ZYXEL_SWITCH_REBOOT "$DEPLOY_ZYXEL_SWITCH_REBOOT" + + _debug DEPLOY_ZYXEL_SWITCH "$DEPLOY_ZYXEL_SWITCH" + _debug DEPLOY_ZYXEL_SWITCH_USER "$DEPLOY_ZYXEL_SWITCH_USER" + _secure_debug DEPLOY_ZYXEL_SWITCH_PASSWORD "$DEPLOY_ZYXEL_SWITCH_PASSWORD" + _debug DEPLOY_ZYXEL_SWITCH_REBOOT "$DEPLOY_ZYXEL_SWITCH_REBOOT" + + _zyxel_switch_base_uri="https://${DEPLOY_ZYXEL_SWITCH}" + + _info "Beginning to deploy to a Zyxel GS1900 series switch at ${_zyxel_switch_base_uri}." + _zyxel_gs1900_deployment_precheck || return $? + + _zyxel_gs1900_should_update + if [ "$?" != "0" ]; then + _info "The switch already has our certificate installed. No update required." + return 0 + else + _info "The switch does not yet have our certificate installed." + fi + + _info "Logging into the switch web interface." + _zyxel_gs1900_login || return $? + + _info "Validating the switch is compatible with this deployment process." + _zyxel_gs1900_validate_device_compatibility || return $? + + _info "Uploading the certificate." + _zyxel_gs1900_upload_certificate || return $? + + if [ "$DEPLOY_ZYXEL_SWITCH_REBOOT" = "1" ]; then + _info "Rebooting the switch." + _zyxel_gs1900_trigger_reboot || return $? + fi + + return 0 +} + +_zyxel_gs1900_deployment_precheck() { + # Initialize the keylength if it isn't already + if [ -z "$Le_Keylength" ]; then + Le_Keylength="" + fi + + if _isEccKey "$Le_Keylength"; then + _info "Warning: Zyxel GS1900 switches are not currently known to work with ECC keys!" + _info "You can continue, but your switch may reject your key." + elif [ -n "$Le_Keylength" ] && [ "$Le_Keylength" -gt "2048" ]; then + _info "Warning: Your RSA key length is greater than 2048!" + _info "You can continue, but you may experience performance issues in the web administration interface." + fi + + # Check the server for some common failure modes prior to authentication and certificate upload in order to avoid + # sending a certificate when we may not want to. + test_login_response=$(_post "username=test&password=test&login=true;" "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi?cmd=0.html" '' "POST" "application/x-www-form-urlencoded" 2>&1) + test_login_page_exitcode="$?" + _debug3 "Test Login Response: ${test_login_response}" + if [ "$test_login_page_exitcode" -ne "0" ]; then + if { [ "${ACME_USE_WGET:-0}" = "0" ] && [ "$test_login_page_exitcode" = "60" ]; } || { [ "${ACME_USE_WGET:-0}" = "1" ] && [ "$test_login_page_exitcode" = "5" ]; }; then + _err "The SSL certificate at $_zyxel_switch_base_uri could not be validated." + _err "Please double check your hostname, port, and that you are actually connecting to your switch." + _err "If the problem persists then please ensure that the certificate is not self-signed, has not" + _err "expired, and matches the switch hostname. If you expect validation to fail then you can disable" + _err "certificate validation by running with --insecure." + return 1 + elif [ "${ACME_USE_WGET:-0}" = "0" ] && [ "$test_login_page_exitcode" = "56" ]; then + _debug3 "Intentionally ignore curl exit code 56 in our precheck" + else + _err "Failed to submit the initial login attempt to $_zyxel_switch_base_uri." + return 1 + fi + fi +} + +_zyxel_gs1900_login() { + # Login to the switch and set the appropriate auth cookie in _H1 + username_encoded=$(printf "%s" "$DEPLOY_ZYXEL_SWITCH_USER" | _url_encode) + password_encoded=$(_zyxel_gs1900_password_obfuscate "$DEPLOY_ZYXEL_SWITCH_PASSWORD" | _url_encode) + + login_response=$(_post "username=${username_encoded}&password=${password_encoded}&login=true;" "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi?cmd=0.html" '' "POST" "application/x-www-form-urlencoded" | tr -d '\n') + auth_response=$(_post "authId=${login_response}&login_chk=true" "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi?cmd=0.html" '' "POST" "application/x-www-form-urlencoded" | tr -d '\n') + if [ "$auth_response" != "OK" ]; then + _err "Login failed due to invalid credentials." + _err "Please double check the configured username and password and try again." + return 1 + fi + + sessionid=$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'HTTPS_XSSID=[^;]*;' | tr -d ';') + _secure_debug2 "sessionid" "$sessionid" + + export _H1="Cookie: $sessionid" + _secure_debug2 "_H1" "$_H1" + + return 0 +} + +_zyxel_gs1900_validate_device_compatibility() { + # Check the switches model and firmware version and throw errors + # if this script isn't compatible. + device_info_html=$(_get "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi?cmd=12" | tr -d '\n') + + model_name=$(_zyxel_gs1900_get_model "$device_info_html") + _debug2 "model_name" "$model_name" + if [ -z "$model_name" ]; then + _err "Could not find the switch model name." + _err "Please re-run with --debug and report a bug." + return $? + fi + + if ! expr "$model_name" : "GS1900-" >/dev/null; then + _err "Switch is an unsupported model: $model_name" + return 1 + fi + + firmware_version=$(_zyxel_gs1900_get_firmware_version "$device_info_html") + _debug2 "firmware_version" "$firmware_version" + if [ -z "$firmware_version" ]; then + _err "Could not find the switch firmware version." + _err "Please re-run with --debug and report a bug." + return $? + fi + + _debug2 "_zyxel_gs1900_minimum_firmware_version" "$_zyxel_gs1900_minimum_firmware_version" + minimum_major_version=$(_zyxel_gs1900_parse_major_version "$_zyxel_gs1900_minimum_firmware_version") + _debug2 "minimum_major_version" "$minimum_major_version" + minimum_minor_version=$(_zyxel_gs1900_parse_minor_version "$_zyxel_gs1900_minimum_firmware_version") + _debug2 "minimum_minor_version" "$minimum_minor_version" + + _debug2 "firmware_version" "$firmware_version" + firmware_major_version=$(_zyxel_gs1900_parse_major_version "$firmware_version") + _debug2 "firmware_major_version" "$firmware_major_version" + firmware_minor_version=$(_zyxel_gs1900_parse_minor_version "$firmware_version") + _debug2 "firmware_minor_version" "$firmware_minor_version" + + _ret=0 + if [ "$firmware_major_version" -lt "$minimum_major_version" ]; then + _ret=1 + elif [ "$firmware_major_version" -eq "$minimum_major_version" ] && [ "$firmware_minor_version" -lt "$minimum_minor_version" ]; then + _ret=1 + fi + + if [ "$_ret" != "0" ]; then + _err "Unsupported firmware version $firmware_version. Please upgrade to at least version $_zyxel_gs1900_minimum_firmware_version." + fi + + return $? +} + +_zyxel_gs1900_should_update() { + # Get the remote certificate serial number + _remote_cert=$(${ACME_OPENSSL_BIN:-openssl} s_client -showcerts -connect "${DEPLOY_ZYXEL_SWITCH}:443" 2>/dev/null "${upload_post_request}" + + _info "Upload certificate to the switch" + + # Unfortunately we cannot rely upon the switch response across switch models + # to return a consistent body return - so we cannot inspect the result of this + # upload to determine success. + upload_response=$(_zyxel_upload_pkcs12 "${upload_post_request}" "${upload_post_boundary}" 2>&1) + _debug3 "Upload response: ${upload_response}" + rm "${upload_post_request}" + + # Pause for a few seconds to give the switch a chance to process the certificate + # For some reason I've found this to be necessary on my GS1900-24E + _debug2 "Waiting 4 seconds for the switch to process the newly uploaded certificate." + sleep "4" + + # Check to see whether or not our update was successful + _ret=0 + _zyxel_gs1900_should_update + if [ "$?" != "0" ]; then + _info "The certificate was updated successfully" + else + _ret=1 + _err "The certificate upload does not appear to have worked." + _err "The remote certificate does not match the certificate we tried to upload." + _err "Please re-run with --debug 2 and review for unexpected errors. If none can be found please submit a bug." + fi + + # ensure the temporary files are cleaned up + [ -f "${temp_pkcs12}" ] && rm -f "${temp_pkcs12}" + + return $_ret +} + +# make the certificate upload request using either +# --data binary with @ for file access in CURL +# or using --post-file for wget to ensure we upload +# the pkcs12 without getting tripped up on null bytes +# +# Usage _zyxel_upload_pkcs12 [body file name] [post boundary marker] +_zyxel_upload_pkcs12() { + bodyfilename="$1" + multipartformmarker="$2" + _post_url="${_zyxel_switch_base_uri}/cgi-bin/httpuploadcert.cgi" + httpmethod="POST" + _postContentType="multipart/form-data; boundary=${multipartformmarker}" + + if [ -z "$httpmethod" ]; then + httpmethod="POST" + fi + _debug $httpmethod + _debug "_post_url" "$_post_url" + _debug2 "bodyfilename" "$bodyfilename" + _debug2 "_postContentType" "$_postContentType" + + _inithttp + + if [ "$_ACME_CURL" ] && [ "${ACME_USE_WGET:-0}" = "0" ]; then + _CURL="$_ACME_CURL" + if [ "$HTTPS_INSECURE" ]; then + _CURL="$_CURL --insecure " + fi + if [ "$httpmethod" = "HEAD" ]; then + _CURL="$_CURL -I " + fi + _debug "_CURL" "$_CURL" + + response="$($_CURL --user-agent "$USER_AGENT" -X $httpmethod -H "$_H1" -H "$_H2" -H "$_H3" -H "$_H4" -H "$_H5" --data-binary "@${bodyfilename}" "$_post_url")" + + _ret="$?" + if [ "$_ret" != "0" ]; then + _err "Please refer to https://curl.haxx.se/libcurl/c/libcurl-errors.html for error code: $_ret" + if [ "$DEBUG" ] && [ "$DEBUG" -ge "2" ]; then + _err "Here is the curl dump log:" + _err "$(cat "$_CURL_DUMP")" + fi + fi + elif [ "$_ACME_WGET" ]; then + _WGET="$_ACME_WGET" + if [ "$HTTPS_INSECURE" ]; then + _WGET="$_WGET --no-check-certificate " + fi + _debug "_WGET" "$_WGET" + + response="$($_WGET -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --post-file="${bodyfilename}" "$_post_url" 2>"$HTTP_HEADER")" + + _ret="$?" + if [ "$_ret" = "8" ]; then + _ret=0 + _debug "wget returned 8 as the server returned a 'Bad Request' response. Let's process the response later." + fi + if [ "$_ret" != "0" ]; then + _err "Please refer to https://www.gnu.org/software/wget/manual/html_node/Exit-Status.html for error code: $_ret" + fi + if _contains "$_WGET" " -d "; then + # Demultiplex wget debug output + cat "$HTTP_HEADER" >&2 + _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" + fi + # remove leading whitespaces from header to match curl format + _sed_i 's/^ //g' "$HTTP_HEADER" + else + _ret="$?" + _err "Neither curl nor wget have been found, cannot make $httpmethod request." + fi + _debug "_ret" "$_ret" + printf "%s" "$response" + return $_ret +} + +_zyxel_gs1900_trigger_reboot() { + # Trigger a reboot via the management reboot page in the web ui + reboot_page_html=$(_get "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi?cmd=5888" | tr -d '\n') + reboot_xss_value=$(printf "%s" "$reboot_page_html" | _egrep_o 'name="XSSID"\s*value="[^"]+"' | sed 's/^.*="\([^"]\{1,\}\)"$/\1/g') + _secure_debug2 "reboot_xss_value" "$reboot_xss_value" + + reboot_response_html=$(_post "XSSID=${reboot_xss_value}&cmd=5889&sysSubmit=Reboot" "${_zyxel_switch_base_uri}/cgi-bin/dispatcher.cgi" '' "POST" "application/x-www-form-urlencoded") + reboot_message=$(printf "%s" "$reboot_response_html" | tr -d '\t\r\n\v\f' | _egrep_o "Rebooting now...") + + if [ -z "$reboot_message" ]; then + _err "Failed to trigger switch reboot!" + return 1 + fi + + return 0 +} + +# password +_zyxel_gs1900_password_obfuscate() { + # Return the password obfuscated via the same method used by the + # switch's web UI login process + echo "$1" | awk '{ + encoded = ""; + password = $1; + allowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + len = length($1); + pwi = length($1); + + for (i=1; i <= (321 - pwi); i++) + { + if (0 == i % 5 && pwi > 0) + { + encoded = (encoded)(substr(password, pwi--, 1)); + } + else if (i == 123) + { + if (len < 10) + { + encoded = (encoded)(0); + } + else + { + encoded = (encoded)(int(len / 10)); + } + } + else if (i == 289) + { + encoded = (encoded)(len % 10) + } + else + { + encoded = (encoded)(substr(allowed, int(rand() * length(allowed)), 1)) + } + } + printf("%s", encoded); + }' +} + +# html label +_zyxel_html_table_lookup() { + # Look up a value in the html representing the status page of the switch + # when provided with the html of the page and the label (i.e. "Model Name:") + html="$1" + label=$(printf "%s" "$2" | tr -d ' ') + lookup_result=$(printf "%s" "$html" | tr -d "\t\r\n\v\f" | sed 's//\n/g' | sed 's/]*>//g' | tr -d ' ' | grep -i "$label" | sed "s/$label<\/td>\([^<]\{1,\}\)<\/td><\/tr>/\1/i") + printf "%s" "$lookup_result" + return 0 +} + +# html +_zyxel_gs1900_get_model() { + html="$1" + model_name=$(_zyxel_html_table_lookup "$html" "Model Name:") + printf "%s" "$model_name" +} + +# html +_zyxel_gs1900_get_firmware_version() { + html="$1" + firmware_version=$(_zyxel_html_table_lookup "$html" "Firmware Version:" | _egrep_o "V[^.]+.[^(]+") + printf "%s" "$firmware_version" +} + +# version_number +_zyxel_gs1900_parse_major_version() { + printf "%s" "$1" | sed 's/^V\([0-9]\{1,\}\).\{1,\}$/\1/gi' +} + +# version_number +_zyxel_gs1900_parse_minor_version() { + printf "%s" "$1" | sed 's/^.\{1,\}\.\([0-9]\{1,\}\)$/\1/gi' +} diff --git a/dnsapi/dns_1984hosting.sh b/dnsapi/dns_1984hosting.sh index 906ea443..8d9676ac 100755 --- a/dnsapi/dns_1984hosting.sh +++ b/dnsapi/dns_1984hosting.sh @@ -128,7 +128,7 @@ _1984hosting_login() { _get "https://1984.hosting/accounts/login/" | grep "csrfmiddlewaretoken" csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')" - sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'sessionid=[^;]*;' | tr -d ';')" + sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')" if [ -z "$csrftoken" ] || [ -z "$sessionid" ]; then _err "One or more cookies are empty: '$csrftoken', '$sessionid'." @@ -145,7 +145,7 @@ _1984hosting_login() { _debug2 response "$response" if _contains "$response" '"loggedin": true'; then - One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'sessionid=[^;]*;' | tr -d ';')" + One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')" One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')" export One984HOSTING_SESSIONID_COOKIE export One984HOSTING_CSRFTOKEN_COOKIE diff --git a/dnsapi/dns_active24.sh b/dnsapi/dns_active24.sh index c56dd363..0f24c53a 100755 --- a/dnsapi/dns_active24.sh +++ b/dnsapi/dns_active24.sh @@ -1,17 +1,17 @@ #!/usr/bin/env sh # shellcheck disable=SC2034 -dns_active24_info='Active24.com -Site: Active24.com +dns_active24_info='Active24.cz +Site: Active24.cz Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_active24 Options: - ACTIVE24_Token API Token + Active24_ApiKey API Key. Called "Identifier" in the Active24 Admin + Active24_ApiSecret API Secret. Called "Secret key" in the Active24 Admin Issues: github.com/acmesh-official/acme.sh/issues/2059 -Author: Milan Pála ' -ACTIVE24_Api="https://api.active24.com" - -######## Public functions ##################### +Active24_Api="https://rest.active24.cz" +# export Active24_ApiKey=ak48l3h7-ak5d-qn4t-p8gc-b6fs8c3l +# export Active24_ApiSecret=ajvkeo3y82ndsu2smvxy3o36496dcascksldncsq # Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" # Used to add txt record @@ -22,8 +22,8 @@ dns_active24_add() { _active24_init _info "Adding txt record" - if _active24_rest POST "dns/$_domain/txt/v1" "{\"name\":\"$_sub_domain\",\"text\":\"$txtvalue\",\"ttl\":0}"; then - if _contains "$response" "errors"; then + if _active24_rest POST "/v2/service/$_service_id/dns/record" "{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":300}"; then + if _contains "$response" "error"; then _err "Add txt record error." return 1 else @@ -31,6 +31,7 @@ dns_active24_add() { return 0 fi fi + _err "Add txt record error." return 1 } @@ -44,19 +45,25 @@ dns_active24_rm() { _active24_init _debug "Getting txt records" - _active24_rest GET "dns/$_domain/records/v1" + # The API needs to send data in body in order the filter to work + # TODO: web can also add content $txtvalue to filter and then get the id from response + _active24_rest GET "/v2/service/$_service_id/dns/record" "{\"page\":1,\"descending\":true,\"sortBy\":\"name\",\"rowsPerPage\":100,\"totalRecords\":0,\"filters\":{\"type\":[\"TXT\"],\"name\":\"${_sub_domain}\"}}" + #_active24_rest GET "/v2/service/$_service_id/dns/record?rowsPerPage=100" - if _contains "$response" "errors"; then + if _contains "$response" "error"; then _err "Error" return 1 fi - hash_ids=$(echo "$response" | _egrep_o "[^{]+${txtvalue}[^}]+" | _egrep_o "hashId\":\"[^\"]+" | cut -c10-) + # Note: it might never be more than one record actually, NEEDS more INVESTIGATION + record_ids=$(printf "%s" "$response" | _egrep_o "[^{]+${txtvalue}[^}]+" | _egrep_o '"id" *: *[^,]+' | cut -d ':' -f 2) + _debug2 record_ids "$record_ids" - for hash_id in $hash_ids; do - _debug "Removing hash_id" "$hash_id" - if _active24_rest DELETE "dns/$_domain/$hash_id/v1" ""; then - if _contains "$response" "errors"; then + for redord_id in $record_ids; do + _debug "Removing record_id" "$redord_id" + _debug "txtvalue" "$txtvalue" + if _active24_rest DELETE "/v2/service/$_service_id/dns/record/$redord_id" ""; then + if _contains "$response" "error"; then _err "Unable to remove txt record." return 1 else @@ -70,21 +77,15 @@ dns_active24_rm() { return 1 } -#################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=sdjkglgdfewsdfg _get_root() { domain=$1 + i=1 + p=1 - if ! _active24_rest GET "dns/domains/v1"; then + if ! _active24_rest GET "/v1/user/self/service"; then return 1 fi - i=1 - p=1 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) _debug "h" "$h" @@ -104,45 +105,102 @@ _get_root() { return 1 } -_active24_rest() { - m=$1 - ep="$2" - data="$3" - _debug "$ep" - - export _H1="Authorization: Bearer $ACTIVE24_Token" - - if [ "$m" != "GET" ]; then - _debug "data" "$data" - response="$(_post "$data" "$ACTIVE24_Api/$ep" "" "$m" "application/json")" - else - response="$(_get "$ACTIVE24_Api/$ep")" +_active24_init() { + Active24_ApiKey="${Active24_ApiKey:-$(_readaccountconf_mutable Active24_ApiKey)}" + Active24_ApiSecret="${Active24_ApiSecret:-$(_readaccountconf_mutable Active24_ApiSecret)}" + #Active24_ServiceId="${Active24_ServiceId:-$(_readaccountconf_mutable Active24_ServiceId)}" + + if [ -z "$Active24_ApiKey" ] || [ -z "$Active24_ApiSecret" ]; then + Active24_ApiKey="" + Active24_ApiSecret="" + _err "You don't specify Active24 api key and ApiSecret yet." + _err "Please create your key and try again." + return 1 fi - if [ "$?" != "0" ]; then - _err "error $ep" + #save the credentials to the account conf file. + _saveaccountconf_mutable Active24_ApiKey "$Active24_ApiKey" + _saveaccountconf_mutable Active24_ApiSecret "$Active24_ApiSecret" + + _debug "A24 API CHECK" + if ! _active24_rest GET "/v2/check"; then + _err "A24 API check failed with: $response" return 1 fi - _debug2 response "$response" - return 0 -} -_active24_init() { - ACTIVE24_Token="${ACTIVE24_Token:-$(_readaccountconf_mutable ACTIVE24_Token)}" - if [ -z "$ACTIVE24_Token" ]; then - ACTIVE24_Token="" - _err "You didn't specify a Active24 api token yet." - _err "Please create the token and try again." + if ! echo "$response" | tr -d " " | grep \"verified\":true >/dev/null; then + _err "A24 API check failed with: $response" return 1 fi - _saveaccountconf_mutable ACTIVE24_Token "$ACTIVE24_Token" - _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" return 1 fi + _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" + _active24_get_service_id "$_domain" + _debug _service_id "$_service_id" +} + +_active24_get_service_id() { + _d=$1 + if ! _active24_rest GET "/v1/user/self/zone/${_d}"; then + return 1 + else + response=$(echo "$response" | _json_decode) + _service_id=$(echo "$response" | _egrep_o '"id" *: *[^,]+' | cut -d ':' -f 2) + fi +} + +_active24_rest() { + m=$1 + ep_qs=$2 # with query string + # ep=$2 + ep=$(printf "%s" "$ep_qs" | cut -d '?' -f1) # no query string + data="$3" + + _debug "A24 $ep" + _debug "A24 $Active24_ApiKey" + _debug "A24 $Active24_ApiSecret" + + timestamp=$(_time) + datez=$(date -u +"%Y%m%dT%H%M%SZ") + canonicalRequest="${m} ${ep} ${timestamp}" + signature=$(printf "%s" "$canonicalRequest" | _hmac sha1 "$(printf "%s" "$Active24_ApiSecret" | _hex_dump | tr -d " ")" hex) + authorization64="$(printf "%s:%s" "$Active24_ApiKey" "$signature" | _base64)" + + export _H1="Date: ${datez}" + export _H2="Accept: application/json" + export _H3="Content-Type: application/json" + export _H4="Authorization: Basic ${authorization64}" + + _debug2 H1 "$_H1" + _debug2 H2 "$_H2" + _debug2 H3 "$_H3" + _debug2 H4 "$_H4" + + # _sleep 1 + + if [ "$m" != "GET" ]; then + _debug2 "${m} $Active24_Api${ep_qs}" + _debug "data" "$data" + response="$(_post "$data" "$Active24_Api${ep_qs}" "" "$m" "application/json")" + else + if [ -z "$data" ]; then + _debug2 "GET $Active24_Api${ep_qs}" + response="$(_get "$Active24_Api${ep_qs}")" + else + _debug2 "GET $Active24_Api${ep_qs} with data: ${data}" + response="$(_post "$data" "$Active24_Api${ep_qs}" "" "$m" "application/json")" + fi + fi + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 } diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 53a82f91..90196c69 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -97,12 +97,13 @@ _ali_rest() { } _ali_nonce() { - #_head_n 1 /dev/null && return 0 + fi + printf "%s" "$(date +%s)$$$(date +%N)" | _digest sha256 hex | cut -c 1-32 } -_timestamp() { +_ali_timestamp() { date -u +"%Y-%m-%dT%H%%3A%M%%3A%SZ" } @@ -150,7 +151,7 @@ _check_exist_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&TypeKeyWord=TXT' query=$query'&Version=2015-01-09' } @@ -166,7 +167,7 @@ _add_record_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&Type=TXT' query=$query'&Value='$3 query=$query'&Version=2015-01-09' @@ -182,7 +183,7 @@ _delete_record_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&Version=2015-01-09' } @@ -196,7 +197,7 @@ _describe_records_query() { query=$query'&SignatureMethod=HMAC-SHA1' query=$query"&SignatureNonce=$(_ali_nonce)" query=$query'&SignatureVersion=1.0' - query=$query'&Timestamp='$(_timestamp) + query=$query'&Timestamp='$(_ali_timestamp) query=$query'&Version=2015-01-09' } diff --git a/dnsapi/dns_aws.sh b/dnsapi/dns_aws.sh index c88c9d9c..b76d69c2 100755 --- a/dnsapi/dns_aws.sh +++ b/dnsapi/dns_aws.sh @@ -161,7 +161,7 @@ _get_root() { h=$(printf "%s" "$domain" | cut -d . -f "$i"-100 | sed 's/\./\\./g') _debug "Checking domain: $h" if [ -z "$h" ]; then - _error "invalid domain" + _err "invalid domain" return 1 fi diff --git a/dnsapi/dns_azure.sh b/dnsapi/dns_azure.sh index 3f0dfa3d..f9d84706 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 Optional Bearer Token. Used instead of service principal credentials or managed identity + AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional. ' wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS @@ -340,8 +340,17 @@ _azure_getaccess_token() { if [ "$managedIdentity" = true ]; then # https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http - export _H1="Metadata: true" - response="$(_get http://169.254.169.254/metadata/identity/oauth2/token\?api-version=2018-02-01\&resource=https://management.azure.com/)" + if [ -n "$IDENTITY_ENDPOINT" ]; then + # Some Azure environments may set IDENTITY_ENDPOINT (formerly MSI_ENDPOINT) to have an alternative metadata endpoint + url="$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=https://management.azure.com/" + headers="X-IDENTITY-HEADER: $IDENTITY_HEADER" + else + url="http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/" + headers="Metadata: true" + fi + + export _H1="$headers" + response="$(_get "$url")" response="$(echo "$response" | _normalizeJson)" accesstoken=$(echo "$response" | _egrep_o "\"access_token\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") expires_on=$(echo "$response" | _egrep_o "\"expires_on\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") diff --git a/dnsapi/dns_beget.sh b/dnsapi/dns_beget.sh new file mode 100755 index 00000000..5f3b1eb1 --- /dev/null +++ b/dnsapi/dns_beget.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_beget_info='Beget.com +Site: Beget.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_beget +Options: + BEGET_User API user + BEGET_Password API password +Issues: github.com/acmesh-official/acme.sh/issues/6200 +Author: ARNik +' + +Beget_Api="https://api.beget.com/api" + +#################### Public functions #################### + +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to add txt record +dns_beget_add() { + fulldomain=$1 + txtvalue=$2 + _debug "dns_beget_add() $fulldomain $txtvalue" + fulldomain=$(echo "$fulldomain" | _lower_case) + + Beget_Username="${Beget_Username:-$(_readaccountconf_mutable Beget_Username)}" + Beget_Password="${Beget_Password:-$(_readaccountconf_mutable Beget_Password)}" + + if [ -z "$Beget_Username" ] || [ -z "$Beget_Password" ]; then + Beget_Username="" + Beget_Password="" + _err "You must export variables: Beget_Username, and Beget_Password" + return 1 + fi + + #save the credentials to the account conf file. + _saveaccountconf_mutable Beget_Username "$Beget_Username" + _saveaccountconf_mutable Beget_Password "$Beget_Password" + + _info "Prepare subdomain." + if ! _prepare_subdomain "$fulldomain"; then + _err "Can't prepare subdomain." + return 1 + fi + + _info "Get domain records" + data="{\"fqdn\":\"$fulldomain\"}" + res=$(_api_call "$Beget_Api/dns/getData" "$data") + if ! _is_api_reply_ok "$res"; then + _err "Can't get domain records." + return 1 + fi + + _info "Add new TXT record" + data="{\"fqdn\":\"$fulldomain\",\"records\":{" + data=${data}$(_parce_records "$res" "A") + data=${data}$(_parce_records "$res" "AAAA") + data=${data}$(_parce_records "$res" "CAA") + data=${data}$(_parce_records "$res" "MX") + data=${data}$(_parce_records "$res" "SRV") + data=${data}$(_parce_records "$res" "TXT") + data=$(echo "$data" | sed 's/,$//') + data=${data}'}}' + + str=$(_txt_to_dns_json "$txtvalue") + data=$(_add_record "$data" "TXT" "$str") + + res=$(_api_call "$Beget_Api/dns/changeRecords" "$data") + if ! _is_api_reply_ok "$res"; then + _err "Can't change domain records." + return 1 + fi + + return 0 +} + +# Usage: fulldomain txtvalue +# Used to remove the txt record after validation +dns_beget_rm() { + fulldomain=$1 + txtvalue=$2 + _debug "dns_beget_rm() $fulldomain $txtvalue" + fulldomain=$(echo "$fulldomain" | _lower_case) + + Beget_Username="${Beget_Username:-$(_readaccountconf_mutable Beget_Username)}" + Beget_Password="${Beget_Password:-$(_readaccountconf_mutable Beget_Password)}" + + _info "Get current domain records" + data="{\"fqdn\":\"$fulldomain\"}" + res=$(_api_call "$Beget_Api/dns/getData" "$data") + if ! _is_api_reply_ok "$res"; then + _err "Can't get domain records." + return 1 + fi + + _info "Remove TXT record" + data="{\"fqdn\":\"$fulldomain\",\"records\":{" + data=${data}$(_parce_records "$res" "A") + data=${data}$(_parce_records "$res" "AAAA") + data=${data}$(_parce_records "$res" "CAA") + data=${data}$(_parce_records "$res" "MX") + data=${data}$(_parce_records "$res" "SRV") + data=${data}$(_parce_records "$res" "TXT") + data=$(echo "$data" | sed 's/,$//') + data=${data}'}}' + + str=$(_txt_to_dns_json "$txtvalue") + data=$(_rm_record "$data" "$str") + + res=$(_api_call "$Beget_Api/dns/changeRecords" "$data") + if ! _is_api_reply_ok "$res"; then + _err "Can't change domain records." + return 1 + fi + + return 0 +} + +#################### Private functions below #################### + +# Create subdomain if needed +# Usage: _prepare_subdomain [fulldomain] +_prepare_subdomain() { + fulldomain=$1 + + _info "Detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + if [ -z "$_sub_domain" ]; then + _debug "$fulldomain is a root domain." + return 0 + fi + + _info "Get subdomain list" + res=$(_api_call "$Beget_Api/domain/getSubdomainList") + if ! _is_api_reply_ok "$res"; then + _err "Can't get subdomain list." + return 1 + fi + + if _contains "$res" "\"fqdn\":\"$fulldomain\""; then + _debug "Subdomain $fulldomain already exist." + return 0 + fi + + _info "Subdomain $fulldomain does not exist. Let's create one." + data="{\"subdomain\":\"$_sub_domain\",\"domain_id\":$_domain_id}" + res=$(_api_call "$Beget_Api/domain/addSubdomainVirtual" "$data") + if ! _is_api_reply_ok "$res"; then + _err "Can't create subdomain." + return 1 + fi + + _debug "Cleanup subdomen records" + data="{\"fqdn\":\"$fulldomain\",\"records\":{}}" + res=$(_api_call "$Beget_Api/dns/changeRecords" "$data") + if ! _is_api_reply_ok "$res"; then + _debug "Can't cleanup $fulldomain records." + fi + + data="{\"fqdn\":\"www.$fulldomain\",\"records\":{}}" + res=$(_api_call "$Beget_Api/dns/changeRecords" "$data") + if ! _is_api_reply_ok "$res"; then + _debug "Can't cleanup www.$fulldomain records." + fi + + return 0 +} + +# Usage: _get_root _acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +# _domain_id=32436365 +_get_root() { + fulldomain=$1 + i=1 + p=1 + + _debug "Get domain list" + res=$(_api_call "$Beget_Api/domain/getList") + if ! _is_api_reply_ok "$res"; then + _err "Can't get domain list." + return 1 + fi + + while true; do + h=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100) + _debug h "$h" + + if [ -z "$h" ]; then + return 1 + fi + + if _contains "$res" "$h"; then + _domain_id=$(echo "$res" | _egrep_o "\"id\":[0-9]*,\"fqdn\":\"$h\"" | cut -d , -f1 | cut -d : -f2) + if [ "$_domain_id" ]; then + if [ "$h" != "$fulldomain" ]; then + _sub_domain=$(echo "$fulldomain" | cut -d . -f 1-"$p") + else + _sub_domain="" + fi + _domain=$h + return 0 + fi + return 1 + fi + p="$i" + i=$(_math "$i" + 1) + done + return 1 +} + +# Parce DNS records from json string +# Usage: _parce_records [j_str] [record_name] +_parce_records() { + j_str=$1 + record_name=$2 + res="\"$record_name\":[" + res=${res}$(echo "$j_str" | _egrep_o "\"$record_name\":\[.*" | cut -d '[' -f2 | cut -d ']' -f1) + res=${res}"]," + echo "$res" +} + +# Usage: _add_record [data] [record_name] [record_data] +_add_record() { + data=$1 + record_name=$2 + record_data=$3 + echo "$data" | sed "s/\"$record_name\":\[/\"$record_name\":\[$record_data,/" | sed "s/,\]/\]/" +} + +# Usage: _rm_record [data] [record_data] +_rm_record() { + data=$1 + record_data=$2 + echo "$data" | sed "s/$record_data//g" | sed "s/,\+/,/g" | + sed "s/{,/{/g" | sed "s/,}/}/g" | + sed "s/\[,/\[/g" | sed "s/,\]/\]/g" +} + +_txt_to_dns_json() { + echo "{\"ttl\":600,\"txtdata\":\"$1\"}" +} + +# Usage: _api_call [api_url] [input_data] +_api_call() { + api_url="$1" + input_data="$2" + + _debug "_api_call $api_url" + _debug "Request: $input_data" + + # res=$(curl -s -L -D ./http.header \ + # "$api_url" \ + # --data-urlencode login=$Beget_Username \ + # --data-urlencode passwd=$Beget_Password \ + # --data-urlencode input_format=json \ + # --data-urlencode output_format=json \ + # --data-urlencode "input_data=$input_data") + + url="$api_url?login=$Beget_Username&passwd=$Beget_Password&input_format=json&output_format=json" + if [ -n "$input_data" ]; then + url=${url}"&input_data=" + url=${url}$(echo "$input_data" | _url_encode) + fi + res=$(_get "$url") + + _debug "Reply: $res" + echo "$res" +} + +# Usage: _is_api_reply_ok [api_reply] +_is_api_reply_ok() { + _contains "$1" '^{"status":"success","answer":{"status":"success","result":.*}}$' +} diff --git a/dnsapi/dns_bookmyname.sh b/dnsapi/dns_bookmyname.sh index 668cf074..cf3f1e3e 100644 --- a/dnsapi/dns_bookmyname.sh +++ b/dnsapi/dns_bookmyname.sh @@ -7,7 +7,7 @@ Options: BOOKMYNAME_USERNAME Username BOOKMYNAME_PASSWORD Password Issues: github.com/acmesh-official/acme.sh/issues/3209 -Author: Neilpang +Author: @Neilpang ' ######## Public functions ##################### diff --git a/dnsapi/dns_cf.sh b/dnsapi/dns_cf.sh index 736742f3..7b383c43 100755 --- a/dnsapi/dns_cf.sh +++ b/dnsapi/dns_cf.sh @@ -92,7 +92,9 @@ dns_cf_add() { if _contains "$response" "$txtvalue"; then _info "Added, OK" return 0 - elif _contains "$response" "The record already exists"; then + elif _contains "$response" "The record already exists" || + _contains "$response" "An identical record already exists." || + _contains "$response" '"code":81058'; then _info "Already exists, OK" return 0 else diff --git a/dnsapi/dns_cloudns.sh b/dnsapi/dns_cloudns.sh index 8bb0e00d..23a219da 100755 --- a/dnsapi/dns_cloudns.sh +++ b/dnsapi/dns_cloudns.sh @@ -197,10 +197,11 @@ _dns_cloudns_http_api_call() { auth_user="auth-id=$CLOUDNS_AUTH_ID" fi + encoded_password=$(echo "$CLOUDNS_AUTH_PASSWORD" | tr -d "\n\r" | _url_encode) if [ -z "$2" ]; then - data="$auth_user&auth-password=$CLOUDNS_AUTH_PASSWORD" + data="$auth_user&auth-password=$encoded_password" else - data="$auth_user&auth-password=$CLOUDNS_AUTH_PASSWORD&$2" + data="$auth_user&auth-password=$encoded_password&$2" fi response="$(_get "$CLOUDNS_API/$method?$data")" diff --git a/dnsapi/dns_constellix.sh b/dnsapi/dns_constellix.sh index 6a50e199..7251f8b2 100644 --- a/dnsapi/dns_constellix.sh +++ b/dnsapi/dns_constellix.sh @@ -117,7 +117,7 @@ dns_constellix_rm() { #################### Private functions below ################################## _get_root() { - domain=$1 + domain=$(echo "$1" | _lower_case) i=2 p=1 _debug "Detecting root zone" @@ -156,6 +156,9 @@ _constellix_rest() { data="$3" _debug "$ep" + # Prevent rate limit + _sleep 2 + rdate=$(date +"%s")"000" hmac=$(printf "%s" "$rdate" | _hmac sha1 "$(printf "%s" "$CONSTELLIX_Secret" | _hex_dump | tr -d ' ')" | _base64) diff --git a/dnsapi/dns_curanet.sh b/dnsapi/dns_curanet.sh index f57afa1f..0ef03fea 100644 --- a/dnsapi/dns_curanet.sh +++ b/dnsapi/dns_curanet.sh @@ -15,7 +15,7 @@ CURANET_REST_URL="https://api.curanet.dk/dns/v1/Domains" CURANET_AUTH_URL="https://apiauth.dk.team.blue/auth/realms/Curanet/protocol/openid-connect/token" CURANET_ACCESS_TOKEN="" -######## Public functions ##################### +######## Public functions #################### #Usage: dns_curanet_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_curanet_add() { @@ -154,7 +154,7 @@ _get_root() { export _H3="Authorization: Bearer $CURANET_ACCESS_TOKEN" response="$(_get "$CURANET_REST_URL/$h/Records" "" "")" - if [ ! "$(echo "$response" | _egrep_o "Entity not found")" ]; then + if [ ! "$(echo "$response" | _egrep_o "Entity not found|Bad Request")" ]; then _domain=$h return 0 fi diff --git a/dnsapi/dns_cyon.sh b/dnsapi/dns_cyon.sh index 04a515aa..0c74be2a 100644 --- a/dnsapi/dns_cyon.sh +++ b/dnsapi/dns_cyon.sh @@ -101,6 +101,8 @@ _cyon_load_parameters() { # This header is required for curl calls. _H1="X-Requested-With: XMLHttpRequest" export _H1 + _H3="User-Agent: cyon-dns-acmesh/1.0" + export _H3 } _cyon_print_header() { @@ -125,7 +127,11 @@ _cyon_print_header() { } _cyon_get_cookie_header() { - printf "Cookie: %s" "$(grep "cyon=" "$HTTP_HEADER" | grep "^Set-Cookie:" | _tail_n 1 | _egrep_o 'cyon=[^;]*;' | tr -d ';')" + # Extract all cookies from the response headers (case-insensitive) + _cookies="$(grep -i "^set-cookie:" "$HTTP_HEADER" | sed 's/^[Ss]et-[Cc]ookie: //' | sed 's/;.*//' | tr '\n' '; ' | sed 's/; $//')" + if [ -n "$_cookies" ]; then + printf "Cookie: %s" "$_cookies" + fi } _cyon_login() { @@ -155,7 +161,12 @@ _cyon_login() { _get "https://my.cyon.ch/" >/dev/null - # todo: instead of just checking if the env variable is defined, check if we actually need to do a 2FA auth request. + # Update cookie after loading main page (only if new cookies are set) + _new_cookies="$(_cyon_get_cookie_header)" + if [ -n "$_new_cookies" ]; then + _H2="$_new_cookies" + export _H2 + fi # 2FA authentication with OTP? if [ -n "${CY_OTP_Secret}" ]; then @@ -184,6 +195,13 @@ _cyon_login() { fi _info " success" + + # Update cookie after 2FA (only if new cookies are set) + _new_cookies="$(_cyon_get_cookie_header)" + if [ -n "$_new_cookies" ]; then + _H2="$_new_cookies" + export _H2 + fi fi _info "" @@ -205,7 +223,17 @@ _cyon_change_domain_env() { domain_env="$(printf "%s" "${fulldomain}" | sed -E -e 's/.*\.(.*\..*)$/\1/')" _debug "Changing domain environment to ${domain_env}" - gloo_item_key="$(_get "https://my.cyon.ch/domain/" | tr '\n' ' ' | sed -E -e "s/.*data-domain=\"${domain_env}\"[^<]*data-itemkey=\"([^\"]*).*/\1/")" + domain_page_response="$(_get "https://my.cyon.ch/domain/")" + _debug domain_page_response "${domain_page_response}" + + # Check if we got an error response (JSON) instead of HTML + if printf "%s" "${domain_page_response}" | grep -q '"iserror":true'; then + _err " $(printf "%s" "${domain_page_response}" | _cyon_get_response_message)" + _err "" + return 1 + fi + + gloo_item_key="$(printf "%s" "${domain_page_response}" | tr '\n' ' ' | sed -E -e "s/.*data-domain=\"${domain_env}\"[^<]*data-itemkey=\"([^\"]*).*/\1/")" _debug gloo_item_key "${gloo_item_key}" domain_env_url="https://my.cyon.ch/user/environment/setdomain/d/${domain_env}/gik/${gloo_item_key}" @@ -215,10 +243,8 @@ _cyon_change_domain_env() { if ! _cyon_check_if_2fa_missed "${domain_env_response}"; then return 1; fi - domain_env_success="$(printf "%s" "${domain_env_response}" | _egrep_o '"authenticated":\w*' | cut -d : -f 2)" - # Bail if domain environment change fails. - if [ "${domain_env_success}" != "true" ]; then + if [ "$(printf "%s" "${domain_env_response}" | _cyon_get_environment_change_status)" != "true" ]; then _err " $(printf "%s" "${domain_env_response}" | _cyon_get_response_message)" _err "" return 1 @@ -232,7 +258,7 @@ _cyon_add_txt() { _info " - Adding DNS TXT entry..." add_txt_url="https://my.cyon.ch/domain/dnseditor/add-record-async" - add_txt_data="zone=${fulldomain_idn}.&ttl=900&type=TXT&value=${txtvalue}" + add_txt_data="name=${fulldomain_idn}.&ttl=900&type=TXT&dnscontent=${txtvalue}" add_txt_response="$(_post "$add_txt_data" "$add_txt_url")" _debug add_txt_response "${add_txt_response}" @@ -241,9 +267,10 @@ _cyon_add_txt() { add_txt_message="$(printf "%s" "${add_txt_response}" | _cyon_get_response_message)" add_txt_status="$(printf "%s" "${add_txt_response}" | _cyon_get_response_status)" + add_txt_validation="$(printf "%s" "${add_txt_response}" | _cyon_get_validation_status)" # Bail if adding TXT entry fails. - if [ "${add_txt_status}" != "true" ]; then + if [ "${add_txt_status}" != "true" ] || [ "${add_txt_validation}" != "true" ]; then _err " ${add_txt_message}" _err "" return 1 @@ -305,13 +332,21 @@ _cyon_get_response_message() { } _cyon_get_response_status() { - _egrep_o '"status":\w*' | cut -d : -f 2 + _egrep_o '"status":[a-zA-z0-9]*' | cut -d : -f 2 +} + +_cyon_get_validation_status() { + _egrep_o '"valid":[a-zA-z0-9]*' | cut -d : -f 2 } _cyon_get_response_success() { _egrep_o '"onSuccess":"[^"]*"' | cut -d : -f 2 | tr -d '"' } +_cyon_get_environment_change_status() { + _egrep_o '"authenticated":[a-zA-z0-9]*' | cut -d : -f 2 +} + _cyon_check_if_2fa_missed() { # Did we miss the 2FA? if test "${1#*multi_factor_form}" != "${1}"; then diff --git a/dnsapi/dns_ddnss.sh b/dnsapi/dns_ddnss.sh index 118b148b..0ac353d4 100644 --- a/dnsapi/dns_ddnss.sh +++ b/dnsapi/dns_ddnss.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_ddnss Options: DDNSS_Token API Token Issues: github.com/acmesh-official/acme.sh/issues/2230 -Author: RaidenII, helbgd, mod242 +Author: @helbgd, @mod242 ' DDNSS_DNS_API="https://ddnss.de/upd.php" diff --git a/dnsapi/dns_dnshome.sh b/dnsapi/dns_dnshome.sh index 59828796..6d583246 100755 --- a/dnsapi/dns_dnshome.sh +++ b/dnsapi/dns_dnshome.sh @@ -7,7 +7,7 @@ Options: DNSHOME_Subdomain Subdomain DNSHOME_SubdomainPassword Subdomain Password Issues: github.com/acmesh-official/acme.sh/issues/3819 -Author: dnsHome.de https://github.com/dnsHome-de +Author: @dnsHome-de ' # Usage: add subdomain.ddnsdomain.tld "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" diff --git a/dnsapi/dns_duckdns.sh b/dnsapi/dns_duckdns.sh index 71594873..33d401b0 100755 --- a/dnsapi/dns_duckdns.sh +++ b/dnsapi/dns_duckdns.sh @@ -5,7 +5,7 @@ Site: www.DuckDNS.org Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_duckdns Options: DuckDNS_Token API Token -Author: RaidenII +Author: @RaidenII ' DuckDNS_API="https://www.duckdns.org/update" diff --git a/dnsapi/dns_dyn.sh b/dnsapi/dns_dyn.sh index 94201923..9b1a97a2 100644 --- a/dnsapi/dns_dyn.sh +++ b/dnsapi/dns_dyn.sh @@ -8,7 +8,7 @@ Options: DYN_Customer Customer DYN_Username API Username DYN_Password Secret -Author: Gerd Naschenweng +Author: Gerd Naschenweng <@magicdude4eva> ' # Dyn Managed DNS API diff --git a/dnsapi/dns_dynv6.sh b/dnsapi/dns_dynv6.sh index 76af17f5..3e7ce8d6 100644 --- a/dnsapi/dns_dynv6.sh +++ b/dnsapi/dns_dynv6.sh @@ -8,7 +8,7 @@ Options: OptionsAlt: KEY Path to SSH private key file. E.g. "/root/.ssh/dynv6" Issues: github.com/acmesh-official/acme.sh/issues/2702 -Author: StefanAbl +Author: @StefanAbl ' dynv6_api="https://dynv6.com/api/v2" @@ -107,7 +107,7 @@ _get_domain() { return 0 fi done - _err "Either their is no such host on your dnyv6 account or it cannot be accessed with this key" + _err "Either there is no such host on your dynv6 account, or it cannot be accessed with this key" return 1 } @@ -179,8 +179,8 @@ _dns_dynv6_rm_http() { fi } +#Usage: _get_zone_id $record #get the zoneid for a specifc record or zone -#usage: _get_zone_id §record #where $record is the record to get the id for #returns _zone_id the id of the zone _get_zone_id() { @@ -189,7 +189,6 @@ _get_zone_id() { _dynv6_rest GET zones zones="$(echo "$response" | tr '}' '\n' | tr ',' '\n' | grep name | sed 's/\[//g' | tr -d '{' | tr -d '"')" - #echo $zones selected="" for z in $zones; do @@ -217,9 +216,9 @@ _get_zone_name() { _zone_name="${_zone_name#name:}" } -#usaage _get_record_id $zone_id $record -# where zone_id is thevalue returned by _get_zone_id -# and record ist in the form _acme.www for an fqdn of _acme.www.example.com +#usage _get_record_id $zone_id $record +# where zone_id is the value returned by _get_zone_id +# and record is in the form _acme.www for an fqdn of _acme.www.example.com # returns _record_id _get_record_id() { _zone_id="$1" @@ -234,8 +233,7 @@ _get_record_id() { _get_record_id_from_response() { response="$1" - _record_id="$(echo "$response" | tr '}' '\n' | grep "\"name\":\"$record\"" | grep "\"data\":\"$value\"" | tr ',' '\n' | grep id | tr -d '"' | tr -d 'id:')" - #_record_id="${_record_id#id:}" + _record_id="$(echo "$response" | tr '}' '\n' | grep "\"name\":\"$record\"" | grep "\"data\":\"$value\"" | tr ',' '\n' | grep '"id":' | tr -d '"' | tr -d 'id:' | tr -d '{')" if [ -z "$_record_id" ]; then _err "no such record: $record found in zone $_zone_id" return 1 diff --git a/dnsapi/dns_easydns.sh b/dnsapi/dns_easydns.sh index 1c96ac8f..423def2b 100644 --- a/dnsapi/dns_easydns.sh +++ b/dnsapi/dns_easydns.sh @@ -7,7 +7,7 @@ Options: EASYDNS_Token API Token EASYDNS_Key API Key Issues: github.com/acmesh-official/acme.sh/issues/2647 -Author: Neilpang, wurzelpanzer +Author: @Neilpang, wurzelpanzer ' # API Documentation: https://sandbox.rest.easydns.net:3001/ diff --git a/dnsapi/dns_edgecenter.sh b/dnsapi/dns_edgecenter.sh new file mode 100644 index 00000000..8f4ad171 --- /dev/null +++ b/dnsapi/dns_edgecenter.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_edgecenter_info='EdgeCenter.ru +Site: EdgeCenter.ru +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_edgecenter +Options: + EDGECENTER_API_KEY API Key +Issues: github.com/acmesh-official/acme.sh/issues/6313 +Author: Konstantin Ruchev +' + +EDGECENTER_API="https://api.edgecenter.ru" +DOMAIN_TYPE= +DOMAIN_MASTER= + +######## Public functions ##################### + +#Usage: dns_edgecenter_add _acme-challenge.www.domain.com "TXT_RECORD_VALUE" +dns_edgecenter_add() { + fulldomain="$1" + txtvalue="$2" + + _info "Using EdgeCenter DNS API" + + if ! _dns_edgecenter_init_check; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + return 1 + fi + + subdomain="${fulldomain%."$_zone"}" + subdomain=${subdomain%.} + + _debug "Zone: $_zone" + _debug "Subdomain: $subdomain" + _debug "TXT value: $txtvalue" + + payload='{"resource_records": [ { "content": ["'"$txtvalue"'"] } ], "ttl": 60 }' + _dns_edgecenter_http_api_call "post" "dns/v2/zones/$_zone/$subdomain.$_zone/txt" "$payload" + + if _contains "$response" '"error":"rrset is already exists"'; then + _debug "RRSet exists, merging values" + _dns_edgecenter_http_api_call "get" "dns/v2/zones/$_zone/$subdomain.$_zone/txt" + current="$response" + newlist="" + for v in $(echo "$current" | sed -n 's/.*"content":\["\([^"]*\)"\].*/\1/p'); do + newlist="$newlist {\"content\":[\"$v\"]}," + done + newlist="$newlist{\"content\":[\"$txtvalue\"]}" + putdata="{\"resource_records\":[${newlist}]} +" + _dns_edgecenter_http_api_call "put" "dns/v2/zones/$_zone/$subdomain.$_zone/txt" "$putdata" + _info "Updated existing RRSet with new TXT value." + return 0 + fi + + if _contains "$response" '"exception":'; then + _err "Record cannot be added." + return 1 + fi + + _info "TXT record added successfully." + return 0 +} + +#Usage: dns_edgecenter_rm _acme-challenge.www.domain.com "TXT_RECORD_VALUE" +dns_edgecenter_rm() { + fulldomain="$1" + txtvalue="$2" + + _info "Removing TXT record for $fulldomain" + + if ! _dns_edgecenter_init_check; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + return 1 + fi + + subdomain="${fulldomain%."$_zone"}" + subdomain=${subdomain%.} + + _dns_edgecenter_http_api_call "delete" "dns/v2/zones/$_zone/$subdomain.$_zone/txt" + + if [ -z "$response" ]; then + _info "TXT record deleted successfully." + else + _info "TXT record may not have been deleted: $response" + fi + return 0 +} + +#################### Private functions below ################################## + +_dns_edgecenter_init_check() { + EDGECENTER_API_KEY="${EDGECENTER_API_KEY:-$(_readaccountconf_mutable EDGECENTER_API_KEY)}" + if [ -z "$EDGECENTER_API_KEY" ]; then + _err "EDGECENTER_API_KEY was not exported." + return 1 + fi + + _saveaccountconf_mutable EDGECENTER_API_KEY "$EDGECENTER_API_KEY" + export _H1="Authorization: APIKey $EDGECENTER_API_KEY" + + _dns_edgecenter_http_api_call "get" "dns/v2/clients/me/features" + if ! _contains "$response" '"id":'; then + _err "Invalid API key." + return 1 + fi + return 0 +} + +_get_root() { + domain="$1" + i=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-) + if [ -z "$h" ]; then + return 1 + fi + _dns_edgecenter_http_api_call "get" "dns/v2/zones/$h" + if ! _contains "$response" 'zone is not found'; then + _zone="$h" + return 0 + fi + i=$((i + 1)) + done + return 1 +} + +_dns_edgecenter_http_api_call() { + mtd="$1" + endpoint="$2" + data="$3" + + export _H1="Authorization: APIKey $EDGECENTER_API_KEY" + + case "$mtd" in + get) + response="$(_get "$EDGECENTER_API/$endpoint")" + ;; + post) + response="$(_post "$data" "$EDGECENTER_API/$endpoint")" + ;; + delete) + response="$(_post "" "$EDGECENTER_API/$endpoint" "" "DELETE")" + ;; + put) + response="$(_post "$data" "$EDGECENTER_API/$endpoint" "" "PUT")" + ;; + *) + _err "Unknown HTTP method $mtd" + return 1 + ;; + esac + + _debug "HTTP $mtd response: $response" + return 0 +} diff --git a/dnsapi/dns_efficientip.sh b/dnsapi/dns_efficientip.sh new file mode 100755 index 00000000..a485849a --- /dev/null +++ b/dnsapi/dns_efficientip.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_efficientip_info='efficientip.com +Site: https://efficientip.com/ +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_efficientip +Options: + EfficientIP_Creds HTTP Basic Authentication credentials. E.g. "username:password" + EfficientIP_Server EfficientIP SOLIDserver Management IP address or FQDN. + EfficientIP_DNS_Name Name of the DNS smart or server hosting the zone. Optional. + EfficientIP_View Name of the DNS view hosting the zone. Optional. +OptionsAlt: + EfficientIP_Token_Key Alternative API token key, prefered over basic authentication. + EfficientIP_Token_Secret Alternative API token secret, required when using a token key. + EfficientIP_Server EfficientIP SOLIDserver Management IP address or FQDN. + EfficientIP_DNS_Name Name of the DNS smart or server hosting the zone. Optional. + EfficientIP_View Name of the DNS view hosting the zone. Optional. +Issues: github.com/acmesh-official/acme.sh/issues/6325 +Author: EfficientIP-Labs +' + +dns_efficientip_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using EfficientIP API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if { [ -z "${EfficientIP_Creds}" ] && { [ -z "${EfficientIP_Token_Key}" ] || [ -z "${EfficientIP_Token_Secret}" ]; }; } || [ -z "${EfficientIP_Server}" ]; then + EfficientIP_Creds="" + EfficientIP_Token_Key="" + EfficientIP_Token_Secret="" + EfficientIP_Server="" + _err "You didn't specify any EfficientIP credentials or token or server (EfficientIP_Creds; EfficientIP_Token_Key; EfficientIP_Token_Secret; EfficientIP_Server)." + _err "Please set them via EXPORT EfficientIP_Creds=username:password or EXPORT EfficientIP_server=ip/hostname" + _err "or if you want to use Token instead EXPORT EfficientIP_Token_Key=yourkey" + _err "and EXPORT EfficientIP_Token_Secret=yoursecret" + _err "then try again." + return 1 + fi + + if [ -z "${EfficientIP_DNS_Name}" ]; then + EfficientIP_DNS_Name="" + fi + + EfficientIP_DNSNameEncoded=$(printf "%b" "${EfficientIP_DNS_Name}" | _url_encode) + + if [ -z "${EfficientIP_View}" ]; then + EfficientIP_View="" + fi + + EfficientIP_ViewEncoded=$(printf "%b" "${EfficientIP_View}" | _url_encode) + + _saveaccountconf EfficientIP_Creds "${EfficientIP_Creds}" + _saveaccountconf EfficientIP_Token_Key "${EfficientIP_Token_Key}" + _saveaccountconf EfficientIP_Token_Secret "${EfficientIP_Token_Secret}" + _saveaccountconf EfficientIP_Server "${EfficientIP_Server}" + _saveaccountconf EfficientIP_DNS_Name "${EfficientIP_DNS_Name}" + _saveaccountconf EfficientIP_View "${EfficientIP_View}" + + export _H1="Accept-Language:en-US" + baseurlnObject="https://${EfficientIP_Server}/rest/dns_rr_add?rr_type=TXT&rr_ttl=300&rr_name=${fulldomain}&rr_value1=${txtvalue}" + + if [ "${EfficientIP_DNSNameEncoded}" != "" ]; then + baseurlnObject="${baseurlnObject}&dns_name=${EfficientIP_DNSNameEncoded}" + fi + + if [ "${EfficientIP_ViewEncoded}" != "" ]; then + baseurlnObject="${baseurlnObject}&dnsview_name=${EfficientIP_ViewEncoded}" + fi + + if [ -z "${EfficientIP_Token_Secret}" ] || [ -z "${EfficientIP_Token_Key}" ]; then + EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64) + export _H2="Authorization: Basic ${EfficientIP_CredsEncoded}" + else + TS=$(date +%s) + Sig=$(printf "%b\n$TS\nPOST\n$baseurlnObject" "${EfficientIP_Token_Secret}" | _digest sha3-256 hex) + EfficientIP_CredsEncoded=$(printf "%b:%b" "${EfficientIP_Token_Key}" "$Sig") + export _H2="Authorization: SDS ${EfficientIP_CredsEncoded}" + export _H3="X-SDS-TS: ${TS}" + fi + + result="$(_post "" "${baseurlnObject}" "" "POST")" + + if [ "$(echo "${result}" | _egrep_o "ret_oid")" ]; then + _info "DNS record successfully created" + return 0 + else + _err "Error creating DNS record" + _err "${result}" + return 1 + fi +} + +dns_efficientip_rm() { + fulldomain=$1 + txtvalue=$2 + + _info "Using EfficientIP API" + _debug fulldomain "${fulldomain}" + _debug txtvalue "${txtvalue}" + + EfficientIP_ViewEncoded=$(printf "%b" "${EfficientIP_View}" | _url_encode) + EfficientIP_DNSNameEncoded=$(printf "%b" "${EfficientIP_DNS_Name}" | _url_encode) + EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64) + + export _H1="Accept-Language:en-US" + + baseurlnObject="https://${EfficientIP_Server}/rest/dns_rr_delete?rr_type=TXT&rr_name=$fulldomain&rr_value1=$txtvalue" + if [ "${EfficientIP_DNSNameEncoded}" != "" ]; then + baseurlnObject="${baseurlnObject}&dns_name=${EfficientIP_DNSNameEncoded}" + fi + + if [ "${EfficientIP_ViewEncoded}" != "" ]; then + baseurlnObject="${baseurlnObject}&dnsview_name=${EfficientIP_ViewEncoded}" + fi + + if [ -z "$EfficientIP_Token_Secret" ] || [ -z "$EfficientIP_Token_Key" ]; then + EfficientIP_CredsEncoded=$(printf "%b" "${EfficientIP_Creds}" | _base64) + export _H2="Authorization: Basic $EfficientIP_CredsEncoded" + else + TS=$(date +%s) + Sig=$(printf "%b\n$TS\nDELETE\n${baseurlnObject}" "${EfficientIP_Token_Secret}" | _digest sha3-256 hex) + EfficientIP_CredsEncoded=$(printf "%b:%b" "${EfficientIP_Token_Key}" "$Sig") + export _H2="Authorization: SDS ${EfficientIP_CredsEncoded}" + export _H3="X-SDS-TS: $TS" + fi + + result="$(_post "" "${baseurlnObject}" "" "DELETE")" + + if [ "$(echo "${result}" | _egrep_o "ret_oid")" ]; then + _info "DNS Record successfully deleted" + return 0 + else + _err "Error deleting DNS record" + _err "${result}" + return 1 + fi +} diff --git a/dnsapi/dns_exoscale.sh b/dnsapi/dns_exoscale.sh old mode 100755 new mode 100644 index 6898ce38..ddd526a4 --- a/dnsapi/dns_exoscale.sh +++ b/dnsapi/dns_exoscale.sh @@ -8,9 +8,9 @@ Options: EXOSCALE_SECRET_KEY API Secret key ' -EXOSCALE_API=https://api.exoscale.com/dns/v1 +EXOSCALE_API="https://api-ch-gva-2.exoscale.com/v2" -######## Public functions ##################### +######## Public functions ######## # Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" # Used to add txt record @@ -18,159 +18,197 @@ dns_exoscale_add() { fulldomain=$1 txtvalue=$2 - if ! _checkAuth; then + _debug "Using Exoscale DNS v2 API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _check_auth; then return 1 fi - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "invalid domain" + root_domain_id=$(_get_root_domain_id "$fulldomain") + if [ -z "$root_domain_id" ]; then + _err "Unable to determine root domain ID for $fulldomain" return 1 fi + _debug root_domain_id "$root_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" + # Always get the subdomain part first + sub_domain=$(_get_sub_domain "$fulldomain" "$root_domain_id") + _debug sub_domain "$sub_domain" - _info "Adding record" - if _exoscale_rest POST "domains/$_domain_id/records" "{\"record\":{\"name\":\"$_sub_domain\",\"record_type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}}" "$_domain_token"; then - if _contains "$response" "$txtvalue"; then - _info "Added, OK" - return 0 - fi + # Build the record name properly + if [ -z "$sub_domain" ]; then + record_name="_acme-challenge" + else + record_name="_acme-challenge.$sub_domain" fi - _err "Add txt record error." - return 1 + payload=$(printf '{"name":"%s","type":"TXT","content":"%s","ttl":120}' "$record_name" "$txtvalue") + _debug payload "$payload" + + response=$(_exoscale_rest POST "/dns-domain/${root_domain_id}/record" "$payload") + if _contains "$response" "\"id\""; then + _info "TXT record added successfully." + return 0 + else + _err "Error adding TXT record: $response" + return 1 + fi } -# Usage: fulldomain txtvalue -# Used to remove the txt record after validation dns_exoscale_rm() { fulldomain=$1 - txtvalue=$2 - if ! _checkAuth; then + _debug "Using Exoscale DNS v2 API for removal" + _debug fulldomain "$fulldomain" + + if ! _check_auth; then return 1 fi - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "invalid domain" + root_domain_id=$(_get_root_domain_id "$fulldomain") + if [ -z "$root_domain_id" ]; then + _err "Unable to determine root domain ID for $fulldomain" return 1 fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _debug "Getting txt records" - _exoscale_rest GET "domains/${_domain_id}/records?type=TXT&name=$_sub_domain" "" "$_domain_token" - if _contains "$response" "\"name\":\"$_sub_domain\"" >/dev/null; then - _record_id=$(echo "$response" | tr '{' "\n" | grep "\"content\":\"$txtvalue\"" | _egrep_o "\"id\":[^,]+" | _head_n 1 | cut -d : -f 2 | tr -d \") + record_name="_acme-challenge" + sub_domain=$(_get_sub_domain "$fulldomain" "$root_domain_id") + if [ -n "$sub_domain" ]; then + record_name="_acme-challenge.$sub_domain" fi - if [ -z "$_record_id" ]; then - _err "Can not get record id to remove." + record_id=$(_find_record_id "$root_domain_id" "$record_name") + if [ -z "$record_id" ]; then + _err "TXT record not found for deletion." return 1 fi - _debug "Deleting record $_record_id" - - if ! _exoscale_rest DELETE "domains/$_domain_id/records/$_record_id" "" "$_domain_token"; then - _err "Delete record error." + response=$(_exoscale_rest DELETE "/dns-domain/$root_domain_id/record/$record_id") + if _contains "$response" "\"state\":\"success\""; then + _info "TXT record deleted successfully." + return 0 + else + _err "Error deleting TXT record: $response" return 1 fi - - return 0 } -#################### Private functions below ################################## +######## Private helpers ######## -_checkAuth() { +_check_auth() { EXOSCALE_API_KEY="${EXOSCALE_API_KEY:-$(_readaccountconf_mutable EXOSCALE_API_KEY)}" EXOSCALE_SECRET_KEY="${EXOSCALE_SECRET_KEY:-$(_readaccountconf_mutable EXOSCALE_SECRET_KEY)}" - if [ -z "$EXOSCALE_API_KEY" ] || [ -z "$EXOSCALE_SECRET_KEY" ]; then - EXOSCALE_API_KEY="" - EXOSCALE_SECRET_KEY="" - _err "You don't specify Exoscale application key and application secret yet." - _err "Please create you key and try again." + _err "EXOSCALE_API_KEY and EXOSCALE_SECRET_KEY must be set." return 1 fi - _saveaccountconf_mutable EXOSCALE_API_KEY "$EXOSCALE_API_KEY" _saveaccountconf_mutable EXOSCALE_SECRET_KEY "$EXOSCALE_SECRET_KEY" - return 0 } -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=sdjkglgdfewsdfg -# _domain_token=sdjkglgdfewsdfg -_get_root() { - - if ! _exoscale_rest GET "domains"; then - return 1 - fi - +_get_root_domain_id() { domain=$1 - i=2 - p=1 + i=1 while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - #not valid - return 1 - fi - - if _contains "$response" "\"name\":\"$h\"" >/dev/null; then - _domain_id=$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o "\"id\":[^,]+" | _head_n 1 | cut -d : -f 2 | tr -d \") - _domain_token=$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o "\"token\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") - if [ "$_domain_token" ] && [ "$_domain_id" ]; then - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain=$h - return 0 + candidate=$(printf "%s" "$domain" | cut -d . -f "${i}-100") + [ -z "$candidate" ] && return 1 + _debug "Trying root domain candidate: $candidate" + domains=$(_exoscale_rest GET "/dns-domain") + # Extract from dns-domains array + result=$(echo "$domains" | _egrep_o '"dns-domains":\[.*\]' | _egrep_o '\{"id":"[^"]*","created-at":"[^"]*","unicode-name":"[^"]*"\}' | while read -r item; do + name=$(echo "$item" | _egrep_o '"unicode-name":"[^"]*"' | cut -d'"' -f4) + id=$(echo "$item" | _egrep_o '"id":"[^"]*"' | cut -d'"' -f4) + if [ "$name" = "$candidate" ]; then + echo "$id" + break fi - return 1 + done) + if [ -n "$result" ]; then + echo "$result" + return 0 fi - p=$i i=$(_math "$i" + 1) done - return 1 } -# returns response +_get_sub_domain() { + fulldomain=$1 + root_id=$2 + root_info=$(_exoscale_rest GET "/dns-domain/$root_id") + _debug root_info "$root_info" + root_name=$(echo "$root_info" | _egrep_o "\"unicode-name\":\"[^\"]*\"" | cut -d\" -f4) + sub=${fulldomain%%."$root_name"} + + if [ "$sub" = "_acme-challenge" ]; then + echo "" + else + # Remove _acme-challenge. prefix to get the actual subdomain + echo "${sub#_acme-challenge.}" + fi +} + +_find_record_id() { + root_id=$1 + name=$2 + records=$(_exoscale_rest GET "/dns-domain/$root_id/record") + + # Convert search name to lowercase for case-insensitive matching + name_lower=$(echo "$name" | tr '[:upper:]' '[:lower:]') + + echo "$records" | _egrep_o '\{[^}]*"name":"[^"]*"[^}]*\}' | while read -r record; do + record_name=$(echo "$record" | _egrep_o '"name":"[^"]*"' | cut -d'"' -f4) + record_name_lower=$(echo "$record_name" | tr '[:upper:]' '[:lower:]') + if [ "$record_name_lower" = "$name_lower" ]; then + echo "$record" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d'"' -f4 + break + fi + done +} + +_exoscale_sign() { + k=$1 + shift + hex_key=$(printf %b "$k" | _hex_dump | tr -d ' ') + printf %s "$@" | _hmac sha256 "$hex_key" +} + _exoscale_rest() { method=$1 - path="$2" - data="$3" - token="$4" - request_url="$EXOSCALE_API/$path" - _debug "$path" + path=$2 + data=$3 - export _H1="Accept: application/json" + url="${EXOSCALE_API}${path}" + expiration=$(_math "$(date +%s)" + 300) # 5m from now - if [ "$token" ]; then - export _H2="X-DNS-Domain-Token: $token" - else - export _H2="X-DNS-Token: $EXOSCALE_API_KEY:$EXOSCALE_SECRET_KEY" - fi + # Build the message with the actual body or empty line + message=$(printf "%s %s\n%s\n\n\n%s" "$method" "/v2$path" "$data" "$expiration") + signature=$(_exoscale_sign "$EXOSCALE_SECRET_KEY" "$message" | _base64) + auth="EXO2-HMAC-SHA256 credential=${EXOSCALE_API_KEY},expires=${expiration},signature=${signature}" + + _debug "API request: $method $url" + _debug "Signed message: [$message]" + _debug "Authorization header: [$auth]" + + export _H1="Accept: application/json" + export _H2="Authorization: ${auth}" if [ "$data" ] || [ "$method" = "DELETE" ]; then export _H3="Content-Type: application/json" _debug data "$data" - response="$(_post "$data" "$request_url" "" "$method")" + response="$(_post "$data" "$url" "" "$method")" else - response="$(_get "$request_url" "" "" "$method")" + response="$(_get "$url" "" "" "$method")" fi - if [ "$?" != "0" ]; then - _err "error $request_url" + # shellcheck disable=SC2181 + if [ "$?" -ne 0 ]; then + _err "error $url" return 1 fi _debug2 response "$response" + echo "$response" return 0 } diff --git a/dnsapi/dns_fornex.sh b/dnsapi/dns_fornex.sh index 91e5491b..dcaa2297 100644 --- a/dnsapi/dns_fornex.sh +++ b/dnsapi/dns_fornex.sh @@ -95,7 +95,7 @@ _get_root() { return 1 fi - if ! _rest GET "dns/domain/"; then + if ! _rest GET "dns/domain/?q=$h"; then return 1 fi diff --git a/dnsapi/dns_freedns.sh b/dnsapi/dns_freedns.sh index 114f30e0..13d9f68b 100755 --- a/dnsapi/dns_freedns.sh +++ b/dnsapi/dns_freedns.sh @@ -7,7 +7,7 @@ Options: FREEDNS_User Username FREEDNS_Password Password Issues: github.com/acmesh-official/acme.sh/issues/2305 -Author: David Kerr +Author: David Kerr <@dkerr64> ' ######## Public functions ##################### diff --git a/dnsapi/dns_freemyip.sh b/dnsapi/dns_freemyip.sh new file mode 100644 index 00000000..d598a657 --- /dev/null +++ b/dnsapi/dns_freemyip.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_freemyip_info='FreeMyIP.com +Site: FreeMyIP.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_freemyip +Options: + FREEMYIP_Token API Token +Issues: github.com/acmesh-official/acme.sh/issues/6247 +Author: Recolic Keghart , @Giova96 +' + +FREEMYIP_DNS_API="https://freemyip.com/update?" + +################ Public functions ################ + +#Usage: dns_freemyip_add fulldomain txtvalue +dns_freemyip_add() { + fulldomain="$1" + txtvalue="$2" + + _info "Add TXT record $txtvalue for $fulldomain using freemyip.com api" + + FREEMYIP_Token="${FREEMYIP_Token:-$(_readaccountconf_mutable FREEMYIP_Token)}" + if [ -z "$FREEMYIP_Token" ]; then + FREEMYIP_Token="" + _err "You don't specify FREEMYIP_Token yet." + _err "Please specify your token and try again." + return 1 + fi + + #save the credentials to the account conf file. + _saveaccountconf_mutable FREEMYIP_Token "$FREEMYIP_Token" + + if _is_root_domain_published "$fulldomain"; then + _err "freemyip API don't allow you to set multiple TXT record for the same subdomain!" + _err "You must apply certificate for only one domain at a time!" + _err "====" + _err "For example, aaa.yourdomain.freemyip.com and bbb.yourdomain.freemyip.com and yourdomain.freemyip.com ALWAYS share the same TXT record. They will overwrite each other if you apply multiple domain at the same time." + _debug "If you are testing this workflow in github pipeline or acmetest, please set TEST_DNS_NO_SUBDOMAIN=1 and TEST_DNS_NO_WILDCARD=1" + return 1 + fi + + # txtvalue must be url-encoded. But it's not necessary for acme txt value. + _freemyip_get_until_ok "${FREEMYIP_DNS_API}token=$FREEMYIP_Token&domain=$fulldomain&txt=$txtvalue" 2>&1 + return $? +} + +#Usage: dns_freemyip_rm fulldomain txtvalue +dns_freemyip_rm() { + fulldomain="$1" + txtvalue="$2" + + _info "Delete TXT record $txtvalue for $fulldomain using freemyip.com api" + + FREEMYIP_Token="${FREEMYIP_Token:-$(_readaccountconf_mutable FREEMYIP_Token)}" + if [ -z "$FREEMYIP_Token" ]; then + FREEMYIP_Token="" + _err "You don't specify FREEMYIP_Token yet." + _err "Please specify your token and try again." + return 1 + fi + + #save the credentials to the account conf file. + _saveaccountconf_mutable FREEMYIP_Token "$FREEMYIP_Token" + + # Leave the TXT record as empty or "null" to delete the record. + _freemyip_get_until_ok "${FREEMYIP_DNS_API}token=$FREEMYIP_Token&domain=$fulldomain&txt=" 2>&1 + return $? +} + +################ Private functions below ################ +_get_root() { + _fmi_d="$1" + + echo "$_fmi_d" | rev | cut -d '.' -f 1-3 | rev +} + +# There is random failure while calling freemyip API too fast. This function automatically retry until success. +_freemyip_get_until_ok() { + _fmi_url="$1" + for i in $(seq 1 8); do + _debug "HTTP GET freemyip.com API '$_fmi_url', retry $i/8..." + _get "$_fmi_url" | tee /dev/fd/2 | grep OK && return 0 + _sleep 1 # DO NOT send the request too fast + done + _err "Failed to request freemyip API: $_fmi_url . Server does not say 'OK'" + return 1 +} + +# Verify in public dns if domain is already there. +_is_root_domain_published() { + _fmi_d="$1" + _webroot="$(_get_root "$_fmi_d")" + + _info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet" + for i in $(seq 1 3); do + _debug "'$_webroot' ns lookup, retry $i/3..." + if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then + _debug "'$_webroot' already has a TXT record published!" + return 0 + fi + _sleep 10 # Give it some time to propagate the TXT record + done + return 1 +} diff --git a/dnsapi/dns_gandi_livedns.sh b/dnsapi/dns_gandi_livedns.sh index 0516fee9..aaef07bf 100644 --- a/dnsapi/dns_gandi_livedns.sh +++ b/dnsapi/dns_gandi_livedns.sh @@ -23,6 +23,8 @@ dns_gandi_livedns_add() { fulldomain=$1 txtvalue=$2 + GANDI_LIVEDNS_KEY="${GANDI_LIVEDNS_KEY:-$(_readaccountconf_mutable GANDI_LIVEDNS_KEY)}" + GANDI_LIVEDNS_TOKEN="${GANDI_LIVEDNS_TOKEN:-$(_readaccountconf_mutable GANDI_LIVEDNS_TOKEN)}" if [ -z "$GANDI_LIVEDNS_KEY" ] && [ -z "$GANDI_LIVEDNS_TOKEN" ]; then _err "No Token or API key (deprecated) specified for Gandi LiveDNS." _err "Create your token or key and export it as GANDI_LIVEDNS_KEY or GANDI_LIVEDNS_TOKEN respectively" @@ -31,11 +33,11 @@ dns_gandi_livedns_add() { # Keep only one secret in configuration if [ -n "$GANDI_LIVEDNS_TOKEN" ]; then - _saveaccountconf GANDI_LIVEDNS_TOKEN "$GANDI_LIVEDNS_TOKEN" - _clearaccountconf GANDI_LIVEDNS_KEY + _saveaccountconf_mutable GANDI_LIVEDNS_TOKEN "$GANDI_LIVEDNS_TOKEN" + _clearaccountconf_mutable GANDI_LIVEDNS_KEY elif [ -n "$GANDI_LIVEDNS_KEY" ]; then - _saveaccountconf GANDI_LIVEDNS_KEY "$GANDI_LIVEDNS_KEY" - _clearaccountconf GANDI_LIVEDNS_TOKEN + _saveaccountconf_mutable GANDI_LIVEDNS_KEY "$GANDI_LIVEDNS_KEY" + _clearaccountconf_mutable GANDI_LIVEDNS_TOKEN fi _debug "First detect the root zone" diff --git a/dnsapi/dns_he_ddns.sh b/dnsapi/dns_he_ddns.sh new file mode 100644 index 00000000..1fe9a7fd --- /dev/null +++ b/dnsapi/dns_he_ddns.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_he_ddns_info='Hurricane Electric HE.net DDNS +Site: dns.he.net +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_he_ddns +Options: + HE_DDNS_KEY The DDNS key +Issues: https://github.com/acmesh-official/acme.sh/issues/5238 +Author: Markku Leiniö +' + +HE_DDNS_URL="https://dyn.dns.he.net/nic/update" + +######## Public functions ##################### + +#Usage: dns_he_ddns_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_he_ddns_add() { + fulldomain=$1 + txtvalue=$2 + HE_DDNS_KEY="${HE_DDNS_KEY:-$(_readaccountconf_mutable HE_DDNS_KEY)}" + if [ -z "$HE_DDNS_KEY" ]; then + HE_DDNS_KEY="" + _err "You didn't specify a DDNS key for accessing the TXT record in HE API." + return 1 + fi + #Save the DDNS key to the account conf file. + _saveaccountconf_mutable HE_DDNS_KEY "$HE_DDNS_KEY" + + _info "Using Hurricane Electric DDNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + response="$(_post "hostname=$fulldomain&password=$HE_DDNS_KEY&txt=$txtvalue" "$HE_DDNS_URL")" + _info "Response: $response" + _contains "$response" "good" && return 0 || return 1 +} + +# dns_he_ddns_rm() is not doing anything because the API call always updates the +# contents of the existing record (that the API key gives access to). + +dns_he_ddns_rm() { + fulldomain=$1 + _debug "Delete TXT record called for '${fulldomain}', not doing anything." + return 0 +} diff --git a/dnsapi/dns_hetzner.sh b/dnsapi/dns_hetzner.sh old mode 100644 new mode 100755 index 5a9cf2d9..f1bddc61 --- a/dnsapi/dns_hetzner.sh +++ b/dnsapi/dns_hetzner.sh @@ -212,7 +212,7 @@ _get_root() { _response_has_error() { unset _response_error - err_part="$(echo "$response" | _egrep_o '"error":{[^}]*}')" + err_part="$(echo "$response" | _egrep_o '"error":\{[^\}]*\}')" if [ -n "$err_part" ]; then err_code=$(echo "$err_part" | _egrep_o '"code":[0-9]+' | cut -d : -f 2) diff --git a/dnsapi/dns_hetznercloud.sh b/dnsapi/dns_hetznercloud.sh new file mode 100644 index 00000000..4a7eea90 --- /dev/null +++ b/dnsapi/dns_hetznercloud.sh @@ -0,0 +1,593 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_hetznercloud_info='Hetzner Cloud DNS +Site: Hetzner.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hetznercloud +Options: + HETZNER_TOKEN API token for the Hetzner Cloud DNS API +Optional: + HETZNER_TTL Custom TTL for new TXT rrsets (default 120) + HETZNER_API Override API endpoint (default https://api.hetzner.cloud/v1) + HETZNER_MAX_ATTEMPTS Number of 1s polls to wait for async actions (default 120) +Issues: github.com/acmesh-official/acme.sh/issues +' + +HETZNERCLOUD_API_DEFAULT="https://api.hetzner.cloud/v1" +HETZNERCLOUD_TTL_DEFAULT=120 +HETZNER_MAX_ATTEMPTS_DEFAULT=120 + +######## Public functions ##################### + +dns_hetznercloud_add() { + fulldomain="$(_idn "${1}")" + txtvalue="${2}" + + _info "Using Hetzner Cloud DNS API to add record" + + if ! _hetznercloud_init; then + return 1 + fi + + if ! _hetznercloud_prepare_zone "${fulldomain}"; then + _err "Unable to determine Hetzner Cloud zone for ${fulldomain}" + return 1 + fi + + if ! _hetznercloud_get_rrset; then + return 1 + fi + + if [ "${_hetznercloud_last_http_code}" = "200" ]; then + if _hetznercloud_rrset_contains_value "${txtvalue}"; then + _info "TXT record already present; nothing to do." + return 0 + fi + elif [ "${_hetznercloud_last_http_code}" != "404" ]; then + _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}" + return 1 + fi + + add_payload="$(_hetznercloud_build_add_payload "${txtvalue}")" + if [ -z "${add_payload}" ]; then + _err "Failed to build request payload." + return 1 + fi + + if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_add}" "${add_payload}"; then + return 1 + fi + + case "${_hetznercloud_last_http_code}" in + 200 | 201 | 202 | 204) + if ! _hetznercloud_handle_action_response "TXT record add"; then + return 1 + fi + _info "Hetzner Cloud TXT record added." + return 0 + ;; + 401 | 403) + _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API." + _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}" + return 1 + ;; + 409 | 422) + _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the add_records request" "${_hetznercloud_last_http_code}" + return 1 + ;; + *) + _hetznercloud_log_http_error "Hetzner Cloud DNS add_records request failed" "${_hetznercloud_last_http_code}" + return 1 + ;; + esac +} + +dns_hetznercloud_rm() { + fulldomain="$(_idn "${1}")" + txtvalue="${2}" + + _info "Using Hetzner Cloud DNS API to remove record" + + if ! _hetznercloud_init; then + return 1 + fi + + if ! _hetznercloud_prepare_zone "${fulldomain}"; then + _err "Unable to determine Hetzner Cloud zone for ${fulldomain}" + return 1 + fi + + if ! _hetznercloud_get_rrset; then + return 1 + fi + + if [ "${_hetznercloud_last_http_code}" = "404" ]; then + _info "TXT rrset does not exist; nothing to remove." + return 0 + fi + + if [ "${_hetznercloud_last_http_code}" != "200" ]; then + _hetznercloud_log_http_error "Failed to query existing TXT rrset" "${_hetznercloud_last_http_code}" + return 1 + fi + + if _hetznercloud_rrset_contains_value "${txtvalue}"; then + remove_payload="$(_hetznercloud_build_remove_payload "${txtvalue}")" + if [ -z "${remove_payload}" ]; then + _err "Failed to build remove_records payload." + return 1 + fi + if ! _hetznercloud_api POST "${_hetznercloud_rrset_action_remove}" "${remove_payload}"; then + return 1 + fi + case "${_hetznercloud_last_http_code}" in + 200 | 201 | 202 | 204) + if ! _hetznercloud_handle_action_response "TXT record remove"; then + return 1 + fi + _info "Hetzner Cloud TXT record removed." + return 0 + ;; + 401 | 403) + _err "Hetzner Cloud DNS API authentication failed (HTTP ${_hetznercloud_last_http_code}). Check HETZNER_TOKEN for the new API." + _hetznercloud_log_http_error "" "${_hetznercloud_last_http_code}" + return 1 + ;; + 404) + _info "TXT rrset already absent after remove action." + return 0 + ;; + 409 | 422) + _hetznercloud_log_http_error "Hetzner Cloud DNS rejected the remove_records request" "${_hetznercloud_last_http_code}" + return 1 + ;; + *) + _hetznercloud_log_http_error "Hetzner Cloud DNS remove_records request failed" "${_hetznercloud_last_http_code}" + return 1 + ;; + esac + else + _info "TXT value not present; nothing to remove." + return 0 + fi +} + +#################### Private functions ################################## + +_hetznercloud_init() { + HETZNER_TOKEN="${HETZNER_TOKEN:-$(_readaccountconf_mutable HETZNER_TOKEN)}" + if [ -z "${HETZNER_TOKEN}" ]; then + _err "The environment variable HETZNER_TOKEN must be set for the Hetzner Cloud DNS API." + return 1 + fi + HETZNER_TOKEN=$(echo "${HETZNER_TOKEN}" | tr -d '"') + _saveaccountconf_mutable HETZNER_TOKEN "${HETZNER_TOKEN}" + + HETZNER_API="${HETZNER_API:-$(_readaccountconf_mutable HETZNER_API)}" + if [ -z "${HETZNER_API}" ]; then + HETZNER_API="${HETZNERCLOUD_API_DEFAULT}" + fi + _saveaccountconf_mutable HETZNER_API "${HETZNER_API}" + + HETZNER_TTL="${HETZNER_TTL:-$(_readaccountconf_mutable HETZNER_TTL)}" + if [ -z "${HETZNER_TTL}" ]; then + HETZNER_TTL="${HETZNERCLOUD_TTL_DEFAULT}" + fi + ttl_check=$(printf "%s" "${HETZNER_TTL}" | tr -d '0-9') + if [ -n "${ttl_check}" ]; then + _err "HETZNER_TTL must be an integer value." + return 1 + fi + _saveaccountconf_mutable HETZNER_TTL "${HETZNER_TTL}" + + HETZNER_MAX_ATTEMPTS="${HETZNER_MAX_ATTEMPTS:-$(_readaccountconf_mutable HETZNER_MAX_ATTEMPTS)}" + if [ -z "${HETZNER_MAX_ATTEMPTS}" ]; then + HETZNER_MAX_ATTEMPTS="${HETZNER_MAX_ATTEMPTS_DEFAULT}" + fi + attempts_check=$(printf "%s" "${HETZNER_MAX_ATTEMPTS}" | tr -d '0-9') + if [ -n "${attempts_check}" ]; then + _err "HETZNER_MAX_ATTEMPTS must be an integer value." + return 1 + fi + _saveaccountconf_mutable HETZNER_MAX_ATTEMPTS "${HETZNER_MAX_ATTEMPTS}" + + return 0 +} + +_hetznercloud_prepare_zone() { + _hetznercloud_zone_id="" + _hetznercloud_zone_name="" + _hetznercloud_zone_name_lc="" + _hetznercloud_rr_name="" + _hetznercloud_rrset_path="" + _hetznercloud_rrset_action_add="" + _hetznercloud_rrset_action_remove="" + fulldomain_lc=$(printf "%s" "${1}" | sed 's/\.$//' | _lower_case) + + i=2 + p=1 + while true; do + candidate=$(printf "%s" "${fulldomain_lc}" | cut -d . -f "${i}"-100) + if [ -z "${candidate}" ]; then + return 1 + fi + + if _hetznercloud_get_zone_by_candidate "${candidate}"; then + zone_name_lc="${_hetznercloud_zone_name_lc}" + if [ "${fulldomain_lc}" = "${zone_name_lc}" ]; then + _hetznercloud_rr_name="@" + else + suffix=".${zone_name_lc}" + if _endswith "${fulldomain_lc}" "${suffix}"; then + _hetznercloud_rr_name="${fulldomain_lc%"${suffix}"}" + else + _hetznercloud_rr_name="${fulldomain_lc}" + fi + fi + _hetznercloud_rrset_path=$(printf "%s" "${_hetznercloud_rr_name}" | _url_encode) + _hetznercloud_rrset_action_add="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/add_records" + _hetznercloud_rrset_action_remove="/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT/actions/remove_records" + return 0 + fi + p=${i} + i=$(_math "${i}" + 1) + done +} + +_hetznercloud_get_zone_by_candidate() { + candidate="${1}" + zone_key=$(printf "%s" "${candidate}" | sed 's/[^A-Za-z0-9]/_/g') + zone_conf_key="HETZNERCLOUD_ZONE_ID_for_${zone_key}" + + cached_zone_id=$(_readdomainconf "${zone_conf_key}") + if [ -n "${cached_zone_id}" ]; then + if _hetznercloud_api GET "/zones/${cached_zone_id}"; then + if [ "${_hetznercloud_last_http_code}" = "200" ]; then + zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//') + if _hetznercloud_parse_zone_fields "${zone_data}"; then + zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case) + if [ "${zone_name_lc}" = "${candidate}" ]; then + return 0 + fi + fi + elif [ "${_hetznercloud_last_http_code}" = "404" ]; then + _cleardomainconf "${zone_conf_key}" + fi + else + return 1 + fi + fi + + if _hetznercloud_api GET "/zones/${candidate}"; then + if [ "${_hetznercloud_last_http_code}" = "200" ]; then + zone_data=$(printf "%s" "${response}" | _normalizeJson | sed 's/^{"zone"://' | sed 's/}$//') + if _hetznercloud_parse_zone_fields "${zone_data}"; then + zone_name_lc=$(printf "%s" "${_hetznercloud_zone_name}" | _lower_case) + if [ "${zone_name_lc}" = "${candidate}" ]; then + _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}" + return 0 + fi + fi + elif [ "${_hetznercloud_last_http_code}" != "404" ]; then + _hetznercloud_log_http_error "Hetzner Cloud zone lookup failed" "${_hetznercloud_last_http_code}" + return 1 + fi + else + return 1 + fi + + encoded_candidate=$(printf "%s" "${candidate}" | _url_encode) + if ! _hetznercloud_api GET "/zones?name=${encoded_candidate}"; then + return 1 + fi + if [ "${_hetznercloud_last_http_code}" != "200" ]; then + if [ "${_hetznercloud_last_http_code}" = "404" ]; then + return 1 + fi + _hetznercloud_log_http_error "Hetzner Cloud zone search failed" "${_hetznercloud_last_http_code}" + return 1 + fi + + zone_data=$(_hetznercloud_extract_zone_from_list "${response}" "${candidate}") + if [ -z "${zone_data}" ]; then + return 1 + fi + if ! _hetznercloud_parse_zone_fields "${zone_data}"; then + return 1 + fi + _savedomainconf "${zone_conf_key}" "${_hetznercloud_zone_id}" + return 0 +} + +_hetznercloud_parse_zone_fields() { + zone_json="${1}" + if [ -z "${zone_json}" ]; then + return 1 + fi + normalized=$(printf "%s" "${zone_json}" | _normalizeJson) + zone_id=$(printf "%s" "${normalized}" | _egrep_o '"id":[^,}]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "') + zone_name=$(printf "%s" "${normalized}" | _egrep_o '"name":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + if [ -z "${zone_id}" ] || [ -z "${zone_name}" ]; then + return 1 + fi + zone_name_trimmed=$(printf "%s" "${zone_name}" | sed 's/\.$//') + if zone_name_ascii=$(_idn "${zone_name_trimmed}"); then + zone_name="${zone_name_ascii}" + else + zone_name="${zone_name_trimmed}" + fi + _hetznercloud_zone_id="${zone_id}" + _hetznercloud_zone_name="${zone_name}" + _hetznercloud_zone_name_lc=$(printf "%s" "${zone_name}" | _lower_case) + return 0 +} + +_hetznercloud_extract_zone_from_list() { + list_response=$(printf "%s" "${1}" | _normalizeJson) + candidate="${2}" + escaped_candidate=$(_hetznercloud_escape_regex "${candidate}") + printf "%s" "${list_response}" | _egrep_o "{[^{}]*\"name\":\"${escaped_candidate}\"[^{}]*}" | _head_n 1 +} + +_hetznercloud_escape_regex() { + printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/\./\\./g' | sed 's/-/\\-/g' +} + +_hetznercloud_get_rrset() { + if [ -z "${_hetznercloud_zone_id}" ] || [ -z "${_hetznercloud_rrset_path}" ]; then + return 1 + fi + if ! _hetznercloud_api GET "/zones/${_hetznercloud_zone_id}/rrsets/${_hetznercloud_rrset_path}/TXT"; then + return 1 + fi + return 0 +} + +_hetznercloud_rrset_contains_value() { + wanted_value="${1}" + normalized=$(printf "%s" "${response}" | _normalizeJson) + escaped_value=$(_hetznercloud_escape_value "${wanted_value}") + search_pattern="\"value\":\"\\\\\"${escaped_value}\\\\\"\"" + if _contains "${normalized}" "${search_pattern}"; then + return 0 + fi + return 1 +} + +_hetznercloud_build_add_payload() { + value="${1}" + escaped_value=$(_hetznercloud_escape_value "${value}") + printf '{"ttl":%s,"records":[{"value":"\\"%s\\""}]}' "${HETZNER_TTL}" "${escaped_value}" +} + +_hetznercloud_build_remove_payload() { + value="${1}" + escaped_value=$(_hetznercloud_escape_value "${value}") + printf '{"records":[{"value":"\\"%s\\""}]}' "${escaped_value}" +} + +_hetznercloud_escape_value() { + printf "%s" "${1}" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' +} + +_hetznercloud_error_message() { + if [ -z "${response}" ]; then + return 1 + fi + message=$(printf "%s" "${response}" | _normalizeJson | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + if [ -n "${message}" ]; then + printf "%s" "${message}" + return 0 + fi + return 1 +} + +_hetznercloud_log_http_error() { + context="${1}" + code="${2}" + message="$(_hetznercloud_error_message)" + if [ -n "${context}" ]; then + if [ -n "${message}" ]; then + _err "${context} (HTTP ${code}): ${message}" + else + _err "${context} (HTTP ${code})" + fi + else + if [ -n "${message}" ]; then + _err "Hetzner Cloud DNS API error (HTTP ${code}): ${message}" + else + _err "Hetzner Cloud DNS API error (HTTP ${code})" + fi + fi +} + +_hetznercloud_api() { + method="${1}" + ep="${2}" + data="${3}" + retried="${4}" + + if [ -z "${method}" ]; then + method="GET" + fi + + if ! _startswith "${ep}" "/"; then + ep="/${ep}" + fi + url="${HETZNER_API}${ep}" + + export _H1="Authorization: Bearer ${HETZNER_TOKEN}" + export _H2="Accept: application/json" + export _H3="" + export _H4="" + export _H5="" + + : >"${HTTP_HEADER}" + + if [ "${method}" = "GET" ]; then + response="$(_get "${url}")" + else + if [ -z "${data}" ]; then + data="{}" + fi + response="$(_post "${data}" "${url}" "" "${method}" "application/json")" + fi + ret="${?}" + + _hetznercloud_last_http_code=$(grep "^HTTP" "${HTTP_HEADER}" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n') + + if [ "${ret}" != "0" ]; then + return 1 + fi + + if [ "${_hetznercloud_last_http_code}" = "429" ] && [ "${retried}" != "retried" ]; then + retry_after=$(grep -i "^Retry-After" "${HTTP_HEADER}" | _tail_n 1 | cut -d : -f 2 | tr -d ' \r') + if [ -z "${retry_after}" ]; then + retry_after=1 + fi + _info "Hetzner Cloud DNS API rate limit hit; retrying in ${retry_after} seconds." + _sleep "${retry_after}" + if ! _hetznercloud_api "${method}" "${ep}" "${data}" "retried"; then + return 1 + fi + return 0 + fi + + return 0 +} + +_hetznercloud_handle_action_response() { + context="${1}" + if [ -z "${response}" ]; then + return 0 + fi + + normalized=$(printf "%s" "${response}" | _normalizeJson) + + failed_message="" + if failed_message=$(_hetznercloud_extract_failed_action_message "${normalized}"); then + if [ -n "${failed_message}" ]; then + _err "Hetzner Cloud DNS ${context} failed: ${failed_message}" + else + _err "Hetzner Cloud DNS ${context} failed." + fi + return 1 + fi + + action_ids="" + if action_ids=$(_hetznercloud_extract_action_ids "${normalized}"); then + for action_id in ${action_ids}; do + if [ -z "${action_id}" ]; then + continue + fi + if ! _hetznercloud_wait_for_action "${action_id}" "${context}"; then + return 1 + fi + done + fi + + return 0 +} + +_hetznercloud_extract_failed_action_message() { + normalized="${1}" + failed_section=$(printf "%s" "${normalized}" | _egrep_o '"failed_actions":\[[^]]*\]') + if [ -z "${failed_section}" ]; then + return 1 + fi + if _contains "${failed_section}" '"failed_actions":[]'; then + return 1 + fi + message=$(printf "%s" "${failed_section}" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + if [ -n "${message}" ]; then + printf "%s" "${message}" + else + printf "%s" "${failed_section}" + fi + return 0 +} + +_hetznercloud_extract_action_ids() { + normalized="${1}" + actions_section=$(printf "%s" "${normalized}" | _egrep_o '"actions":\[[^]]*\]') + if [ -z "${actions_section}" ]; then + return 1 + fi + action_ids=$(printf "%s" "${actions_section}" | _egrep_o '"id":[0-9]*' | cut -d : -f 2 | tr -d '"' | tr '\n' ' ') + action_ids=$(printf "%s" "${action_ids}" | tr -s ' ') + action_ids=$(printf "%s" "${action_ids}" | sed 's/^ //;s/ $//') + if [ -z "${action_ids}" ]; then + return 1 + fi + printf "%s" "${action_ids}" + return 0 +} + +_hetznercloud_wait_for_action() { + action_id="${1}" + context="${2}" + attempts="0" + + while true; do + if ! _hetznercloud_api GET "/actions/${action_id}"; then + return 1 + fi + if [ "${_hetznercloud_last_http_code}" != "200" ]; then + _hetznercloud_log_http_error "Hetzner Cloud DNS action ${action_id} query failed" "${_hetznercloud_last_http_code}" + return 1 + fi + + normalized=$(printf "%s" "${response}" | _normalizeJson) + action_status=$(_hetznercloud_action_status_from_normalized "${normalized}") + + if [ -z "${action_status}" ]; then + _err "Hetzner Cloud DNS ${context} action ${action_id} returned no status." + return 1 + fi + + if [ "${action_status}" = "success" ]; then + return 0 + fi + + if [ "${action_status}" = "error" ]; then + if action_error=$(_hetznercloud_action_error_from_normalized "${normalized}"); then + _err "Hetzner Cloud DNS ${context} action ${action_id} failed: ${action_error}" + else + _err "Hetzner Cloud DNS ${context} action ${action_id} failed." + fi + return 1 + fi + + attempts=$(_math "${attempts}" + 1) + if [ "${attempts}" -ge "${HETZNER_MAX_ATTEMPTS}" ]; then + _err "Hetzner Cloud DNS ${context} action ${action_id} did not complete after ${HETZNER_MAX_ATTEMPTS} attempts." + return 1 + fi + + _sleep 1 + done +} + +_hetznercloud_action_status_from_normalized() { + normalized="${1}" + status=$(printf "%s" "${normalized}" | _egrep_o '"status":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + printf "%s" "${status}" +} + +_hetznercloud_action_error_from_normalized() { + normalized="${1}" + error_section=$(printf "%s" "${normalized}" | _egrep_o '"error":{[^}]*}') + if [ -z "${error_section}" ]; then + return 1 + fi + message=$(printf "%s" "${error_section}" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + if [ -n "${message}" ]; then + printf "%s" "${message}" + return 0 + fi + code=$(printf "%s" "${error_section}" | _egrep_o '"code":"[^"]*"' | _head_n 1 | cut -d : -f 2 | tr -d '"') + if [ -n "${code}" ]; then + printf "%s" "${code}" + return 0 + fi + return 1 +} diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh new file mode 100644 index 00000000..b3211069 --- /dev/null +++ b/dnsapi/dns_hostup.sh @@ -0,0 +1,501 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034,SC2154 + +dns_hostup_info='HostUp DNS +Site: hostup.se +Docs: https://developer.hostup.se/ +Options: + HOSTUP_API_KEY Required. HostUp API key with read:dns + write:dns + read:domains scopes. + HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api). + HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds). + HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection). +Author: HostUp (https://cloud.hostup.se/contact/en) +' + +HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api" +HOSTUP_DEFAULT_TTL=60 + +# Public: add TXT record +# Usage: dns_hostup_add _acme-challenge.example.com "txt-value" +dns_hostup_add() { + fulldomain="$1" + txtvalue="$2" + + _info "Using HostUp DNS API" + + if ! _hostup_init; then + return 1 + fi + + if ! _hostup_detect_zone "$fulldomain"; then + _err "Unable to determine HostUp zone for $fulldomain" + return 1 + fi + + record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")" + record_name="$(_hostup_sanitize_name "$record_name")" + record_value="$(_hostup_json_escape "$txtvalue")" + + ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}" + + _debug "zone_id" "$HOSTUP_ZONE_ID" + _debug "zone_domain" "$HOSTUP_ZONE_DOMAIN" + _debug "record_name" "$record_name" + _debug "ttl" "$ttl" + + request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$record_value\",\"ttl\":$ttl}" + + if ! _hostup_rest "POST" "/dns/zones/$HOSTUP_ZONE_ID/records" "$request_body"; then + return 1 + fi + + if ! _contains "$_hostup_response" '"success":true'; then + _err "HostUp DNS API: failed to create TXT record for $fulldomain" + _debug2 "_hostup_response" "$_hostup_response" + return 1 + fi + + record_id="$(_hostup_extract_record_id "$_hostup_response")" + if [ -n "$record_id" ]; then + _hostup_save_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_id" + _debug "hostup_saved_record_id" "$record_id" + fi + + _info "Added TXT record for $fulldomain" + return 0 +} + +# Public: remove TXT record +# Usage: dns_hostup_rm _acme-challenge.example.com "txt-value" +dns_hostup_rm() { + fulldomain="$1" + txtvalue="$2" + + _info "Using HostUp DNS API" + + if ! _hostup_init; then + return 1 + fi + + if ! _hostup_detect_zone "$fulldomain"; then + _err "Unable to determine HostUp zone for $fulldomain" + return 1 + fi + + record_name_fqdn="$(_hostup_fqdn "$fulldomain")" + record_value="$txtvalue" + + record_id_cached="$(_hostup_get_saved_record_id "$HOSTUP_ZONE_ID" "$fulldomain")" + if [ -n "$record_id_cached" ]; then + _debug "hostup_record_id_cached" "$record_id_cached" + if _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$record_id_cached"; then + _info "Deleted TXT record $record_id_cached" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + HOSTUP_ZONE_ID="" + return 0 + fi + fi + + if ! _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$record_value"; then + _info "TXT record not found for $record_name_fqdn. Skipping removal." + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + return 0 + fi + + _debug "Deleting record" "$HOSTUP_RECORD_ID" + + if ! _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$HOSTUP_RECORD_ID"; then + return 1 + fi + + _info "Deleted TXT record $HOSTUP_RECORD_ID" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + HOSTUP_ZONE_ID="" + return 0 +} + +########################## +# Private helper methods # +########################## + +_hostup_init() { + HOSTUP_API_KEY="${HOSTUP_API_KEY:-$(_readaccountconf_mutable HOSTUP_API_KEY)}" + HOSTUP_API_BASE="${HOSTUP_API_BASE:-$(_readaccountconf_mutable HOSTUP_API_BASE)}" + HOSTUP_TTL="${HOSTUP_TTL:-$(_readaccountconf_mutable HOSTUP_TTL)}" + HOSTUP_ZONE_ID="${HOSTUP_ZONE_ID:-$(_readaccountconf_mutable HOSTUP_ZONE_ID)}" + + if [ -z "$HOSTUP_API_BASE" ]; then + HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT" + fi + + if [ -z "$HOSTUP_API_KEY" ]; then + HOSTUP_API_KEY="" + _err "HOSTUP_API_KEY is not set." + _err "Please export your HostUp API key with read:dns and write:dns scopes." + return 1 + fi + + _saveaccountconf_mutable HOSTUP_API_KEY "$HOSTUP_API_KEY" + _saveaccountconf_mutable HOSTUP_API_BASE "$HOSTUP_API_BASE" + + if [ -n "$HOSTUP_TTL" ]; then + _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL" + fi + + if [ -n "$HOSTUP_ZONE_ID" ]; then + _saveaccountconf_mutable HOSTUP_ZONE_ID "$HOSTUP_ZONE_ID" + fi + + return 0 +} + +_hostup_detect_zone() { + fulldomain="$1" + + if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then + return 0 + fi + + HOSTUP_ZONE_DOMAIN="" + _debug "hostup_full_domain" "$fulldomain" + + if [ -n "$HOSTUP_ZONE_ID" ] && [ -z "$HOSTUP_ZONE_DOMAIN" ]; then + # Attempt to fetch domain name for provided zone ID + if _hostup_fetch_zone_details "$HOSTUP_ZONE_ID"; then + return 0 + fi + HOSTUP_ZONE_ID="" + fi + + if ! _hostup_load_zones; then + return 1 + fi + + _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)" + _debug "hostup_initial_candidate" "$_domain_candidate" + + while [ -n "$_domain_candidate" ]; do + _debug "hostup_zone_candidate" "$_domain_candidate" + if _hostup_lookup_zone "$_domain_candidate"; then + HOSTUP_ZONE_DOMAIN="$_lookup_zone_domain" + HOSTUP_ZONE_ID="$_lookup_zone_id" + return 0 + fi + + case "$_domain_candidate" in + *.*) ;; + *) break ;; + esac + + _domain_candidate="${_domain_candidate#*.}" + done + + HOSTUP_ZONE_ID="" + return 1 +} + +_hostup_record_name() { + fulldomain="$1" + zonedomain="$2" + + # Remove trailing dot, if any + fulldomain="${fulldomain%.}" + zonedomain="${zonedomain%.}" + + if [ "$fulldomain" = "$zonedomain" ]; then + printf "%s" "@" + return 0 + fi + + suffix=".$zonedomain" + case "$fulldomain" in + *"$suffix") + printf "%s" "${fulldomain%"$suffix"}" + ;; + *) + # Domain not within zone, fall back to full host + printf "%s" "$fulldomain" + ;; + esac +} + +_hostup_sanitize_name() { + name="$1" + + if [ -z "$name" ] || [ "$name" = "." ]; then + printf "%s" "@" + return 0 + fi + + # Remove any trailing dot + name="${name%.}" + printf "%s" "$name" +} + +_hostup_fqdn() { + domain="$1" + printf "%s" "${domain%.}" +} + +_hostup_fetch_zone_details() { + zone_id="$1" + + if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then + return 1 + fi + + zonedomain="$(printf "%s" "$_hostup_response" | _egrep_o '"domain":"[^"]*"' | sed -n '1p' | cut -d ':' -f 2 | tr -d '"')" + if [ -n "$zonedomain" ]; then + HOSTUP_ZONE_DOMAIN="$zonedomain" + return 0 + fi + + return 1 +} + +_hostup_load_zones() { + if ! _hostup_rest "GET" "/dns/zones" ""; then + return 1 + fi + + HOSTUP_ZONES_CACHE="" + data="$(printf "%s" "$_hostup_response" | tr '{' '\n')" + + while IFS= read -r line; do + case "$line" in + *'"domain_id"'*'"domain"'*) + zone_id="$(printf "%s" "$line" | _hostup_json_extract "domain_id")" + zone_domain="$(printf "%s" "$line" | _hostup_json_extract "domain")" + if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then + HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id} +" + _debug "hostup_zone_loaded" "$zone_domain|$zone_id" + fi + ;; + esac + done <%s + + content + + %s + + - ' "$_domain" "$_sub_domain") + ' "$_domain" "$_sub_domain" "$txtvalue") response="$(_post "$xml_content" "$INWX_Api" "" "POST")" if ! _contains "$response" "Command completed successfully"; then @@ -125,7 +132,7 @@ dns_inwx_rm() { if ! printf "%s" "$response" | grep "count" >/dev/null; then _info "Do not need to delete record" else - _record_id=$(printf '%s' "$response" | _egrep_o '.*(record){1}(.*)([0-9]+){1}' | _egrep_o 'id<\/name>[0-9]+' | _egrep_o '[0-9]+') + _record_id=$(printf '%s' "$response" | _egrep_o '.*(record){1}(.*)([0-9]+){1}' | _egrep_o 'id<\/name>[0-9]+' | _egrep_o '[0-9]+') _info "Deleting record" _inwx_delete_record "$_record_id" fi @@ -324,7 +331,7 @@ _inwx_delete_record() { id - %s + %s @@ -362,7 +369,7 @@ _inwx_update_record() { id - %s + %s diff --git a/dnsapi/dns_joker.sh b/dnsapi/dns_joker.sh index 1fe33c67..401471be 100644 --- a/dnsapi/dns_joker.sh +++ b/dnsapi/dns_joker.sh @@ -7,7 +7,7 @@ Options: JOKER_USERNAME Username JOKER_PASSWORD Password Issues: github.com/acmesh-official/acme.sh/issues/2840 -Author: +Author: @aattww ' JOKER_API="https://svc.joker.com/nic/replace" diff --git a/dnsapi/dns_la.sh b/dnsapi/dns_la.sh index f19333c4..9cb6327e 100644 --- a/dnsapi/dns_la.sh +++ b/dnsapi/dns_la.sh @@ -1,14 +1,17 @@ #!/usr/bin/env sh + +# LA_Id="123" +# LA_Sk="456" # shellcheck disable=SC2034 dns_la_info='dns.la Site: dns.la Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_la Options: - LA_Id API ID - LA_Key API key + LA_Id APIID + LA_Sk APISecret + LA_Token 用冒号连接 APIID APISecret 再base64生成 Issues: github.com/acmesh-official/acme.sh/issues/4257 ' - LA_Api="https://api.dns.la/api" ######## Public functions ##################### @@ -19,18 +22,23 @@ dns_la_add() { txtvalue=$2 LA_Id="${LA_Id:-$(_readaccountconf_mutable LA_Id)}" - LA_Key="${LA_Key:-$(_readaccountconf_mutable LA_Key)}" + LA_Sk="${LA_Sk:-$(_readaccountconf_mutable LA_Sk)}" + _log "LA_Id=$LA_Id" + _log "LA_Sk=$LA_Sk" - if [ -z "$LA_Id" ] || [ -z "$LA_Key" ]; then + if [ -z "$LA_Id" ] || [ -z "$LA_Sk" ]; then LA_Id="" - LA_Key="" + LA_Sk="" _err "You didn't specify a dnsla api id and key yet." return 1 fi #save the api key and email to the account conf file. _saveaccountconf_mutable LA_Id "$LA_Id" - _saveaccountconf_mutable LA_Key "$LA_Key" + _saveaccountconf_mutable LA_Sk "$LA_Sk" + + # generate dnsla token + _la_token _debug "First detect the root zone" if ! _get_root "$fulldomain"; then @@ -42,11 +50,13 @@ dns_la_add() { _debug _domain "$_domain" _info "Adding record" - if _la_rest "record.ashx?cmd=create&apiid=$LA_Id&apipass=$LA_Key&rtype=json&domainid=$_domain_id&host=$_sub_domain&recordtype=TXT&recorddata=$txtvalue&recordline="; then - if _contains "$response" '"resultid":'; then + + # record type is enum in new api, 16 for TXT + if _la_post "{\"domainId\":\"$_domain_id\",\"type\":16,\"host\":\"$_sub_domain\",\"data\":\"$txtvalue\",\"ttl\":600}" "record"; then + if _contains "$response" '"id":'; then _info "Added, OK" return 0 - elif _contains "$response" '"code":532'; then + elif _contains "$response" '"msg":"与已有记录冲突"'; then _info "Already exists, OK" return 0 else @@ -54,7 +64,7 @@ dns_la_add() { return 1 fi fi - _err "Add txt record error." + _err "Add txt record failed." return 1 } @@ -65,7 +75,9 @@ dns_la_rm() { txtvalue=$2 LA_Id="${LA_Id:-$(_readaccountconf_mutable LA_Id)}" - LA_Key="${LA_Key:-$(_readaccountconf_mutable LA_Key)}" + LA_Sk="${LA_Sk:-$(_readaccountconf_mutable LA_Sk)}" + + _la_token _debug "First detect the root zone" if ! _get_root "$fulldomain"; then @@ -77,27 +89,29 @@ dns_la_rm() { _debug _domain "$_domain" _debug "Getting txt records" - if ! _la_rest "record.ashx?cmd=listn&apiid=$LA_Id&apipass=$LA_Key&rtype=json&domainid=$_domain_id&domain=$_domain&host=$_sub_domain&recordtype=TXT&recorddata=$txtvalue"; then + # record type is enum in new api, 16 for TXT + if ! _la_get "recordList?pageIndex=1&pageSize=10&domainId=$_domain_id&host=$_sub_domain&type=16&data=$txtvalue"; then _err "Error" return 1 fi - if ! _contains "$response" '"recordid":'; then + if ! _contains "$response" '"id":'; then _info "Don't need to remove." return 0 fi - record_id=$(printf "%s" "$response" | grep '"recordid":' | cut -d : -f 2 | cut -d , -f 1 | tr -d '\r' | tr -d '\n') + record_id=$(printf "%s" "$response" | grep '"id":' | _head_n 1 | sed 's/.*"id": *"\([^"]*\)".*/\1/') _debug "record_id" "$record_id" if [ -z "$record_id" ]; then _err "Can not get record id to remove." return 1 fi - if ! _la_rest "record.ashx?cmd=remove&apiid=$LA_Id&apipass=$LA_Key&rtype=json&domainid=$_domain_id&domain=$_domain&recordid=$record_id"; then + # remove record in new api is RESTful + if ! _la_post "" "record?id=$record_id" "DELETE"; then _err "Delete record error." return 1 fi - _contains "$response" '"code":300' + _contains "$response" '"code":200' } @@ -119,12 +133,13 @@ _get_root() { return 1 fi - if ! _la_rest "domain.ashx?cmd=get&apiid=$LA_Id&apipass=$LA_Key&rtype=json&domain=$h"; then + if ! _la_get "domain?domain=$h"; then return 1 fi - if _contains "$response" '"domainid":'; then - _domain_id=$(printf "%s" "$response" | grep '"domainid":' | cut -d : -f 2 | cut -d , -f 1 | tr -d '\r' | tr -d '\n') + if _contains "$response" '"domain":'; then + _domain_id=$(echo "$response" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') + _log "_domain_id" "$_domain_id" if [ "$_domain_id" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$h" @@ -143,6 +158,21 @@ _la_rest() { url="$LA_Api/$1" _debug "$url" + if ! response="$(_get "$url" "Authorization: Basic $LA_Token" | tr -d ' ' | tr "}" ",")"; then + _err "Error: $url" + return 1 + fi + + _debug2 response "$response" + return 0 +} + +_la_get() { + url="$LA_Api/$1" + _debug "$url" + + export _H1="Authorization: Basic $LA_Token" + if ! response="$(_get "$url" | tr -d ' ' | tr "}" ",")"; then _err "Error: $url" return 1 @@ -151,3 +181,29 @@ _la_rest() { _debug2 response "$response" return 0 } + +# Usage: _la_post body url [POST|PUT|DELETE] +_la_post() { + body=$1 + url="$LA_Api/$2" + http_method=$3 + _debug "$body" + _debug "$url" + + export _H1="Authorization: Basic $LA_Token" + + if ! response="$(_post "$body" "$url" "" "$http_method")"; then + _err "Error: $url" + return 1 + fi + + _debug2 response "$response" + return 0 +} + +_la_token() { + LA_Token=$(printf "%s:%s" "$LA_Id" "$LA_Sk" | _base64) + _debug "$LA_Token" + + return 0 +} diff --git a/dnsapi/dns_limacity.sh b/dnsapi/dns_limacity.sh index fb12f8c6..5734be9e 100644 --- a/dnsapi/dns_limacity.sh +++ b/dnsapi/dns_limacity.sh @@ -1,13 +1,13 @@ #!/usr/bin/env sh - -# Created by Laraveluser -# -# Pass credentials before "acme.sh --issue --dns dns_limacity ..." -# -- -# export LIMACITY_APIKEY="" -# -- -# -# Pleas note: APIKEY must have following roles: dns.admin, domains.reader +# shellcheck disable=SC2034 +dns_limacity_info='lima-city.de +Site: www.lima-city.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_limacity +Options: + LIMACITY_APIKEY API Key. Note: The API Key must have following roles: dns.admin, domains.reader +Issues: github.com/acmesh-official/acme.sh/issues/4758 +Author: @Laraveluser +' ######## Public functions ##################### diff --git a/dnsapi/dns_mgwm.sh b/dnsapi/dns_mgwm.sh new file mode 100644 index 00000000..57679127 --- /dev/null +++ b/dnsapi/dns_mgwm.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_mgwm_info='mgw-media.de +Site: mgw-media.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_mgwm +Options: + MGWM_CUSTOMER Your customer number + MGWM_API_HASH Your API Hash +Issues: github.com/acmesh-official/acme.sh/issues/6669 +' +# Base URL for the mgw-media.de API +MGWM_API_BASE="https://api.mgw-media.de/record" + +######## Public functions ##################### + +# This function is called by acme.sh to add a TXT record. +dns_mgwm_add() { + fulldomain=$1 + txtvalue=$2 + _info "Using mgw-media.de DNS API for domain $fulldomain (add record)" + _debug "fulldomain: $fulldomain" + _debug "txtvalue: $txtvalue" + + # Call the new private function to handle the API request. + # The 'add' action, fulldomain, type 'txt' and txtvalue are passed. + if _mgwm_request "add" "$fulldomain" "txt" "$txtvalue"; then + _info "TXT record for $fulldomain successfully added via mgw-media.de API." + _sleep 10 # Wait briefly for DNS propagation, a common practice in DNS-01 hooks. + return 0 + else + # Error message already logged by _mgwm_request, but a specific one here helps. + _err "mgwm_add: Failed to add TXT record for $fulldomain." + return 1 + fi +} +# This function is called by acme.sh to remove a TXT record after validation. +dns_mgwm_rm() { + fulldomain=$1 + txtvalue=$2 # This txtvalue is now used to identify the specific record to be removed. + _info "Removing TXT record for $fulldomain using mgw-media.de DNS API (remove record)" + _debug "fulldomain: $fulldomain" + _debug "txtvalue: $txtvalue" + + # Call the new private function to handle the API request. + # The 'rm' action, fulldomain, type 'txt' and txtvalue are passed. + if _mgwm_request "rm" "$fulldomain" "txt" "$txtvalue"; then + _info "TXT record for $fulldomain successfully removed via mgw-media.de API." + return 0 + else + # Error message already logged by _mgwm_request, but a specific one here helps. + _err "mgwm_rm: Failed to remove TXT record for $fulldomain." + return 1 + fi +} +#################### Private functions below ################################## + +# _mgwm_request() encapsulates the API call logic, including +# loading credentials, setting the Authorization header, and executing the request. +# Arguments: +# $1: action (e.g., "add", "rm") +# $2: fulldomain +# $3: type (e.g., "txt") +# $4: content (the txtvalue) +_mgwm_request() { + _action="$1" + _fulldomain="$2" + _type="$3" + _content="$4" + + _debug "Calling _mgwm_request for action: $_action, domain: $_fulldomain, type: $_type, content: $_content" + + # Load credentials from environment or acme.sh config + MGWM_CUSTOMER="${MGWM_CUSTOMER:-$(_readaccountconf_mutable MGWM_CUSTOMER)}" + MGWM_API_HASH="${MGWM_API_HASH:-$(_readaccountconf_mutable MGWM_API_HASH)}" + + # Check if credentials are set + if [ -z "$MGWM_CUSTOMER" ] || [ -z "$MGWM_API_HASH" ]; then + _err "You didn't specify one or more of MGWM_CUSTOMER or MGWM_API_HASH." + _err "Please check these environment variables and try again." + return 1 + fi + + # Save credentials for automatic renewal and future calls + _saveaccountconf_mutable MGWM_CUSTOMER "$MGWM_CUSTOMER" + _saveaccountconf_mutable MGWM_API_HASH "$MGWM_API_HASH" + + # Create the Basic Auth Header. acme.sh's _base64 function is used for encoding. + _credentials="$(printf "%s:%s" "$MGWM_CUSTOMER" "$MGWM_API_HASH" | _base64)" + export _H1="Authorization: Basic $_credentials" + _debug "Set Authorization Header: Basic " # Log debug message without sensitive credentials + + # Construct the API URL based on the action and provided parameters. + _request_url="${MGWM_API_BASE}/${_action}/${_fulldomain}/${_type}/${_content}" + _debug "Constructed mgw-media.de API URL for action '$_action': ${_request_url}" + + # Execute the HTTP GET request with the Authorization Header. + # The 5th parameter of _get is where acme.sh expects custom HTTP headers like Authorization. + response="$(_get "$_request_url")" + _debug "mgw-media.de API response for action '$_action': $response" + + # Check the API response for success. The API returns "OK" on success. + if [ "$response" = "OK" ]; then + _info "mgw-media.de API action '$_action' for record '$_fulldomain' successful." + return 0 + else + _err "Failed mgw-media.de API action '$_action' for record '$_fulldomain'. Unexpected API Response: '$response'" + return 1 + fi +} diff --git a/dnsapi/dns_mijnhost.sh b/dnsapi/dns_mijnhost.sh new file mode 100644 index 00000000..9f5e7710 --- /dev/null +++ b/dnsapi/dns_mijnhost.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_mijnhost_info='mijn.host +Site: mijn.host +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_mijnhost +Options: + MIJNHOST_API_KEY API Key +Issues: github.com/acmesh-official/acme.sh/issues/6177 +Author: @peterv99 +' + +######## Public functions ###################### +MIJNHOST_API="https://mijn.host/api/v2" + +# Add TXT record for domain verification +dns_mijnhost_add() { + fulldomain=$1 + txtvalue=$2 + + MIJNHOST_API_KEY="${MIJNHOST_API_KEY:-$(_readaccountconf_mutable MIJNHOST_API_KEY)}" + if [ -z "$MIJNHOST_API_KEY" ]; then + MIJNHOST_API_KEY="" + _err "You haven't specified your mijn-host API key yet." + _err "Please add MIJNHOST_API_KEY to the env." + return 1 + fi + + # Save the API key for future use + _saveaccountconf_mutable MIJNHOST_API_KEY "$MIJNHOST_API_KEY" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + _debug2 _sub_domain "$_sub_domain" + _debug2 _domain "$_domain" + _debug "Adding DNS record" "${fulldomain}." + + # Construct the API URL + api_url="$MIJNHOST_API/domains/$_domain/dns" + + # Getting previous records + _mijnhost_rest GET "$api_url" "" + + if [ "$_code" != "200" ]; then + _err "Error getting current DNS enties ($_code)" + return 1 + fi + + records=$(echo "$response" | _egrep_o '"records":\[.*\]' | sed 's/"records"://') + + _debug2 "Current records" "$records" + + # Build the payload for the API + data="{\"type\":\"TXT\",\"name\":\"$fulldomain.\",\"value\":\"$txtvalue\",\"ttl\":300}" + + _debug2 "Record to add" "$data" + + # Updating the records + updated_records=$(echo "$records" | sed -E "s/\]( *$)/,$data\]/") + + _debug2 "Updated records" "$updated_records" + + # data + data="{\"records\": $updated_records}" + + _mijnhost_rest PUT "$api_url" "$data" + + if [ "$_code" = "200" ]; then + _info "DNS record succesfully added." + return 0 + else + _err "Error adding DNS record ($_code)." + return 1 + fi +} + +# Remove TXT record after verification +dns_mijnhost_rm() { + fulldomain=$1 + txtvalue=$2 + + MIJNHOST_API_KEY="${MIJNHOST_API_KEY:-$(_readaccountconf_mutable MIJNHOST_API_KEY)}" + if [ -z "$MIJNHOST_API_KEY" ]; then + MIJNHOST_API_KEY="" + _err "You haven't specified your mijn-host API key yet." + _err "Please add MIJNHOST_API_KEY to the env." + return 1 + fi + + _debug "Detecting root zone for" "${fulldomain}." + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + _debug "Removing DNS record for TXT value" "${txtvalue}." + + # Construct the API URL + api_url="$MIJNHOST_API/domains/$_domain/dns" + + # Get current records + _mijnhost_rest GET "$api_url" "" + + if [ "$_code" != "200" ]; then + _err "Error getting current DNS enties ($_code)" + return 1 + fi + + _debug2 "Get current records response:" "$response" + + records=$(echo "$response" | _egrep_o '"records":\[.*\]' | sed 's/"records"://') + + _debug2 "Current records:" "$records" + + updated_records=$(echo "$records" | sed -E "s/\{[^}]*\"value\":\"$txtvalue\"[^}]*\},?//g" | sed 's/,]/]/g') + + _debug2 "Updated records:" "$updated_records" + + # Build the new payload + data="{\"records\": $updated_records}" + + # Use the _put method to update the records + _mijnhost_rest PUT "$api_url" "$data" + + if [ "$_code" = "200" ]; then + _info "DNS record removed successfully." + return 0 + else + _err "Error removing DNS record ($_code)." + return 1 + fi +} + +# Helper function to detect the root zone +_get_root() { + domain=$1 + + # Get current records + _debug "Getting current domains" + _mijnhost_rest GET "$MIJNHOST_API/domains" "" + + if [ "$_code" != "200" ]; then + _err "error getting current domains ($_code)" + return 1 + fi + + # Extract root domains from response + rootDomains=$(echo "$response" | _egrep_o '"domain":"[^"]*"' | sed -E 's/"domain":"([^"]*)"/\1/') + _debug "Root domains:" "$rootDomains" + + for rootDomain in $rootDomains; do + if _contains "$domain" "$rootDomain"; then + _domain="$rootDomain" + _sub_domain=$(echo "$domain" | sed "s/.$rootDomain//g") + _debug "Found root domain" "$_domain" "and subdomain" "$_sub_domain" "for" "$domain" + return 0 + fi + done + return 1 +} + +# Helper function for rest calls +_mijnhost_rest() { + m=$1 + ep="$2" + data="$3" + + MAX_REQUEST_RETRY_TIMES=15 + _request_retry_times=0 + _retry_sleep=5 #Initial sleep time in seconds. + + while [ "${_request_retry_times}" -lt "$MAX_REQUEST_RETRY_TIMES" ]; do + _debug2 _request_retry_times "$_request_retry_times" + export _H1="API-Key: $MIJNHOST_API_KEY" + export _H2="Content-Type: application/json" + # clear headers from previous request to avoid getting wrong http code on timeouts + : >"$HTTP_HEADER" + _debug "$ep" + if [ "$m" != "GET" ]; then + _debug2 "data $data" + response="$(_post "$data" "$ep" "" "$m")" + else + response="$(_get "$ep")" + fi + _ret="$?" + _debug2 "response $response" + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug "http response code $_code" + if [ "$_code" = "401" ]; then + # we have an invalid API token, maybe it is expired? + _err "Access denied. Invalid API token." + return 1 + fi + + if [ "$_ret" != "0" ] || [ -z "$_code" ] || [ "$_code" = "400" ] || _contains "$response" "DNS records not managed by mijn.host"; then #Sometimes API errors out + _request_retry_times="$(_math "$_request_retry_times" + 1)" + _info "REST call error $_code retrying $ep in ${_retry_sleep}s" + _sleep "$_retry_sleep" + _retry_sleep="$(_math "$_retry_sleep" \* 2)" + continue + fi + break + done + if [ "$_request_retry_times" = "$MAX_REQUEST_RETRY_TIMES" ]; then + _err "Error mijn.host API call was retried $MAX_REQUEST_RETRY_TIMES times." + _err "Calling $ep failed." + return 1 + fi + response="$(echo "$response" | _normalizeJson)" + return 0 +} diff --git a/dnsapi/dns_myapi.sh b/dnsapi/dns_myapi.sh index c9f5eb9f..101854d5 100755 --- a/dnsapi/dns_myapi.sh +++ b/dnsapi/dns_myapi.sh @@ -1,12 +1,14 @@ #!/usr/bin/env sh # shellcheck disable=SC2034 dns_myapi_info='Custom API Example - A sample custom DNS API script. -Domains: example.com + A sample custom DNS API script description. +Domains: example.com example.net Site: github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide -Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_duckdns +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_myapi Options: - MYAPI_Token API Token. Get API Token from https://example.com/api/. Optional. + MYAPI_Token API Token. Get API Token from https://example.com/api/ + MYAPI_Variable2 Option 2. Default "default value". + MYAPI_Variable2 Option 3. Optional. Issues: github.com/acmesh-official/acme.sh Author: Neil Pang ' diff --git a/dnsapi/dns_mydnsjp.sh b/dnsapi/dns_mydnsjp.sh index 336c4889..4dfffaaa 100755 --- a/dnsapi/dns_mydnsjp.sh +++ b/dnsapi/dns_mydnsjp.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_mydnsjp Options: MYDNSJP_MasterID Master ID MYDNSJP_Password Password -Author: epgdatacapbon +Author: @tkmsst ' ######## Public functions ##################### diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 44549c9e..1062c849 100755 --- a/dnsapi/dns_namecom.sh +++ b/dnsapi/dns_namecom.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_namecom Options: Namecom_Username Username Namecom_Token API Token -Author: RaidenII +Author: @RaidenII ' ######## Public functions ##################### diff --git a/dnsapi/dns_namesilo.sh b/dnsapi/dns_namesilo.sh index b31e32a1..5d47a59a 100755 --- a/dnsapi/dns_namesilo.sh +++ b/dnsapi/dns_namesilo.sh @@ -5,7 +5,7 @@ Site: NameSilo.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_namesilo Options: Namesilo_Key API Key -Author: meowthink +Author: @meowthink ' #Utilize API to finish dns-01 verifications. diff --git a/dnsapi/dns_nanelo.sh b/dnsapi/dns_nanelo.sh index 1ab47a89..0c42989b 100644 --- a/dnsapi/dns_nanelo.sh +++ b/dnsapi/dns_nanelo.sh @@ -27,8 +27,16 @@ dns_nanelo_add() { fi _saveaccountconf_mutable NANELO_TOKEN "$NANELO_TOKEN" + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _info "Adding TXT record to ${fulldomain}" - response="$(_get "$NANELO_API$NANELO_TOKEN/dns/addrecord?type=TXT&ttl=60&name=${fulldomain}&value=${txtvalue}")" + response="$(_post "" "$NANELO_API$NANELO_TOKEN/dns/addrecord?domain=${_domain}&type=TXT&ttl=60&name=${_sub_domain}&value=${txtvalue}" "" "" "")" if _contains "${response}" 'success'; then return 0 fi @@ -51,8 +59,16 @@ dns_nanelo_rm() { fi _saveaccountconf_mutable NANELO_TOKEN "$NANELO_TOKEN" + _debug "First, let's detect the root zone:" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _info "Deleting resource record $fulldomain" - response="$(_get "$NANELO_API$NANELO_TOKEN/dns/deleterecord?type=TXT&ttl=60&name=${fulldomain}&value=${txtvalue}")" + response="$(_post "" "$NANELO_API$NANELO_TOKEN/dns/deleterecord?domain=${_domain}&type=TXT&ttl=60&name=${_sub_domain}&value=${txtvalue}" "" "" "")" if _contains "${response}" 'success'; then return 0 fi @@ -60,3 +76,45 @@ dns_nanelo_rm() { _err "${response}" return 1 } + +#################### Private functions below ################################## +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com + +_get_root() { + fulldomain=$1 + + # Fetch all zones from Nanelo + response="$(_get "$NANELO_API$NANELO_TOKEN/dns/getzones")" || return 1 + + # Extract "zones" array into space-separated list + zones=$(echo "$response" | + tr -d ' \n' | + sed -n 's/.*"zones":\[\([^]]*\)\].*/\1/p' | + tr -d '"' | + tr , ' ') + _debug zones "$zones" + + bestzone="" + for z in $zones; do + case "$fulldomain" in + *."$z" | "$z") + if [ ${#z} -gt ${#bestzone} ]; then + bestzone=$z + fi + ;; + esac + done + + if [ -z "$bestzone" ]; then + _err "No matching zone found for $fulldomain" + return 1 + fi + + _domain="$bestzone" + _sub_domain=$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//") + + return 0 +} diff --git a/dnsapi/dns_netcup.sh b/dnsapi/dns_netcup.sh index 687b99bc..8609adf6 100644 --- a/dnsapi/dns_netcup.sh +++ b/dnsapi/dns_netcup.sh @@ -19,7 +19,7 @@ client="" dns_netcup_add() { _debug NC_Apikey "$NC_Apikey" - login + _login if [ "$NC_Apikey" = "" ] || [ "$NC_Apipw" = "" ] || [ "$NC_CID" = "" ]; then _err "No Credentials given" return 1 @@ -61,7 +61,7 @@ dns_netcup_add() { } dns_netcup_rm() { - login + _login fulldomain=$1 txtvalue=$2 @@ -125,7 +125,7 @@ dns_netcup_rm() { logout } -login() { +_login() { tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4) _debug "$tmp" diff --git a/dnsapi/dns_omglol.sh b/dnsapi/dns_omglol.sh index 5c137c3f..fd38d046 100644 --- a/dnsapi/dns_omglol.sh +++ b/dnsapi/dns_omglol.sh @@ -4,8 +4,8 @@ dns_omglol_info='omg.lol Site: omg.lol Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_omglol Options: - OMG_ApiKey API Key from omg.lol. This is accessible from the bottom of the account page at https://home.omg.lol/account - OMG_Address This is your omg.lol address, without the preceding @ - you can see your list on your dashboard at https://home.omg.lol/dashboard + OMG_ApiKey - API Key. This is accessible from the bottom of the account page at https://home.omg.lol/account + OMG_Address - Address. This is your omg.lol address, without the preceding @ - you can see your list on your dashboard at https://home.omg.lol/dashboard Issues: github.com/acmesh-official/acme.sh/issues/5299 Author: @Kholin ' @@ -35,7 +35,7 @@ dns_omglol_add() { _debug "omg.lol Address" "$OMG_Address" omg_validate "$OMG_ApiKey" "$OMG_Address" "$fulldomain" - if [ ! $? ]; then + if [ 1 = $? ]; then return 1 fi @@ -67,7 +67,7 @@ dns_omglol_rm() { _debug "omg.lol Address" "$OMG_Address" omg_validate "$OMG_ApiKey" "$OMG_Address" "$fulldomain" - if [ ! $? ]; then + if [ 1 = $? ]; then return 1 fi @@ -100,18 +100,49 @@ omg_validate() { fi _endswith "$fulldomain" "omg.lol" - if [ ! $? ]; then + if [ 1 = $? ]; then _err "Domain name requested is not under omg.lol" return 1 fi _endswith "$fulldomain" "$omg_address.omg.lol" - if [ ! $? ]; then + if [ 1 = $? ]; then _err "Domain name is not a subdomain of provided omg.lol address $omg_address" return 1 fi - _debug "Required environment parameters are all present" + omg_testconnect "$omg_apikey" "$omg_address" + if [ 1 = $? ]; then + _err "Authentication to omg.lol for address $omg_address using provided API key failed" + return 1 + fi + + _debug "Required environment parameters are all present and validated" +} + +# Validate that the address and API key are both correct and associated to each other +omg_testconnect() { + omg_apikey=$1 + omg_address=$2 + + _debug2 "Function" "omg_testconnect" + _secure_debug2 "omg.lol API key" "$omg_apikey" + _debug2 "omg.lol Address" "$omg_address" + + authheader="$(_createAuthHeader "$omg_apikey")" + export _H1="$authheader" + endpoint="https://api.omg.lol/address/$omg_address/info" + _debug2 "Endpoint for validation" "$endpoint" + + response=$(_get "$endpoint" "" 30) + + _jsonResponseCheck "$response" "status_code" 200 + if [ 1 = $? ]; then + _debug2 "Failed to query omg.lol for $omg_address with provided API key" + _secure_debug2 "API Key" "omg_apikey" + _secure_debug3 "Raw response" "$response" + return 1 + fi } # Add (or modify) an entry for a new ACME query diff --git a/dnsapi/dns_openprovider.sh b/dnsapi/dns_openprovider.sh index b584fad2..2dec9934 100755 --- a/dnsapi/dns_openprovider.sh +++ b/dnsapi/dns_openprovider.sh @@ -2,6 +2,7 @@ # shellcheck disable=SC2034 dns_openprovider_info='OpenProvider.eu Site: OpenProvider.eu +Domains: OpenProvider.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_openprovider Options: OPENPROVIDER_USER Username diff --git a/dnsapi/dns_openprovider_rest.sh b/dnsapi/dns_openprovider_rest.sh new file mode 100644 index 00000000..210dc6fc --- /dev/null +++ b/dnsapi/dns_openprovider_rest.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_openprovider_rest_info='OpenProvider (REST) +Domains: OpenProvider.com +Site: OpenProvider.eu +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_openprovider_rest +Options: + OPENPROVIDER_REST_USERNAME Openprovider Account Username + OPENPROVIDER_REST_PASSWORD Openprovider Account Password +Issues: github.com/acmesh-official/acme.sh/issues/6122 +Author: Lambiek12 +' + +OPENPROVIDER_API_URL="https://api.openprovider.eu/v1beta" + +######## Public functions ##################### + +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to add txt record +dns_openprovider_rest_add() { + fulldomain=$1 + txtvalue=$2 + + _openprovider_prepare_credentials || return 1 + + _debug "Try fetch OpenProvider DNS zone details" + if ! _get_dns_zone "$fulldomain"; then + _err "DNS zone not found within configured OpenProvider account." + return 1 + fi + + if [ -n "$_domain_id" ]; then + addzonerecordrequestparameters="dns/zones/$_domain_name" + addzonerecordrequestbody="{\"id\":$_domain_id,\"name\":\"$_domain_name\",\"records\":{\"add\":[{\"name\":\"$_sub_domain\",\"ttl\":900,\"type\":\"TXT\",\"value\":\"$txtvalue\"}]}}" + + if _openprovider_rest PUT "$addzonerecordrequestparameters" "$addzonerecordrequestbody"; then + if _contains "$response" "\"success\":true"; then + return 0 + elif _contains "$response" "\"Duplicate record\""; then + _debug "Record already existed" + return 0 + else + _err "Adding TXT record failed due to errors." + return 1 + fi + fi + fi + + _err "Adding TXT record failed due to errors." + return 1 +} + +# Usage: rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to remove the txt record after validation +dns_openprovider_rest_rm() { + fulldomain=$1 + txtvalue=$2 + + _openprovider_prepare_credentials || return 1 + + _debug "Try fetch OpenProvider DNS zone details" + if ! _get_dns_zone "$fulldomain"; then + _err "DNS zone not found within configured OpenProvider account." + return 1 + fi + + if [ -n "$_domain_id" ]; then + removezonerecordrequestparameters="dns/zones/$_domain_name" + removezonerecordrequestbody="{\"id\":$_domain_id,\"name\":\"$_domain_name\",\"records\":{\"remove\":[{\"name\":\"$_sub_domain\",\"ttl\":900,\"type\":\"TXT\",\"value\":\"\\\"$txtvalue\\\"\"}]}}" + + if _openprovider_rest PUT "$removezonerecordrequestparameters" "$removezonerecordrequestbody"; then + if _contains "$response" "\"success\":true"; then + return 0 + else + _err "Removing TXT record failed due to errors." + return 1 + fi + fi + fi + + _err "Removing TXT record failed due to errors." + return 1 +} + +#################### OpenProvider API common functions #################### +_openprovider_prepare_credentials() { + OPENPROVIDER_REST_USERNAME="${OPENPROVIDER_REST_USERNAME:-$(_readaccountconf_mutable OPENPROVIDER_REST_USERNAME)}" + OPENPROVIDER_REST_PASSWORD="${OPENPROVIDER_REST_PASSWORD:-$(_readaccountconf_mutable OPENPROVIDER_REST_PASSWORD)}" + + if [ -z "$OPENPROVIDER_REST_USERNAME" ] || [ -z "$OPENPROVIDER_REST_PASSWORD" ]; then + OPENPROVIDER_REST_USERNAME="" + OPENPROVIDER_REST_PASSWORD="" + _err "You didn't specify the Openprovider username or password yet." + return 1 + fi + + #save the credentials to the account conf file. + _saveaccountconf_mutable OPENPROVIDER_REST_USERNAME "$OPENPROVIDER_REST_USERNAME" + _saveaccountconf_mutable OPENPROVIDER_REST_PASSWORD "$OPENPROVIDER_REST_PASSWORD" +} + +_openprovider_rest() { + httpmethod=$1 + queryparameters=$2 + requestbody=$3 + + _openprovider_rest_login + if [ -z "$openproviderauthtoken" ]; then + _err "Unable to fetch authentication token from Openprovider API." + return 1 + fi + + export _H1="Content-Type: application/json" + export _H2="Accept: application/json" + export _H3="Authorization: Bearer $openproviderauthtoken" + + if [ "$httpmethod" != "GET" ]; then + response="$(_post "$requestbody" "$OPENPROVIDER_API_URL/$queryparameters" "" "$httpmethod")" + else + response="$(_get "$OPENPROVIDER_API_URL/$queryparameters")" + fi + + if [ "$?" != "0" ]; then + _err "No valid parameters supplied for Openprovider API: Error $queryparameters" + return 1 + fi + + _debug2 response "$response" + + return 0 +} + +_openprovider_rest_login() { + export _H1="Content-Type: application/json" + export _H2="Accept: application/json" + + loginrequesturl="$OPENPROVIDER_API_URL/auth/login" + loginrequestbody="{\"ip\":\"0.0.0.0\",\"password\":\"$OPENPROVIDER_REST_PASSWORD\",\"username\":\"$OPENPROVIDER_REST_USERNAME\"}" + loginresponse="$(_post "$loginrequestbody" "$loginrequesturl" "" "POST")" + + openproviderauthtoken="$(printf "%s\n" "$loginresponse" | _egrep_o '"token" *: *"[^"]*' | _head_n 1 | sed 's#^"token" *: *"##')" + + export openproviderauthtoken +} + +#################### Private functions ################################## + +# Usage: _get_dns_zone _acme-challenge.www.domain.com +# Returns: +# _domain_id=123456789 +# _domain_name=domain.com +# _sub_domain=_acme-challenge.www +_get_dns_zone() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + # Empty value not allowed + return 1 + fi + + if ! _openprovider_rest GET "dns/zones/$h" ""; then + return 1 + fi + + if _contains "$response" "\"name\":\"$h\""; then + _domain_id="$(printf "%s\n" "$response" | _egrep_o '"id" *: *[^,]*' | _head_n 1 | sed 's#^"id" *: *##')" + _debug _domain_id "$_domain_id" + + _domain_name="$h" + _debug _domain_name "$_domain_name" + + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _debug _sub_domain "$_sub_domain" + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done + + return 1 +} diff --git a/dnsapi/dns_opnsense.sh b/dnsapi/dns_opnsense.sh index d1e9c0ac..a11cfae5 100755 --- a/dnsapi/dns_opnsense.sh +++ b/dnsapi/dns_opnsense.sh @@ -110,15 +110,16 @@ rm_record() { if _existingchallenge "$_domain" "$_host" "$new_challenge"; then # Delete if _opns_rest "POST" "/record/delRecord/${_uuid}" "\{\}"; then - if echo "$_return_str" | _egrep_o "\"result\":\"deleted\"" >/dev/null; then - _opns_rest "POST" "/service/reconfigure" "{}" + if echo "$response" | _egrep_o "\"result\":\"deleted\"" >/dev/null; then _debug "Record deleted" + _opns_rest "POST" "/service/reconfigure" "{}" + _debug "Service reconfigured" else _err "Error deleting record $_host from domain $fulldomain" return 1 fi else - _err "Error deleting record $_host from domain $fulldomain" + _err "Error requesting deletion of record $_host from domain $fulldomain" return 1 fi else @@ -150,14 +151,17 @@ _get_root() { return 1 fi _debug h "$h" - id=$(echo "$_domain_response" | _egrep_o "\"uuid\":\"[a-z0-9\-]*\",\"enabled\":\"1\",\"type\":\"primary\",\"domainname\":\"${h}\"" | cut -d ':' -f 2 | cut -d '"' -f 2) - if [ -n "$id" ]; then - _debug id "$id" - _host=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain="${h}" - _domainid="${id}" - return 0 - fi + lines=$(echo "$_domain_response" | sed 's/{/\n/g') + for line in $lines; do + id=$(echo "$line" | _egrep_o "\"uuid\":\"[a-z0-9\-]*\",\"enabled\":\"1\",\"type\":\"primary\",.*\"domainname\":\"${h}\"" | cut -d ':' -f 2 | cut -d '"' -f 2) + if [ -n "$id" ]; then + _debug id "$id" + _host=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain="${h}" + _domainid="${id}" + return 0 + fi + done p=$i i=$(_math "$i" + 1) done @@ -206,13 +210,13 @@ _existingchallenge() { return 1 fi _uuid="" - _uuid=$(echo "$_record_response" | _egrep_o "\"uuid\":\"[^\"]*\",\"enabled\":\"[01]\",\"domain\":\"$1\",\"name\":\"$2\",\"type\":\"TXT\",\"value\":\"$3\"" | cut -d ':' -f 2 | cut -d '"' -f 2) + _uuid=$(echo "$_record_response" | _egrep_o "\"uuid\":\"[a-z0-9\-]*\",\"enabled\":\"[01]\",\"domain\":\"[a-z0-9\-]*\",\"%domain\":\"$1\",\"name\":\"$2\",\"type\":\"TXT\",\"value\":\"$3\"" | cut -d ':' -f 2 | cut -d '"' -f 2) if [ -n "$_uuid" ]; then _debug uuid "$_uuid" return 0 fi - _debug "${2}.$1{1} record not found" + _debug "${2}.${1} record not found" return 1 } diff --git a/dnsapi/dns_ovh.sh b/dnsapi/dns_ovh.sh index 24ad0904..9f2cd23f 100755 --- a/dnsapi/dns_ovh.sh +++ b/dnsapi/dns_ovh.sh @@ -201,7 +201,7 @@ dns_ovh_rm() { if ! _ovh_rest GET "domain/zone/$_domain/record/$rid"; then return 1 fi - if _contains "$response" "\"target\":\"$txtvalue\""; then + if _contains "$response" "$txtvalue"; then _debug "Found txt id:$rid" if ! _ovh_rest DELETE "domain/zone/$_domain/record/$rid"; then return 1 diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh index 2478e19f..ec19ad25 100755 --- a/dnsapi/dns_pdns.sh +++ b/dnsapi/dns_pdns.sh @@ -7,7 +7,7 @@ Options: PDNS_Url API URL. E.g. "http://ns.example.com:8081" PDNS_ServerId Server ID. E.g. "localhost" PDNS_Token API Token - PDNS_Ttl=60 Domain TTL. Default: "60". + PDNS_Ttl Domain TTL. Default: "60". ' DEFAULT_PDNS_TTL=60 diff --git a/dnsapi/dns_pleskxml.sh b/dnsapi/dns_pleskxml.sh index 6b38abcb..465bcc60 100644 --- a/dnsapi/dns_pleskxml.sh +++ b/dnsapi/dns_pleskxml.sh @@ -8,7 +8,7 @@ Options: pleskxml_user Username pleskxml_pass Password Issues: github.com/acmesh-official/acme.sh/issues/2577 -Author: Stilez, +Author: @Stilez, @romanlum ' ## Plesk XML API described at: diff --git a/dnsapi/dns_qc.sh b/dnsapi/dns_qc.sh new file mode 100755 index 00000000..78756a35 --- /dev/null +++ b/dnsapi/dns_qc.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_qc_info='QUIC.cloud +Site: quic.cloud +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_qc +Options: + QC_API_KEY QC API Key + QC_API_EMAIL Your account email +' + +QC_Api="https://api.quic.cloud/v2" + +######## Public functions ##################### + +#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_qc_add() { + fulldomain=$1 + txtvalue=$2 + + _debug "Enter dns_qc_add fulldomain: $fulldomain, txtvalue: $txtvalue" + QC_API_KEY="${QC_API_KEY:-$(_readaccountconf_mutable QC_API_KEY)}" + QC_API_EMAIL="${QC_API_EMAIL:-$(_readaccountconf_mutable QC_API_EMAIL)}" + + if [ "$QC_API_KEY" ]; then + _saveaccountconf_mutable QC_API_KEY "$QC_API_KEY" + else + _err "You didn't specify a QUIC.cloud api key as QC_API_KEY." + _err "You can get yours from here https://my.quic.cloud/up/api." + return 1 + fi + + if ! _contains "$QC_API_EMAIL" "@"; then + _err "It seems that the QC_API_EMAIL=$QC_API_EMAIL is not a valid email address." + _err "Please check and retry." + return 1 + fi + #save the api key and email to the account conf file. + _saveaccountconf_mutable QC_API_EMAIL "$QC_API_EMAIL" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain during add" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _debug "Getting txt records" + _qc_rest GET "zones/${_domain_id}/records" + + if ! echo "$response" | tr -d " " | grep \"success\":true >/dev/null; then + _err "Error failed response from QC GET: $response" + return 1 + fi + + # For wildcard cert, the main root domain and the wildcard domain have the same txt subdomain name, so + # we can not use updating anymore. + # count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2) + # _debug count "$count" + # if [ "$count" = "0" ]; then + _info "Adding txt record" + if _qc_rest POST "zones/$_domain_id/records" "{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"content\":\"$txtvalue\",\"ttl\":1800}"; then + if _contains "$response" "$txtvalue"; then + _info "Added txt record, OK" + return 0 + elif _contains "$response" "Same record already exists"; then + _info "txt record already exists, OK" + return 0 + else + _err "Add txt record error: $response" + return 1 + fi + fi + _err "Add txt record error: POST failed: $response" + return 1 + +} + +#fulldomain txtvalue +dns_qc_rm() { + fulldomain=$1 + txtvalue=$2 + + _debug "Enter dns_qc_rm fulldomain: $fulldomain, txtvalue: $txtvalue" + QC_API_KEY="${QC_API_KEY:-$(_readaccountconf_mutable QC_API_KEY)}" + QC_API_EMAIL="${QC_API_EMAIL:-$(_readaccountconf_mutable QC_API_EMAIL)}" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain during rm" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _debug "Getting txt records" + _qc_rest GET "zones/${_domain_id}/records" + + if ! echo "$response" | tr -d " " | grep \"success\":true >/dev/null; then + _err "Error rm GET response: $response" + return 1 + fi + + _debug "Pre-jq response:" "$response" + # Do not use jq or subsequent code + #response=$(echo "$response" | jq ".result[] | select(.id) | select(.content == \"$txtvalue\") | select(.type == \"TXT\")") + #_debug "get txt response" "$response" + #if [ "${response}" = "" ]; then + # _info "Don't need to remove txt records." + # return 0 + #fi + #record_id=$(echo "$response" | grep \"id\" | awk -F ' ' '{print $2}' | sed 's/,$//') + #_debug "txt record_id" "$record_id" + #Instead of jq + array=$(echo "$response" | grep -o '\[[^]]*\]' | sed 's/^\[\(.*\)\]$/\1/') + if [ -z "$array" ]; then + _err "Expected array in QC response: $response" + return 1 + fi + # Temporary file to hold matched content (one per line) + tmpfile=$(_mktemp) + echo "$array" | grep -o '{[^}]*}' | sed 's/^{//;s/}$//' >"$tmpfile" + record_id="" + + while IFS= read -r obj || [ -n "$obj" ]; do + if echo "$obj" | grep -q '"TXT"' && echo "$obj" | grep -q '"id"' && echo "$obj" | grep -q "$txtvalue"; then + _debug "response includes" "$obj" + record_id=$(echo "$obj" | sed 's/^\"id\":\([0-9]\+\).*/\1/') + break + fi + done <"$tmpfile" + + rm "$tmpfile" + + if [ -z "$record_id" ]; then + _info "TXT record, or $txtvalue not found, nothing to remove" + return 0 + fi + + #End of jq replacement + if ! _qc_rest DELETE "zones/$_domain_id/records/$record_id"; then + _info "Delete txt record error." + return 1 + fi + + _info "TXT Record ID: $record_id successfully deleted" + return 0 + +} + +#################### Private functions below ################################## +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +# _domain_id=sdjkglgdfewsdfg +_get_root() { + domain=$1 + i=1 + p=1 + + h=$(printf "%s" "$domain" | cut -d . -f2-) + _debug h "$h" + if [ -z "$h" ]; then + _err "$h ($domain) is an invalid domain" + return 1 + fi + + if ! _qc_rest GET "zones"; then + _err "qc_rest failed" + return 1 + fi + + if _contains "$response" "\"name\":\"$h\"" || _contains "$response" "\"name\":\"$h.\""; then + _domain_id=$h + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + _err "Empty domain_id $h" + return 1 + fi + _err "Missing domain_id $h" + return 1 +} + +_qc_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + email_trimmed=$(echo "$QC_API_EMAIL" | tr -d '"') + token_trimmed=$(echo "$QC_API_KEY" | tr -d '"') + + export _H1="Content-Type: application/json" + export _H2="X-Auth-Email: $email_trimmed" + export _H3="X-Auth-Key: $token_trimmed" + + if [ "$m" != "GET" ]; then + _debug data "$data" + response="$(_post "$data" "$QC_Api/$ep" "" "$m")" + else + response="$(_get "$QC_Api/$ep")" + fi + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_rage4.sh b/dnsapi/dns_rage4.sh index ad312759..c27fbc5f 100755 --- a/dnsapi/dns_rage4.sh +++ b/dnsapi/dns_rage4.sh @@ -42,6 +42,14 @@ dns_rage4_add() { _debug _domain_id "$_domain_id" _rage4_rest "createrecord/?id=$_domain_id&name=$fulldomain&content=$unquotedtxtvalue&type=TXT&active=true&ttl=1" + + # Response after adding a TXT record should be something like this: + # {"status":true,"id":28160443,"error":null} + if ! _contains "$response" '"error":null' >/dev/null; then + _err "Error while adding TXT record: '$response'" + return 1 + fi + return 0 } @@ -63,7 +71,12 @@ dns_rage4_rm() { _debug "Getting txt records" _rage4_rest "getrecords/?id=${_domain_id}" - _record_id=$(echo "$response" | sed -rn 's/.*"id":([[:digit:]]+)[^\}]*'"$txtvalue"'.*/\1/p') + _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p') + if [ -z "$_record_id" ]; then + _err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'." + return 1 + fi + _rage4_rest "deleterecord/?id=${_record_id}" return 0 } @@ -105,8 +118,7 @@ _rage4_rest() { token_trimmed=$(echo "$RAGE4_TOKEN" | tr -d '"') auth=$(printf '%s:%s' "$username_trimmed" "$token_trimmed" | _base64) - export _H1="Content-Type: application/json" - export _H2="Authorization: Basic $auth" + export _H1="Authorization: Basic $auth" response="$(_get "$RAGE4_Api$ep")" diff --git a/dnsapi/dns_schlundtech.sh b/dnsapi/dns_schlundtech.sh index 6d2930a2..21930110 100644 --- a/dnsapi/dns_schlundtech.sh +++ b/dnsapi/dns_schlundtech.sh @@ -7,7 +7,7 @@ Options: SCHLUNDTECH_USER Username SCHLUNDTECH_PASSWORD Password Issues: github.com/acmesh-official/acme.sh/issues/2246 -Author: +Author: @mod242 ' SCHLUNDTECH_API="https://gateway.schlundtech.de" diff --git a/dnsapi/dns_selectel.sh b/dnsapi/dns_selectel.sh index 8b52b24e..565f541b 100644 --- a/dnsapi/dns_selectel.sh +++ b/dnsapi/dns_selectel.sh @@ -4,11 +4,22 @@ dns_selectel_info='Selectel.com Domains: Selectel.ru Site: Selectel.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_selectel -Options: - SL_Key API Key +Options: For old API version v1 (deprecated) + SL_Ver API version. Use "v1". + SL_Key API Key +OptionsAlt: For the current API version v2 + SL_Ver API version. Use "v2". + SL_Login_ID Account ID + SL_Project_Name Project name + SL_Login_Name Service user name + SL_Pswd Service user password + SL_Expire Token lifetime. In minutes (0-1440). Default "1400" +Issues: github.com/acmesh-official/acme.sh/issues/5126 ' -SL_Api="https://api.selectel.ru/domains/v1" +SL_Api="https://api.selectel.ru/domains" +auth_uri="https://cloud.api.selcloud.ru/identity/v3/auth/tokens" +_sl_sep='#' ######## Public functions ##################### @@ -17,17 +28,14 @@ dns_selectel_add() { fulldomain=$1 txtvalue=$2 - SL_Key="${SL_Key:-$(_readaccountconf_mutable SL_Key)}" - - if [ -z "$SL_Key" ]; then - SL_Key="" - _err "You don't specify selectel.ru api key yet." - _err "Please create you key and try again." + if ! _sl_init_vars; then return 1 fi - - #save the api key to the account conf file. - _saveaccountconf_mutable SL_Key "$SL_Key" + _debug2 SL_Ver "$SL_Ver" + _debug2 SL_Expire "$SL_Expire" + _debug2 SL_Login_Name "$SL_Login_Name" + _debug2 SL_Login_ID "$SL_Login_ID" + _debug2 SL_Project_Name "$SL_Project_Name" _debug "First detect the root zone" if ! _get_root "$fulldomain"; then @@ -39,11 +47,63 @@ dns_selectel_add() { _debug _domain "$_domain" _info "Adding record" - if _sl_rest POST "/$_domain_id/records/" "{\"type\": \"TXT\", \"ttl\": 60, \"name\": \"$fulldomain\", \"content\": \"$txtvalue\"}"; then - if _contains "$response" "$txtvalue" || _contains "$response" "record_already_exists"; then + if [ "$SL_Ver" = "v2" ]; then + _ext_srv1="/zones/" + _ext_srv2="/rrset/" + _text_tmp=$(echo "$txtvalue" | sed -En "s/[\"]*([^\"]*)/\1/p") + _text_tmp='\"'$_text_tmp'\"' + _data="{\"type\": \"TXT\", \"ttl\": 60, \"name\": \"${fulldomain}.\", \"records\": [{\"content\":\"$_text_tmp\"}]}" + elif [ "$SL_Ver" = "v1" ]; then + _ext_srv1="/" + _ext_srv2="/records/" + _data="{\"type\":\"TXT\",\"ttl\":60,\"name\":\"$fulldomain\",\"content\":\"$txtvalue\"}" + else + _err "Error. Unsupported version API $SL_Ver" + return 1 + fi + _ext_uri="${_ext_srv1}$_domain_id${_ext_srv2}" + _debug _ext_uri "$_ext_uri" + _debug _data "$_data" + + if _sl_rest POST "$_ext_uri" "$_data"; then + if _contains "$response" "$txtvalue"; then _info "Added, OK" return 0 fi + if _contains "$response" "already_exists"; then + # record TXT with $fulldomain already exists + if [ "$SL_Ver" = "v2" ]; then + # It is necessary to add one more content to the comments + # read all records rrset + _debug "Getting txt records" + _sl_rest GET "${_ext_uri}" + # There is already a $txtvalue value, no need to add it + if _contains "$response" "$txtvalue"; then + _info "Added, OK" + _info "Txt record ${fulldomain} with value ${txtvalue} already exists" + return 0 + fi + # group \1 - full record rrset; group \2 - records attribute value, exactly {"content":"\"value1\""},{"content":"\"value2\""}",... + _record_seg="$(echo "$response" | sed -En "s/.*(\{\"id\"[^}]*${fulldomain}[^}]*records[^}]*\[(\{[^]]*\})\][^}]*}).*/\1/p")" + _record_array="$(echo "$response" | sed -En "s/.*(\{\"id\"[^}]*${fulldomain}[^}]*records[^}]*\[(\{[^]]*\})\][^}]*}).*/\2/p")" + # record id + _record_id="$(echo "$_record_seg" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\"" | cut -d : -f 2 | tr -d "\"")" + # preparing _data + _tmp_str="${_record_array},{\"content\":\"${_text_tmp}\"}" + _data="{\"ttl\": 60, \"records\": [${_tmp_str}]}" + _debug2 _record_seg "$_record_seg" + _debug2 _record_array "$_record_array" + _debug2 _record_array "$_record_id" + _debug "New data for record" "$_data" + if _sl_rest PATCH "${_ext_uri}${_record_id}" "$_data"; then + _info "Added, OK" + return 0 + fi + elif [ "$SL_Ver" = "v1" ]; then + _info "Added, OK" + return 0 + fi + fi fi _err "Add txt record error." return 1 @@ -54,15 +114,15 @@ dns_selectel_rm() { fulldomain=$1 txtvalue=$2 - SL_Key="${SL_Key:-$(_readaccountconf_mutable SL_Key)}" - - if [ -z "$SL_Key" ]; then - SL_Key="" - _err "You don't specify slectel api key yet." - _err "Please create you key and try again." + if ! _sl_init_vars "nosave"; then return 1 fi - + _debug2 SL_Ver "$SL_Ver" + _debug2 SL_Expire "$SL_Expire" + _debug2 SL_Login_Name "$SL_Login_Name" + _debug2 SL_Login_ID "$SL_Login_ID" + _debug2 SL_Project_Name "$SL_Project_Name" + # _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -71,91 +131,195 @@ dns_selectel_rm() { _debug _domain_id "$_domain_id" _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - + # + if [ "$SL_Ver" = "v2" ]; then + _ext_srv1="/zones/" + _ext_srv2="/rrset/" + elif [ "$SL_Ver" = "v1" ]; then + _ext_srv1="/" + _ext_srv2="/records/" + else + _err "Error. Unsupported version API $SL_Ver" + return 1 + fi + # _debug "Getting txt records" - _sl_rest GET "/${_domain_id}/records/" - + _ext_uri="${_ext_srv1}$_domain_id${_ext_srv2}" + _debug _ext_uri "$_ext_uri" + _sl_rest GET "${_ext_uri}" + # if ! _contains "$response" "$txtvalue"; then _err "Txt record not found" return 1 fi - - _record_seg="$(echo "$response" | _egrep_o "[^{]*\"content\" *: *\"$txtvalue\"[^}]*}")" + # + if [ "$SL_Ver" = "v2" ]; then + _record_seg="$(echo "$response" | sed -En "s/.*(\{\"id\"[^}]*records[^[]*(\[(\{[^]]*${txtvalue}[^]]*)\])[^}]*}).*/\1/gp")" + _record_arr="$(echo "$response" | sed -En "s/.*(\{\"id\"[^}]*records[^[]*(\[(\{[^]]*${txtvalue}[^]]*)\])[^}]*}).*/\3/p")" + elif [ "$SL_Ver" = "v1" ]; then + _record_seg="$(echo "$response" | _egrep_o "[^{]*\"content\" *: *\"$txtvalue\"[^}]*}")" + else + _err "Error. Unsupported version API $SL_Ver" + return 1 + fi _debug2 "_record_seg" "$_record_seg" if [ -z "$_record_seg" ]; then _err "can not find _record_seg" return 1 fi - - _record_id="$(echo "$_record_seg" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\"" | cut -d : -f 2)" - _debug2 "_record_id" "$_record_id" + # record id + # the following lines change the algorithm for deleting records with the value $txtvalue + # if you use the 1st line, then all such records are deleted at once + # if you use the 2nd line, then only the first entry from them is deleted + #_record_id="$(echo "$_record_seg" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\"" | cut -d : -f 2 | tr -d "\"")" + _record_id="$(echo "$_record_seg" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\"" | cut -d : -f 2 | tr -d "\"" | sed '1!d')" if [ -z "$_record_id" ]; then _err "can not find _record_id" return 1 fi - - if ! _sl_rest DELETE "/$_domain_id/records/$_record_id"; then - _err "Delete record error." - return 1 + _debug2 "_record_id" "$_record_id" + # delete all record type TXT with text $txtvalue + if [ "$SL_Ver" = "v2" ]; then + # actual + _new_arr="$(echo "$_record_seg" | sed -En "s/.*(\{\"id\"[^}]*records[^[]*(\[(\{[^]]*${txtvalue}[^]]*)\])[^}]*}).*/\3/gp" | sed -En "s/(\},\{)/}\n{/gp" | sed "/${txtvalue}/d" | sed ":a;N;s/\n/,/;ta")" + # uri record for DEL or PATCH + _del_uri="${_ext_uri}${_record_id}" + _debug _del_uri "$_del_uri" + if [ -z "$_new_arr" ]; then + # remove record + if ! _sl_rest DELETE "${_del_uri}"; then + _err "Delete record error: ${_del_uri}." + else + info "Delete record success: ${_del_uri}." + fi + else + # update a record by removing one element in content + _data="{\"ttl\": 60, \"records\": [${_new_arr}]}" + _debug2 _data "$_data" + # REST API PATCH call + if _sl_rest PATCH "${_del_uri}" "$_data"; then + _info "Patched, OK: ${_del_uri}" + else + _err "Patched record error: ${_del_uri}." + fi + fi + else + # legacy + for _one_id in $_record_id; do + _del_uri="${_ext_uri}${_one_id}" + _debug _del_uri "$_del_uri" + if ! _sl_rest DELETE "${_del_uri}"; then + _err "Delete record error: ${_del_uri}." + else + info "Delete record success: ${_del_uri}." + fi + done fi return 0 } #################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=sdjkglgdfewsdfg + _get_root() { domain=$1 - if ! _sl_rest GET "/"; then - return 1 - fi - - i=2 - p=1 - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - #not valid + if [ "$SL_Ver" = 'v1' ]; then + # version API 1 + if ! _sl_rest GET "/"; then return 1 fi - - if _contains "$response" "\"name\" *: *\"$h\","; then - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain=$h - _debug "Getting domain id for $h" - if ! _sl_rest GET "/$h"; then + i=2 + p=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then return 1 fi - _domain_id="$(echo "$response" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\":" | cut -d : -f 2)" - return 0 + if _contains "$response" "\"name\" *: *\"$h\","; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + _debug "Getting domain id for $h" + if ! _sl_rest GET "/$h"; then + _err "Error read records of all domains $SL_Ver" + return 1 + fi + _domain_id="$(echo "$response" | tr "," "\n" | tr "}" "\n" | tr -d " " | grep "\"id\":" | cut -d : -f 2)" + return 0 + fi + p=$i + i=$(_math "$i" + 1) + done + _err "Error read records of all domains $SL_Ver" + return 1 + elif [ "$SL_Ver" = "v2" ]; then + # version API 2 + _ext_uri='/zones/' + domain="${domain}." + _debug "domain:: " "$domain" + # read records of all domains + if ! _sl_rest GET "$_ext_uri"; then + _err "Error read records of all domains $SL_Ver" + return 1 fi - p=$i - i=$(_math "$i" + 1) - done - return 1 + i=1 + p=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + _err "The domain was not found among the registered ones" + return 1 + fi + _domain_record=$(echo "$response" | sed -En "s/.*(\{[^}]*id[^}]*\"name\" *: *\"$h\"[^}]*}).*/\1/p") + _debug "_domain_record:: " "$_domain_record" + if [ -n "$_domain_record" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + _debug "Getting domain id for $h" + _domain_id=$(echo "$_domain_record" | sed -En "s/\{[^}]*\"id\" *: *\"([^\"]*)\"[^}]*\}/\1/p") + return 0 + fi + p=$i + i=$(_math "$i" + 1) + done + _err "Error read records of all domains $SL_Ver" + return 1 + else + _err "Error. Unsupported version API $SL_Ver" + return 1 + fi } +################################################################# +# use: method add_url body _sl_rest() { m=$1 ep="$2" data="$3" - _debug "$ep" - export _H1="X-Token: $SL_Key" + _token=$(_get_auth_token) + if [ -z "$_token" ]; then + _err "BAD key or token $ep" + return 1 + fi + if [ "$SL_Ver" = v2 ]; then + _h1_name="X-Auth-Token" + else + _h1_name='X-Token' + fi + export _H1="${_h1_name}: ${_token}" export _H2="Content-Type: application/json" - + _debug2 "Full URI: " "$SL_Api/${SL_Ver}${ep}" + _debug2 "_H1:" "$_H1" + _debug2 "_H2:" "$_H2" if [ "$m" != "GET" ]; then _debug data "$data" - response="$(_post "$data" "$SL_Api/$ep" "" "$m")" + response="$(_post "$data" "$SL_Api/${SL_Ver}${ep}" "" "$m")" else - response="$(_get "$SL_Api/$ep")" + response="$(_get "$SL_Api/${SL_Ver}${ep}")" fi - + # shellcheck disable=SC2181 if [ "$?" != "0" ]; then _err "error $ep" return 1 @@ -163,3 +327,152 @@ _sl_rest() { _debug2 response "$response" return 0 } + +_get_auth_token() { + if [ "$SL_Ver" = 'v1' ]; then + # token for v1 + _debug "Token v1" + _token_keystone=$SL_Key + elif [ "$SL_Ver" = 'v2' ]; then + # token for v2. Get a token for calling the API + _debug "Keystone Token v2" + token_v2=$(_readaccountconf_mutable SL_Token_V2) + if [ -n "$token_v2" ]; then + # The structure with the token was considered. Let's check its validity + # field 1 - SL_Login_Name + # field 2 - token keystone + # field 3 - SL_Login_ID + # field 4 - SL_Project_Name + # field 5 - Receipt time + # separator - '$_sl_sep' + _login_name=$(_getfield "$token_v2" 1 "$_sl_sep") + _token_keystone=$(_getfield "$token_v2" 2 "$_sl_sep") + _project_name=$(_getfield "$token_v2" 4 "$_sl_sep") + _receipt_time=$(_getfield "$token_v2" 5 "$_sl_sep") + _login_id=$(_getfield "$token_v2" 3 "$_sl_sep") + _debug2 _login_name "$_login_name" + _debug2 _login_id "$_login_id" + _debug2 _project_name "$_project_name" + # check the validity of the token for the user and the project and its lifetime + _dt_diff_minute=$((($(date +%s) - _receipt_time) / 60)) + _debug2 _dt_diff_minute "$_dt_diff_minute" + [ "$_dt_diff_minute" -gt "$SL_Expire" ] && unset _token_keystone + if [ "$_project_name" != "$SL_Project_Name" ] || [ "$_login_name" != "$SL_Login_Name" ] || [ "$_login_id" != "$SL_Login_ID" ]; then + unset _token_keystone + fi + _debug "Get exists token" + fi + if [ -z "$_token_keystone" ]; then + # the previous token is incorrect or was not received, get a new one + _debug "Update (get new) token" + _data_auth="{\"auth\":{\"identity\":{\"methods\":[\"password\"],\"password\":{\"user\":{\"name\":\"${SL_Login_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"},\"password\":\"${SL_Pswd}\"}}},\"scope\":{\"project\":{\"name\":\"${SL_Project_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"}}}}}" + export _H1="Content-Type: application/json" + _result=$(_post "$_data_auth" "$auth_uri") + _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | sed -nE "s/[[:space:]]*x-subject-token:[[:space:]]*([[:print:]]*)(\r*)/\1/p") + _dt_curr=$(date +%s) + SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}" + _saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2" + fi + else + # token set empty for unsupported version API + _token_keystone="" + fi + printf -- "%s" "$_token_keystone" +} + +################################################################# +# use: [non_save] +_sl_init_vars() { + _non_save="${1}" + _debug2 _non_save "$_non_save" + + _debug "First init variables" + # version API + SL_Ver="${SL_Ver:-$(_readaccountconf_mutable SL_Ver)}" + if [ -z "$SL_Ver" ]; then + SL_Ver="v1" + fi + if ! [ "$SL_Ver" = "v1" ] && ! [ "$SL_Ver" = "v2" ]; then + _err "You don't specify selectel.ru API version." + _err "Please define specify API version." + fi + _debug2 SL_Ver "$SL_Ver" + if [ "$SL_Ver" = "v1" ]; then + # token + SL_Key="${SL_Key:-$(_readaccountconf_mutable SL_Key)}" + + if [ -z "$SL_Key" ]; then + SL_Key="" + _err "You don't specify selectel.ru api key yet." + _err "Please create you key and try again." + return 1 + fi + #save the api key to the account conf file. + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Key "$SL_Key" + fi + elif [ "$SL_Ver" = "v2" ]; then + # time expire token + SL_Expire="${SL_Expire:-$(_readaccountconf_mutable SL_Expire)}" + if [ -z "$SL_Expire" ]; then + SL_Expire=1400 # 23h 20 min + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Expire "$SL_Expire" + fi + # login service user + SL_Login_Name="${SL_Login_Name:-$(_readaccountconf_mutable SL_Login_Name)}" + if [ -z "$SL_Login_Name" ]; then + SL_Login_Name='' + _err "You did not specify the selectel.ru API service user name." + _err "Please provide a service user name and try again." + return 1 + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Login_Name "$SL_Login_Name" + fi + # user ID + SL_Login_ID="${SL_Login_ID:-$(_readaccountconf_mutable SL_Login_ID)}" + if [ -z "$SL_Login_ID" ]; then + SL_Login_ID='' + _err "You did not specify the selectel.ru API user ID." + _err "Please provide a user ID and try again." + return 1 + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Login_ID "$SL_Login_ID" + fi + # project name + SL_Project_Name="${SL_Project_Name:-$(_readaccountconf_mutable SL_Project_Name)}" + if [ -z "$SL_Project_Name" ]; then + SL_Project_Name='' + _err "You did not specify the project name." + _err "Please provide a project name and try again." + return 1 + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Project_Name "$SL_Project_Name" + fi + # service user password + SL_Pswd="${SL_Pswd:-$(_readaccountconf_mutable SL_Pswd)}" + if [ -z "$SL_Pswd" ]; then + SL_Pswd='' + _err "You did not specify the service user password." + _err "Please provide a service user password and try again." + return 1 + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Pswd "$SL_Pswd" "12345678" + fi + else + SL_Ver="" + _err "You also specified the wrong version of the selectel.ru API." + _err "Please provide the correct API version and try again." + return 1 + fi + if [ -z "$_non_save" ]; then + _saveaccountconf_mutable SL_Ver "$SL_Ver" + fi + + return 0 +} diff --git a/dnsapi/dns_sotoon.sh b/dnsapi/dns_sotoon.sh new file mode 100644 index 00000000..b94a220f --- /dev/null +++ b/dnsapi/dns_sotoon.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_sotoon_info='Sotoon.ir +Site: Sotoon.ir +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_sotoon +Options: + Sotoon_Token API Token + Sotoon_WorkspaceUUID Workspace UUID +Issues: github.com/acmesh-official/acme.sh/issues/6656 +Author: Erfan Gholizade +' + +SOTOON_API_URL="https://api.sotoon.ir/delivery/v2.1/global" + +######## Public functions ##################### + +#Adding the txt record for validation. +#Usage: dns_sotoon_add fulldomain TXT_record +#Usage: dns_sotoon_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_sotoon_add() { + fulldomain=$1 + txtvalue=$2 + _info_sotoon "Using Sotoon" + + Sotoon_Token="${Sotoon_Token:-$(_readaccountconf_mutable Sotoon_Token)}" + Sotoon_WorkspaceUUID="${Sotoon_WorkspaceUUID:-$(_readaccountconf_mutable Sotoon_WorkspaceUUID)}" + + if [ -z "$Sotoon_Token" ]; then + _err_sotoon "You didn't specify \"Sotoon_Token\" token yet." + _err_sotoon "You can get yours from here https://ocean.sotoon.ir/profile/tokens" + return 1 + fi + if [ -z "$Sotoon_WorkspaceUUID" ]; then + _err_sotoon "You didn't specify \"Sotoon_WorkspaceUUID\" Workspace UUID yet." + _err_sotoon "You can get yours from here https://ocean.sotoon.ir/profile/workspaces" + return 1 + fi + + #save the info to the account conf file. + _saveaccountconf_mutable Sotoon_Token "$Sotoon_Token" + _saveaccountconf_mutable Sotoon_WorkspaceUUID "$Sotoon_WorkspaceUUID" + + _debug_sotoon "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err_sotoon "invalid domain" + return 1 + fi + + _info_sotoon "Adding record" + + _debug_sotoon _domain_id "$_domain_id" + _debug_sotoon _sub_domain "$_sub_domain" + _debug_sotoon _domain "$_domain" + + # First, GET the current domain zone to check for existing TXT records + # This is needed for wildcard certs which require multiple TXT values + _info_sotoon "Checking for existing TXT records" + if ! _sotoon_rest GET "$_domain_id"; then + _err_sotoon "Failed to get domain zone" + return 1 + fi + + # Check if there are existing TXT records for this subdomain + _existing_txt="" + if _contains "$response" "\"$_sub_domain\""; then + _debug_sotoon "Found existing records for $_sub_domain" + # Extract existing TXT values from the response + # The format is: "_acme-challenge":[{"TXT":"value1","type":"TXT","ttl":10},{"TXT":"value2",...}] + _existing_txt=$(echo "$response" | _egrep_o "\"$_sub_domain\":\[[^]]*\]" | sed "s/\"$_sub_domain\"://") + _debug_sotoon "Existing TXT records: $_existing_txt" + fi + + # Build the new record entry + _new_record="{\"TXT\":\"$txtvalue\",\"type\":\"TXT\",\"ttl\":120}" + + # If there are existing records, append to them; otherwise create new array + if [ -n "$_existing_txt" ] && [ "$_existing_txt" != "[]" ] && [ "$_existing_txt" != "null" ]; then + # Check if this exact TXT value already exists (avoid duplicates) + if _contains "$_existing_txt" "\"$txtvalue\""; then + _info_sotoon "TXT record already exists, skipping" + return 0 + fi + # Remove the closing bracket and append new record + _combined_records="$(echo "$_existing_txt" | sed 's/]$//'),$_new_record]" + _debug_sotoon "Combined records: $_combined_records" + else + # No existing records, create new array + _combined_records="[$_new_record]" + fi + + # Prepare the DNS record data in Kubernetes CRD format + _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":$_combined_records}}}" + + _debug_sotoon "DNS record payload: $_dns_record" + + # Use PATCH to update/add the record to the domain zone + _info_sotoon "Updating domain zone $_domain_id with TXT record" + if _sotoon_rest PATCH "$_domain_id" "$_dns_record"; then + if _contains "$response" "$txtvalue" || _contains "$response" "\"$_sub_domain\""; then + _info_sotoon "Added, OK" + return 0 + else + _debug_sotoon "Response: $response" + _err_sotoon "Add txt record error." + return 1 + fi + fi + + _err_sotoon "Add txt record error." + return 1 +} + +#Remove the txt record after validation. +#Usage: dns_sotoon_rm fulldomain TXT_record +#Usage: dns_sotoon_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_sotoon_rm() { + fulldomain=$1 + txtvalue=$2 + _info_sotoon "Using Sotoon" + _debug_sotoon fulldomain "$fulldomain" + _debug_sotoon txtvalue "$txtvalue" + + Sotoon_Token="${Sotoon_Token:-$(_readaccountconf_mutable Sotoon_Token)}" + Sotoon_WorkspaceUUID="${Sotoon_WorkspaceUUID:-$(_readaccountconf_mutable Sotoon_WorkspaceUUID)}" + + _debug_sotoon "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err_sotoon "invalid domain" + return 1 + fi + _debug_sotoon _domain_id "$_domain_id" + _debug_sotoon _sub_domain "$_sub_domain" + _debug_sotoon _domain "$_domain" + + _info_sotoon "Removing TXT record" + + # First, GET the current domain zone to check for existing TXT records + if ! _sotoon_rest GET "$_domain_id"; then + _err_sotoon "Failed to get domain zone" + return 1 + fi + + # Check if there are existing TXT records for this subdomain + _existing_txt="" + if _contains "$response" "\"$_sub_domain\""; then + _debug_sotoon "Found existing records for $_sub_domain" + _existing_txt=$(echo "$response" | _egrep_o "\"$_sub_domain\":\[[^]]*\]" | sed "s/\"$_sub_domain\"://") + _debug_sotoon "Existing TXT records: $_existing_txt" + fi + + # If no existing records, nothing to remove + if [ -z "$_existing_txt" ] || [ "$_existing_txt" = "[]" ] || [ "$_existing_txt" = "null" ]; then + _info_sotoon "No TXT records found, nothing to remove" + return 0 + fi + + # Remove the specific TXT value from the array + # This handles the case where there are multiple TXT values (wildcard certs) + _remaining_records=$(echo "$_existing_txt" | sed "s/{\"TXT\":\"$txtvalue\"[^}]*},*//g" | sed 's/,]/]/g' | sed 's/\[,/[/g') + _debug_sotoon "Remaining records after removal: $_remaining_records" + + # If no records remain, set to null to remove the subdomain entirely + if [ "$_remaining_records" = "[]" ] || [ -z "$_remaining_records" ]; then + _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":null}}}" + else + _dns_record="{\"spec\":{\"records\":{\"$_sub_domain\":$_remaining_records}}}" + fi + + _debug_sotoon "Remove record payload: $_dns_record" + + # Use PATCH to remove the record from the domain zone + if _sotoon_rest PATCH "$_domain_id" "$_dns_record"; then + _info_sotoon "Record removed, OK" + return 0 + else + _debug_sotoon "Response: $response" + _err_sotoon "Error removing record" + return 1 + fi +} + +#################### Private functions below ################################## + +_get_root() { + domain=$1 + i=1 + p=1 + + _debug_sotoon "Getting root domain for: $domain" + _debug_sotoon "Sotoon WorkspaceUUID: $Sotoon_WorkspaceUUID" + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug_sotoon "Checking domain part: $h" + + if [ -z "$h" ]; then + #not valid + _err_sotoon "Could not find valid domain" + return 1 + fi + + _debug_sotoon "Fetching domain zones from Sotoon API" + if ! _sotoon_rest GET ""; then + _err_sotoon "Failed to get domain zones from Sotoon API" + _err_sotoon "Please check your Sotoon_Token, Sotoon_WorkspaceUUID" + return 1 + fi + + _debug2_sotoon "API Response: $response" + + # Check if the response contains our domain + # Sotoon API uses Kubernetes CRD format with spec.origin for domain matching + if _contains "$response" "\"origin\":\"$h\""; then + _debug_sotoon "Found domain by origin: $h" + + # In Kubernetes CRD format, the metadata.name is the resource identifier + # The name can be either: + # 1. Same as origin + # 2. Origin with dots replaced by hyphens + # We check both patterns in the response to determine which one exists + + # Convert origin to hyphenated version for checking + _h_hyphenated=$(echo "$h" | tr '.' '-') + + # Check if the hyphenated name exists in the response + if _contains "$response" "\"name\":\"$_h_hyphenated\""; then + _domain_id="$_h_hyphenated" + _debug_sotoon "Found domain ID (hyphenated): $_domain_id" + # Check if the origin itself is used as name + elif _contains "$response" "\"name\":\"$h\""; then + _domain_id="$h" + _debug_sotoon "Found domain ID (same as origin): $_domain_id" + else + # Fallback: use the hyphenated version (more common) + _domain_id="$_h_hyphenated" + _debug_sotoon "Using hyphenated domain ID as fallback: $_domain_id" + fi + + if [ -n "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + _debug_sotoon "Domain ID (metadata.name): $_domain_id" + _debug_sotoon "Sub domain: $_sub_domain" + _debug_sotoon "Domain (origin): $_domain" + return 0 + fi + _err_sotoon "Found domain $h but could not extract domain ID" + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +_sotoon_rest() { + mtd="$1" + resource_id="$2" + data="$3" + + token_trimmed=$(echo "$Sotoon_Token" | tr -d '"') + + # Construct the API endpoint + _api_path="$SOTOON_API_URL/workspaces/$Sotoon_WorkspaceUUID/domainzones" + + if [ -n "$resource_id" ]; then + _api_path="$_api_path/$resource_id" + fi + + _debug_sotoon "API Path: $_api_path" + _debug_sotoon "Method: $mtd" + + # Set authorization header - Sotoon API uses Bearer token + export _H1="Authorization: Bearer $token_trimmed" + + if [ "$mtd" = "GET" ]; then + # GET request + _debug_sotoon "GET" "$_api_path" + response="$(_get "$_api_path")" + elif [ "$mtd" = "PATCH" ]; then + # PATCH Request + export _H2="Content-Type: application/merge-patch+json" + _debug_sotoon data "$data" + response="$(_post "$data" "$_api_path" "" "$mtd")" + else + _err_sotoon "Unknown method: $mtd" + return 1 + fi + + _debug2_sotoon response "$response" + return 0 +} + +#Wrappers for logging +_info_sotoon() { + _info "[Sotoon]" "$@" +} + +_err_sotoon() { + _err "[Sotoon]" "$@" +} + +_debug_sotoon() { + _debug "[Sotoon]" "$@" +} + +_debug2_sotoon() { + _debug2 "[Sotoon]" "$@" +} diff --git a/dnsapi/dns_spaceship.sh b/dnsapi/dns_spaceship.sh new file mode 100644 index 00000000..8fff4037 --- /dev/null +++ b/dnsapi/dns_spaceship.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_spaceship_info='Spaceship.com +Site: Spaceship.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_spaceship +Options: + SPACESHIP_API_KEY API Key + SPACESHIP_API_SECRET API Secret + SPACESHIP_ROOT_DOMAIN Root domain. Manually specify the root domain if auto-detection fails. Optional. +Issues: github.com/acmesh-official/acme.sh/issues/6304 +Author: Meow <@Meo597> +' + +# Spaceship API +# https://docs.spaceship.dev/ + +######## Public functions ##################### + +SPACESHIP_API_BASE="https://spaceship.dev/api/v1" + +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to add txt record +dns_spaceship_add() { + fulldomain="$1" + txtvalue="$2" + + _info "Adding TXT record for $fulldomain with value $txtvalue" + + # Initialize API credentials and headers + if ! _spaceship_init; then + return 1 + fi + + # Detect root zone + if ! _get_root "$fulldomain"; then + return 1 + fi + + # Extract subdomain part relative to root domain + subdomain=$(echo "$fulldomain" | sed "s/\.$_domain$//") + if [ "$subdomain" = "$fulldomain" ]; then + _err "Failed to extract subdomain from $fulldomain relative to root domain $_domain" + return 1 + fi + _debug "Extracted subdomain: $subdomain for root domain: $_domain" + + # Escape txtvalue to prevent JSON injection (e.g., quotes in txtvalue) + escaped_txtvalue=$(echo "$txtvalue" | sed 's/"/\\"/g') + + # Prepare payload and URL for adding TXT record + # Note: 'name' in payload uses subdomain (e.g., _acme-challenge.sub) as required by Spaceship API + payload="{\"force\": true, \"items\": [{\"type\": \"TXT\", \"name\": \"$subdomain\", \"value\": \"$escaped_txtvalue\", \"ttl\": 600}]}" + url="$SPACESHIP_API_BASE/dns/records/$_domain" + + # Send API request + if _spaceship_api_request "PUT" "$url" "$payload"; then + _info "Successfully added TXT record for $fulldomain" + return 0 + else + _err "Failed to add TXT record. If the domain $_domain is incorrect, set SPACESHIP_ROOT_DOMAIN to the correct root domain." + return 1 + fi +} + +# Usage: fulldomain txtvalue +# Used to remove the txt record after validation +dns_spaceship_rm() { + fulldomain="$1" + txtvalue="$2" + + _info "Removing TXT record for $fulldomain with value $txtvalue" + + # Initialize API credentials and headers + if ! _spaceship_init; then + return 1 + fi + + # Detect root zone + if ! _get_root "$fulldomain"; then + return 1 + fi + + # Extract subdomain part relative to root domain + subdomain=$(echo "$fulldomain" | sed "s/\.$_domain$//") + if [ "$subdomain" = "$fulldomain" ]; then + _err "Failed to extract subdomain from $fulldomain relative to root domain $_domain" + return 1 + fi + _debug "Extracted subdomain: $subdomain for root domain: $_domain" + + # Escape txtvalue to prevent JSON injection + escaped_txtvalue=$(echo "$txtvalue" | sed 's/"/\\"/g') + + # Prepare payload and URL for deleting TXT record + # Note: 'name' in payload uses subdomain (e.g., _acme-challenge.sub) as required by Spaceship API + payload="[{\"type\": \"TXT\", \"name\": \"$subdomain\", \"value\": \"$escaped_txtvalue\"}]" + url="$SPACESHIP_API_BASE/dns/records/$_domain" + + # Send API request + if _spaceship_api_request "DELETE" "$url" "$payload"; then + _info "Successfully deleted TXT record for $fulldomain" + return 0 + else + _err "Failed to delete TXT record. If the domain $_domain is incorrect, set SPACESHIP_ROOT_DOMAIN to the correct root domain." + return 1 + fi +} + +#################### Private functions below ################################## + +_spaceship_init() { + SPACESHIP_API_KEY="${SPACESHIP_API_KEY:-$(_readaccountconf_mutable SPACESHIP_API_KEY)}" + SPACESHIP_API_SECRET="${SPACESHIP_API_SECRET:-$(_readaccountconf_mutable SPACESHIP_API_SECRET)}" + + if [ -z "$SPACESHIP_API_KEY" ] || [ -z "$SPACESHIP_API_SECRET" ]; then + _err "Spaceship API credentials are not set. Please set SPACESHIP_API_KEY and SPACESHIP_API_SECRET." + _err "Ensure \"$LE_CONFIG_HOME\" directory has restricted permissions (chmod 700 \"$LE_CONFIG_HOME\") to protect credentials." + return 1 + fi + + # Save credentials to account config for future renewals + _saveaccountconf_mutable SPACESHIP_API_KEY "$SPACESHIP_API_KEY" + _saveaccountconf_mutable SPACESHIP_API_SECRET "$SPACESHIP_API_SECRET" + + # Set common headers for API requests + export _H1="X-API-Key: $SPACESHIP_API_KEY" + export _H2="X-API-Secret: $SPACESHIP_API_SECRET" + export _H3="Content-Type: application/json" + return 0 +} + +_get_root() { + domain="$1" + + # Check manual override + SPACESHIP_ROOT_DOMAIN="${SPACESHIP_ROOT_DOMAIN:-$(_readdomainconf SPACESHIP_ROOT_DOMAIN)}" + if [ -n "$SPACESHIP_ROOT_DOMAIN" ]; then + _domain="$SPACESHIP_ROOT_DOMAIN" + _debug "Using manually specified or saved root domain: $_domain" + _savedomainconf SPACESHIP_ROOT_DOMAIN "$SPACESHIP_ROOT_DOMAIN" + return 0 + fi + + _debug "Detecting root zone for '$domain'" + + i=1 + p=1 + while true; do + _cutdomain=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + + _debug "Attempt i=$i: Checking if '$_cutdomain' is root zone (cut ret=$?)" + + if [ -z "$_cutdomain" ]; then + _debug "Cut resulted in empty string, root zone not found." + break + fi + + # Call the API to check if this _cutdomain is a manageable zone + if _spaceship_api_request "GET" "$SPACESHIP_API_BASE/dns/records/$_cutdomain?take=1&skip=0"; then + # API call succeeded (HTTP 200 OK for GET /dns/records) + _domain="$_cutdomain" + _debug "Root zone found: '$_domain'" + + # Save the detected root domain + _savedomainconf SPACESHIP_ROOT_DOMAIN "$_domain" + _info "Root domain '$_domain' saved to configuration for future use." + + return 0 + fi + + _debug "API check failed for '$_cutdomain'. Continuing search." + + p=$i + i=$((i + 1)) + done + + _err "Could not detect root zone for '$domain'. Please set SPACESHIP_ROOT_DOMAIN manually." + return 1 +} + +_spaceship_api_request() { + method="$1" + url="$2" + payload="$3" + + _debug2 "Sending $method request to $url with payload $payload" + if [ "$method" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$payload" "$url" "" "$method")" + fi + + if [ "$?" != "0" ]; then + _err "API request failed. Response: $response" + return 1 + fi + + _debug2 "API response body: $response" + + if [ "$method" = "GET" ]; then + if _contains "$(_head_n 1 <"$HTTP_HEADER")" '200'; then + return 0 + fi + else + if _contains "$(_head_n 1 <"$HTTP_HEADER")" '204'; then + return 0 + fi + fi + + _debug2 "API response header: $HTTP_HEADER" + return 1 +} diff --git a/dnsapi/dns_technitium.sh b/dnsapi/dns_technitium.sh index a50db97c..7bc0dd48 100755 --- a/dnsapi/dns_technitium.sh +++ b/dnsapi/dns_technitium.sh @@ -1,13 +1,12 @@ #!/usr/bin/env sh # shellcheck disable=SC2034 -dns_Technitium_info='Technitium DNS Server - -Site: https://technitium.com/dns/ +dns_technitium_info='Technitium DNS Server +Site: Technitium.com/dns/ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_technitium Options: Technitium_Server Server Address Technitium_Token API Token -Issues:https://github.com/acmesh-official/acme.sh/issues/6116 +Issues: github.com/acmesh-official/acme.sh/issues/6116 Author: Henning Reich ' diff --git a/dnsapi/dns_tele3.sh b/dnsapi/dns_tele3.sh index e5974951..3a3ccf8c 100644 --- a/dnsapi/dns_tele3.sh +++ b/dnsapi/dns_tele3.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#tele3 Options: TELE3_Key API Key TELE3_Secret API Secret -Author: Roman Blizik +Author: Roman Blizik <@par-pa> ' TELE3_API="https://www.tele3.cz/acme/" diff --git a/dnsapi/dns_tencent.sh b/dnsapi/dns_tencent.sh index d82768b9..b148adc3 100644 --- a/dnsapi/dns_tencent.sh +++ b/dnsapi/dns_tencent.sh @@ -2,7 +2,7 @@ # shellcheck disable=SC2034 dns_tencent_info='Tencent.com Site: cloud.Tencent.com -Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_tencent +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_tencent Options: Tencent_SecretId Secret ID Tencent_SecretKey Secret Key diff --git a/dnsapi/dns_timeweb.sh b/dnsapi/dns_timeweb.sh index 544564ea..7040ac9a 100644 --- a/dnsapi/dns_timeweb.sh +++ b/dnsapi/dns_timeweb.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_timeweb Options: TW_Token API JWT token. Get it from the control panel at https://timeweb.cloud/my/api-keys Issues: github.com/acmesh-official/acme.sh/issues/5140 -Author: Nikolay Pronchev +Author: Nikolay Pronchev <@nikolaypronchev> ' TW_Api="https://api.timeweb.cloud/api/v1" diff --git a/dnsapi/dns_transip.sh b/dnsapi/dns_transip.sh index 2abbe34d..b3c5ed70 100644 --- a/dnsapi/dns_transip.sh +++ b/dnsapi/dns_transip.sh @@ -24,7 +24,7 @@ dns_transip_add() { _debug txtvalue="$txtvalue" _transip_setup "$fulldomain" || return 1 _info "Creating TXT record." - if ! _transip_rest POST "domains/$_domain/dns" "{\"dnsEntry\":{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"expire\":300}}"; then + if ! _transip_rest POST "domains/$_domain/dns" "{\"dnsEntry\":{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"expire\":60}}"; then _err "Could not add TXT record." return 1 fi @@ -38,7 +38,7 @@ dns_transip_rm() { _debug txtvalue="$txtvalue" _transip_setup "$fulldomain" || return 1 _info "Removing TXT record." - if ! _transip_rest DELETE "domains/$_domain/dns" "{\"dnsEntry\":{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"expire\":300}}"; then + if ! _transip_rest DELETE "domains/$_domain/dns" "{\"dnsEntry\":{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"expire\":60}}"; then _err "Could not remove TXT record $_sub_domain for $domain" return 1 fi diff --git a/dnsapi/dns_udr.sh b/dnsapi/dns_udr.sh index f9772e10..656a0557 100644 --- a/dnsapi/dns_udr.sh +++ b/dnsapi/dns_udr.sh @@ -7,7 +7,7 @@ Options: UDR_USER Username UDR_PASS Password Issues: github.com/acmesh-official/acme.sh/issues/3923 -Author: Andreas Scherer +Author: Andreas Scherer <@andischerer> ' UDR_API="https://api.domainreselling.de/api/call.cgi" diff --git a/dnsapi/dns_variomedia.sh b/dnsapi/dns_variomedia.sh index fa38bbb6..4620b854 100644 --- a/dnsapi/dns_variomedia.sh +++ b/dnsapi/dns_variomedia.sh @@ -74,7 +74,7 @@ dns_variomedia_rm() { return 1 fi - _record_id="$(echo "$response" | sed -E 's/,"tags":\[[^]]*\]//g' | cut -d '[' -f2 | cut -d']' -f1 | sed 's/},[ \t]*{/\},§\{/g' | tr § '\n' | grep "$_sub_domain" | grep -- "$txtvalue" | sed 's/^{//;s/}[,]?$//' | tr , '\n' | tr -d '\"' | grep ^id | cut -d : -f2 | tr -d ' ')" + _record_id="$(echo "$response" | sed -E 's/,"tags":\[[^]]*\]//g' | cut -d '[' -f3 | cut -d']' -f1 | sed 's/},[ \t]*{/\},§\{/g' | tr § '\n' | grep -i "$_sub_domain" | grep -- "$txtvalue" | sed 's/^{//;s/}[,]?$//' | tr , '\n' | tr -d '\"' | grep ^id | cut -d : -f2 | tr -d ' ')" _debug _record_id "$_record_id" if [ "$_record_id" ]; then _info "Successfully retrieved the record id for ACME challenge." diff --git a/dnsapi/dns_virakcloud.sh b/dnsapi/dns_virakcloud.sh new file mode 100755 index 00000000..7ae665d2 --- /dev/null +++ b/dnsapi/dns_virakcloud.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_virakcloud_info='VirakCloud DNS API +Site: VirakCloud.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_virakcloud +Options: + VIRAKCLOUD_API_TOKEN VirakCloud API Bearer Token +' + +VIRAKCLOUD_API_URL="https://public-api.virakcloud.com/dns" + +######## Public functions ##################### + +#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +#Used to add txt record +dns_virakcloud_add() { + fulldomain=$1 + txtvalue=$2 + + VIRAKCLOUD_API_TOKEN="${VIRAKCLOUD_API_TOKEN:-$(_readaccountconf_mutable VIRAKCLOUD_API_TOKEN)}" + + if [ -z "$VIRAKCLOUD_API_TOKEN" ]; then + _err "You haven't configured your VirakCloud API token yet." + _err "Please set VIRAKCLOUD_API_TOKEN environment variable or run:" + _err " export VIRAKCLOUD_API_TOKEN=\"your-api-token\"" + return 1 + fi + + _saveaccountconf_mutable VIRAKCLOUD_API_TOKEN "$VIRAKCLOUD_API_TOKEN" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + if [ "$http_code" = "401" ]; then + return 1 + fi + _err "Invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug fulldomain "$fulldomain" + + _info "Adding TXT record" + + if _virakcloud_rest POST "domains/${_domain}/records" "{\"record\":\"${fulldomain}\",\"type\":\"TXT\",\"ttl\":3600,\"content\":\"${txtvalue}\"}"; then + if echo "$response" | grep -q "success" || echo "$response" | grep -q "\"data\""; then + _info "Added, OK" + return 0 + elif echo "$response" | grep -q "already exists" || echo "$response" | grep -q "duplicate"; then + _info "Record already exists, OK" + return 0 + else + _err "Add TXT record error." + _err "Response: $response" + return 1 + fi + fi + + _err "Add TXT record error." + return 1 +} + +#Usage: fulldomain txtvalue +#Used to remove the txt record after validation +dns_virakcloud_rm() { + fulldomain=$1 + txtvalue=$2 + + VIRAKCLOUD_API_TOKEN="${VIRAKCLOUD_API_TOKEN:-$(_readaccountconf_mutable VIRAKCLOUD_API_TOKEN)}" + + if [ -z "$VIRAKCLOUD_API_TOKEN" ]; then + _err "You haven't configured your VirakCloud API token yet." + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + if [ "$http_code" = "401" ]; then + return 1 + fi + _err "Invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + _info "Removing TXT record" + + _debug "Getting list of records to find content ID" + if ! _virakcloud_rest GET "domains/${_domain}/records" ""; then + return 1 + fi + + _debug2 "Records response" "$response" + + contentid="" + # Extract innermost objects (content objects) which look like {"id":"...","content_raw":"..."} + # We filter for the one containing txtvalue + + target_obj=$(echo "$response" | grep -o '{[^}]*}' | grep "$txtvalue" | _head_n 1) + + if [ -n "$target_obj" ]; then + contentid=$(echo "$target_obj" | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4) + fi + + if [ -z "$contentid" ]; then + _debug "Could not find matching record ID in response" + _info "Record not found, may have been already removed" + return 0 + fi + + _debug contentid "$contentid" + + if _virakcloud_rest DELETE "domains/${_domain}/records/${fulldomain}/TXT/${contentid}" ""; then + if echo "$response" | grep -q "success" || [ -z "$response" ]; then + _info "Removed, OK" + return 0 + elif echo "$response" | grep -q "not found" || echo "$response" | grep -q "404"; then + _info "Record not found, OK" + return 0 + else + _err "Remove TXT record error." + _err "Response: $response" + return 1 + fi + fi + + _err "Remove TXT record error." + return 1 +} + +#################### Private functions below ################################## + +#_acme-challenge.www.domain.com +#returns +# _domain=domain.com +_get_root() { + domain=$1 + i=1 + p=1 + + # Optimization: skip _acme-challenge subdomain to avoid 422 errors + if echo "$domain" | grep -q "^_acme-challenge."; then + i=2 + fi + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + + if [ -z "$h" ]; then + return 1 + fi + + if ! _virakcloud_rest GET "domains/$h" ""; then + http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + if [ "$http_code" = "401" ]; then + return 1 + fi + p=$i + i=$(_math "$i" + 1) + continue + fi + + if echo "$response" | grep -q "\"name\""; then + _domain="$h" + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done + + return 1 +} + +_virakcloud_rest() { + m=$1 + ep="$2" + data="$3" + + _debug "$ep" + + export _H1="Content-Type: application/json" + export _H2="Authorization: Bearer $VIRAKCLOUD_API_TOKEN" + + if [ "$m" != "GET" ]; then + _debug data "$data" + response="$(_post "$data" "$VIRAKCLOUD_API_URL/$ep" "" "$m")" + else + response="$(_get "$VIRAKCLOUD_API_URL/$ep")" + fi + + _ret="$?" + + if [ "$_ret" != "0" ]; then + _err "error on $m $ep" + return 1 + fi + + http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug "http response code" "$http_code" + + if [ "$http_code" = "401" ]; then + _err "VirakCloud API returned 401 Unauthorized." + _err "Your VIRAKCLOUD_API_TOKEN is invalid or expired." + _err "Please check your API token and try again." + return 1 + fi + + if [ "$http_code" = "403" ]; then + _err "VirakCloud API returned 403 Forbidden." + _err "Your API token does not have permission to access this resource." + return 1 + fi + + if [ -n "$http_code" ] && [ "$http_code" -ge 400 ]; then + _err "VirakCloud API error. HTTP code: $http_code" + _err "Response: $response" + return 1 + fi + + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_vscale.sh b/dnsapi/dns_vscale.sh index c3915c69..faf3105d 100755 --- a/dnsapi/dns_vscale.sh +++ b/dnsapi/dns_vscale.sh @@ -5,7 +5,7 @@ Site: vscale.io Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_vscale Options: VSCALE_API_KEY API Key -Author: Alex Loban +Author: Alex Loban <@LAV45> ' VSCALE_API_URL="https://api.vscale.io/v1" diff --git a/dnsapi/dns_vultr.sh b/dnsapi/dns_vultr.sh index 61ec3f60..4002e5de 100644 --- a/dnsapi/dns_vultr.sh +++ b/dnsapi/dns_vultr.sh @@ -6,7 +6,6 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_vultr Options: VULTR_API_KEY API Key Issues: github.com/acmesh-official/acme.sh/issues/2374 -Author: ' VULTR_Api="https://api.vultr.com/v2" diff --git a/dnsapi/dns_websupport.sh b/dnsapi/dns_websupport.sh index bfc4b23a..2374afc3 100644 --- a/dnsapi/dns_websupport.sh +++ b/dnsapi/dns_websupport.sh @@ -7,7 +7,7 @@ Options: WS_ApiKey API Key. Called "Identifier" in the WS Admin WS_ApiSecret API Secret. Called "Secret key" in the WS Admin Issues: github.com/acmesh-official/acme.sh/issues/3486 -Author: trgo.sk , akulumbeg +Author: trgo.sk <@trgosk>, @akulumbeg ' # Requirements: API Key and Secret from https://admin.websupport.sk/en/auth/apiKey diff --git a/dnsapi/dns_west_cn.sh b/dnsapi/dns_west_cn.sh index d0bb7d49..b873bfc0 100644 --- a/dnsapi/dns_west_cn.sh +++ b/dnsapi/dns_west_cn.sh @@ -1,9 +1,13 @@ #!/usr/bin/env sh - -# West.cn Domain api -#WEST_Username="username" -#WEST_Key="sADDsdasdgdsf" -#Set key at https://www.west.cn/manager/API/APIconfig.asp +# shellcheck disable=SC2034 +dns_west_cn_info='West.cn +Site: West.cn +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_west_cn +Options: + WEST_Username API username + WEST_Key API Key. Set at https://www.west.cn/manager/API/APIconfig.asp +Issues: github.com/acmesh-official/acme.sh/issues/4894 +' REST_API="https://api.west.cn/API/v2" diff --git a/dnsapi/dns_world4you.sh b/dnsapi/dns_world4you.sh index 0febbad9..dc295330 100644 --- a/dnsapi/dns_world4you.sh +++ b/dnsapi/dns_world4you.sh @@ -7,7 +7,7 @@ Options: WORLD4YOU_USERNAME Username WORLD4YOU_PASSWORD Password Issues: github.com/acmesh-official/acme.sh/issues/3269 -Author: Lorenz Stechauner +Author: Lorenz Stechauner <@NerLOR> ' WORLD4YOU_API="https://my.world4you.com/en" @@ -202,7 +202,7 @@ _get_paketnr() { fqdn="$1" form="$2" - domains=$(echo "$form" | grep '