diff --git a/.github/workflows/Apache.yml b/.github/workflows/Apache.yml new file mode 100644 index 00000000..b17abbd1 --- /dev/null +++ b/.github/workflows/Apache.yml @@ -0,0 +1,50 @@ +name: Apache +on: + push: + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Apache: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_APACHE: 1 + CASE: le_test_apache + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat apache2 + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up Apache + # Apache serves on 5002, which is the HTTP-01 validation port in + # Pebble's default config; acme.sh appends the challenge Alias to + # the main config itself + run: | + echo "Listen 5002" | sudo tee /etc/apache2/ports.conf + sudo sed -i "s/\*:80/*:5002/" /etc/apache2/sites-available/000-default.conf + sudo apache2ctl configtest + sudo systemctl restart apache2 + curl -s -o /dev/null -w "%{http_code}" -H "Host: example.com" http://127.0.0.1:5002/ | grep -E "200|403|404" + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 00d180b9..84a17470 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -26,9 +26,9 @@ jobs: id: step_one run: | if [ "${{secrets.TokenName1}}" ] ; then - echo "::set-output name=hasToken::true" + echo "hasToken=true" >> "$GITHUB_OUTPUT" else - echo "::set-output name=hasToken::false" + echo "hasToken=false" >> "$GITHUB_OUTPUT" fi - name: Check the value run: echo ${{ steps.step_one.outputs.hasToken }} @@ -66,7 +66,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Set env file @@ -114,9 +114,11 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install tools - run: brew install socat + run: | + brew untap aws/tap || true + brew install socat - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest @@ -165,7 +167,7 @@ jobs: - name: Set git to use LF run: | git config --global core.autocrlf false - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install cygwin base packages with chocolatey run: | choco config get cacheLocation @@ -176,9 +178,14 @@ jobs: C:\tools\cygwin\cygwinsetup.exe -qgnNdO -R C:/tools/cygwin -s https://mirrors.kernel.org/sourceware/cygwin/ -P socat,curl,cron,unzip,git shell: cmd - name: Set ENV - shell: cmd + shell: bash run: | - echo PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin >> %GITHUB_ENV% + echo 'PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin' >> "$GITHUB_ENV" + # cygwin git sees the runner workspace as owned by another user and + # fails with "dubious ownership" (exit 128) in the checkout post step + echo 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_KEY_0=safe.directory' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_VALUE_0=*' >> "$GITHUB_ENV" - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest @@ -224,12 +231,72 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + prepare: pkg install -y socat curl + usesh: true + sync: nfs + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + GhostBSD: + runs-on: ubuntu-latest + needs: FreeBSD + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/ghostbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg install -y socat curl usesh: true @@ -262,7 +329,7 @@ jobs: OpenBSD: runs-on: ubuntu-latest - needs: FreeBSD + needs: GhostBSD env: TEST_DNS : ${{ secrets.TEST_DNS }} TestingDomain: ${{ secrets.TestingDomain }} @@ -280,12 +347,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg_add socat curl libiconv usesh: true @@ -336,12 +404,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | /usr/sbin/pkg_add curl socat @@ -393,12 +462,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | pkg install -y libnghttp2 @@ -454,12 +524,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: mport install socat curl || true usesh: true @@ -511,12 +582,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: | @@ -570,12 +642,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -626,12 +699,13 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -661,7 +735,7 @@ jobs: - Haiku: + Tribblix: runs-on: ubuntu-latest needs: OpenIndiana env: @@ -675,26 +749,204 @@ jobs: 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 + HTTPS_INSECURE: 1 # always set to 1 to ignore https error, since Tribblix 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@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - - uses: vmactions/haiku-vm@v1 + - uses: vmactions/tribblix-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy 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 - + sync: nfs + prepare: zap 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: DebugOnError + 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: Tribblix + 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@v7 + - 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: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy 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: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + Hurd: + runs-on: ubuntu-latest + needs: Haiku + 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 + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: | + apt-get update -y + apt-get install -y curl cron + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + OpenEuler: + runs-on: ubuntu-latest + needs: Hurd + 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 + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: dnf install -y curl socat cronie tar gzip run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" diff --git a/.github/workflows/DragonFlyBSD.yml b/.github/workflows/DragonFlyBSD.yml index c8cbc985..20c61dcc 100644 --- a/.github/workflows/DragonFlyBSD.yml +++ b/.github/workflows/DragonFlyBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/FreeBSD.yml b/.github/workflows/FreeBSD.yml index 50fcab32..88ef0a6e 100644 --- a/.github/workflows/FreeBSD.yml +++ b/.github/workflows/FreeBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml new file mode 100644 index 00000000..04510c15 --- /dev/null +++ b/.github/workflows/GhostBSD.yml @@ -0,0 +1,83 @@ +name: GhostBSD +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + GhostBSD: + 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 DV SSL CA 2" + # CA: "ZeroSSL RSA DV SSL CA 2" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + runs-on: ubuntu-latest + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true + 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@v6 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/ghostbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install -y socat curl wget + usesh: true + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/Haiku.yml b/.github/workflows/Haiku.yml index 9884ebeb..b133dd18 100644 --- a/.github/workflows/Haiku.yml +++ b/.github/workflows/Haiku.yml @@ -65,6 +65,7 @@ jobs: - uses: vmactions/haiku-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Hurd.yml b/.github/workflows/Hurd.yml new file mode 100644 index 00000000..fee80d29 --- /dev/null +++ b/.github/workflows/Hurd.yml @@ -0,0 +1,76 @@ +name: Hurd +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Hurd: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + 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 }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + # Do NOT install socat: socat's SYSTEM: address is broken on GNU Hurd + # (the child shell output goes to socat's stdout instead of the socket, + # so clients get an empty reply). Without socat, acme.sh standalone + # mode falls back to its python3 server, which works on Hurd. + prepare: | + apt-get update -y + apt-get install -y curl cron + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/MidnightBSD.yml b/.github/workflows/MidnightBSD.yml index 15024833..ce499e4e 100644 --- a/.github/workflows/MidnightBSD.yml +++ b/.github/workflows/MidnightBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/NetBSD.yml b/.github/workflows/NetBSD.yml index 16d0ae2d..6695f71e 100644 --- a/.github/workflows/NetBSD.yml +++ b/.github/workflows/NetBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Nginx.yml b/.github/workflows/Nginx.yml new file mode 100644 index 00000000..2ca9d64a --- /dev/null +++ b/.github/workflows/Nginx.yml @@ -0,0 +1,66 @@ +name: Nginx +on: + push: + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Nginx: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_NGINX: 1 + CASE: le_test_nginx + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat nginx + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up nginx + # a backend on 8081 plus a site with an aaPanel/BT style + # "location ^~ /" proxy block that shadows plain regex locations + # (regression for #6125); the site listens on 5002, which is the + # HTTP-01 validation port in Pebble's default config + run: | + sudo tee /etc/nginx/sites-available/default >/dev/null <<'EOF' + server { + listen 127.0.0.1:8081; + location / { + default_type text/plain; + return 200 "backend"; + } + } + server { + listen 5002 default_server; + server_name example.com; + location ^~ / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $http_host; + } + } + EOF + sudo nginx -t + sudo systemctl restart nginx + curl -s -H "Host: example.com" http://127.0.0.1:5002/ | grep backend + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/.github/workflows/Omnios.yml b/.github/workflows/Omnios.yml index eb486b35..aabb168b 100644 --- a/.github/workflows/Omnios.yml +++ b/.github/workflows/Omnios.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenBSD.yml b/.github/workflows/OpenBSD.yml index 4fdb76c5..46318163 100644 --- a/.github/workflows/OpenBSD.yml +++ b/.github/workflows/OpenBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenEuler.yml b/.github/workflows/OpenEuler.yml new file mode 100644 index 00000000..2b4bd0ab --- /dev/null +++ b/.github/workflows/OpenEuler.yml @@ -0,0 +1,70 @@ +name: OpenEuler +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + OpenEuler: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + 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 }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + prepare: dnf install -y curl socat cronie tar gzip + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml index b5061ba7..e3119f8e 100644 --- a/.github/workflows/OpenIndiana.yml +++ b/.github/workflows/OpenIndiana.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Solaris.yml b/.github/workflows/Solaris.yml index 3393269a..30e4e291 100644 --- a/.github/workflows/Solaris.yml +++ b/.github/workflows/Solaris.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Tribblix.yml b/.github/workflows/Tribblix.yml new file mode 100644 index 00000000..68e61dc8 --- /dev/null +++ b/.github/workflows/Tribblix.yml @@ -0,0 +1,80 @@ +name: Tribblix +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Tribblix.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Tribblix.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Tribblix: + 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 DV SSL CA 2" + # CA: "ZeroSSL RSA DV SSL CA 2" + # 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@v6 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/tribblix-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: zap install socat curl wget + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/.github/workflows/blacklist-command.yml b/.github/workflows/blacklist-command.yml new file mode 100644 index 00000000..35e53677 --- /dev/null +++ b/.github/workflows/blacklist-command.yml @@ -0,0 +1,114 @@ +name: Blacklist Command + +# An issue titled "blacklist: " opened by the maintainer +# or a write-access member adds that identity to the Blacklist wiki page +# (see wiki-guard.yml) and closes the issue. The wiki-monitor notification +# embeds a prefilled link that opens such an issue in one click. + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +# Share the wiki-guard concurrency group so we never push to the wiki +# at the same time as the guard. +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + blacklist: + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'blacklist:') + runs-on: ubuntu-latest + steps: + - name: Check authorization + id: auth + run: | + assoc="${{ github.event.issue.author_association }}" + case "$assoc" in + OWNER|MEMBER|COLLABORATOR) + echo "ok=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "issue author is not authorized ($assoc); ignoring" + echo "ok=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Checkout wiki repository + if: steps.auth.outputs.ok == 'true' + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + + - name: Add the identity to the blacklist page + if: steps.auth.outputs.ok == 'true' + id: add + env: + TITLE: ${{ github.event.issue.title }} + run: | + target="$(printf '%s' "$TITLE" \ + | sed 's/^blacklist:[[:space:]]*//; s/^@//; s/[[:space:]].*$//' \ + | tr 'A-Z' 'a-z')" + case "$target" in + ''|*[!a-z0-9._+@-]*) + echo "invalid target: '$target'" + echo "result=invalid" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + echo "target=$target" >> "$GITHUB_OUTPUT" + cd wiki + if [ ! -e Blacklist.md ]; then + echo "result=nopage" >> "$GITHUB_OUTPUT" + exit 0 + fi + if grep -Fxiq -- "- $target" Blacklist.md; then + echo "result=already" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ -n "$(tail -c1 Blacklist.md)" ]; then + echo >> Blacklist.md + fi + printf -- '- %s\n' "$target" >> Blacklist.md + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Blacklist.md + git commit -m "blacklist $target (requested in #${{ github.event.issue.number }})" + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + echo "result=added" >> "$GITHUB_OUTPUT" + + - name: Reply and close + if: steps.auth.outputs.ok == 'true' + uses: actions/github-script@v9 + env: + RESULT: ${{ steps.add.outputs.result }} + TARGET: ${{ steps.add.outputs.target }} + with: + script: | + const result = process.env.RESULT; + const target = process.env.TARGET; + const messages = { + added: `\`${target}\` has been added to the [Blacklist](https://github.com/${context.repo.owner}/${context.repo.repo}/wiki/Blacklist). The wiki guard will revert their recent wiki changes on its next run.`, + already: `\`${target}\` is already on the blacklist.`, + invalid: "Could not parse a valid login or email from the issue title.", + nopage: "The Blacklist wiki page does not exist." + }; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: messages[result] || "No action taken." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: "closed", + state_reason: result === "added" ? "completed" : "not_planned" + }); diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index 0e7ba748..7dc42290 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -41,23 +41,29 @@ jobs: runs-on: ubuntu-latest needs: CheckToken if: "contains(needs.CheckToken.outputs.hasToken, 'true')" + permissions: + contents: read + packages: write steps: - name: checkout code uses: actions/checkout@v6 with: persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Extract Docker metadata id: meta uses: docker/metadata-action@v6 with: images: ${DOCKER_IMAGE} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 - name: login to docker hub run: | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + - name: login to ghcr + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: build and push the image run: | if [[ $GITHUB_REF == refs/tags/* ]]; then @@ -73,6 +79,8 @@ jobs: fi fi + echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}" >>"$GITHUB_ENV" + DOCKER_LABELS=() while read -r label; do DOCKER_LABELS+=(--label "${label}") @@ -84,3 +92,9 @@ jobs: --output "type=image,push=true" \ --build-arg AUTO_UPGRADE=${AUTO_UPGRADE} \ --platform linux/arm64/v8,linux/amd64,linux/arm/v6,linux/arm/v7,linux/386,linux/ppc64le,linux/s390x . + - name: mirror the image to ghcr (best-effort) + run: | + docker buildx imagetools create \ + --tag ghcr.io/${{ github.repository }}:${DOCKER_IMAGE_TAG} \ + ${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \ + || echo "::warning::GHCR mirror failed; Docker Hub publish unaffected" diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index e92b0411..a25cd4ef 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -2,18 +2,128 @@ name: "Update issues" on: issues: types: [opened] + issue_comment: + types: [created] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write jobs: comment: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, + const item = context.payload.issue || context.payload.pull_request; + + // Close on sight anything opened by a user on the wiki Blacklist + // page (maintained by the Wiki Guard workflow). + let blacklist = []; + try { + const res = await fetch(`https://raw.githubusercontent.com/wiki/${context.repo.owner}/${context.repo.repo}/Blacklist.md`); + if (res.ok) { + blacklist = (await res.text()).split("\n") + .filter(l => l.startsWith("- ")) + .map(l => l.slice(2).trim().toLowerCase()) + .filter(Boolean); + } + } catch (e) { + core.warning(`Failed to fetch the blacklist: ${e}`); + } + // A comment on a closed tracking issue reopens it (the standard + // closing note promises this). Bots, blacklisted users and the + // maintainer's own comments don't reopen. + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + const commenter = context.payload.comment.user; + if (issue.pull_request || issue.state !== "closed") { + return; + } + if (!/^report\s+(bugs?|issues?)\b/i.test(issue.title)) { + return; + } + if (commenter.type === "Bot" || + commenter.login.toLowerCase() === "neilpang" || + blacklist.includes(commenter.login.toLowerCase())) { + return; + } + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "open" + }); + return; + } + + if (blacklist.includes(item.user.login.toLowerCase())) { + if (context.payload.pull_request) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: item.number, + state: "closed" + }); + } else { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + state: "closed", + state_reason: "not_planned" + }); + } + return; + } + + if (context.payload.pull_request) { + return; + } + + const issue = context.payload.issue; + if (issue.title.startsWith("blacklist:") || issue.title.startsWith("revert:")) { + // Handled by the Blacklist / Revert Command workflows. + return; + } + if (/^report\s+(bugs?|issues?)\b/i.test(issue.title)) { + // Tracking issue for a third-party dns/deploy/notify api: + // no upgrade boilerplate; assign it to the opener, label it, + // then close it right away to keep the issue list clean. Any + // later comment reopens it (see the issue_comment handler). + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: [issue.user.login] + }); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ["3rd party api"] + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: "Closing this tracking issue for now to keep the issue list clean. It remains the place to report problems with this provider -- if you hit a bug, comment here and the issue will be reopened." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: "closed", + state_reason: "completed" + }); + return; + } + await github.rest.issues.createComment({ + issue_number: issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you." - + body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you. Before posting the log, review it and REDACT any secrets: private keys (`-----BEGIN ... PRIVATE KEY-----` blocks), API tokens and passwords." }) \ No newline at end of file diff --git a/.github/workflows/pr_dns.yml b/.github/workflows/pr_dns.yml index 558ebf48..19763a15 100644 --- a/.github/workflows/pr_dns.yml +++ b/.github/workflows/pr_dns.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/pr_notify.yml b/.github/workflows/pr_notify.yml index 416ed721..76ae76f6 100644 --- a/.github/workflows/pr_notify.yml +++ b/.github/workflows/pr_notify.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/revert-command.yml b/.github/workflows/revert-command.yml new file mode 100644 index 00000000..03161bbb --- /dev/null +++ b/.github/workflows/revert-command.yml @@ -0,0 +1,110 @@ +name: Revert Command + +# An issue titled "revert: " opened by the maintainer or +# a write-access member reverts that commit in the wiki repository and +# closes the issue. The wiki-monitor notification embeds a prefilled link +# that opens such an issue in one click. + +on: + issues: + types: [opened] + +permissions: + contents: write + issues: write + +# Share the wiki-guard concurrency group so we never push to the wiki +# at the same time as the guard. +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + revert: + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'revert:') + runs-on: ubuntu-latest + steps: + - name: Check authorization + id: auth + run: | + assoc="${{ github.event.issue.author_association }}" + case "$assoc" in + OWNER|MEMBER|COLLABORATOR) + echo "ok=true" >> "$GITHUB_OUTPUT" + ;; + *) + echo "issue author is not authorized ($assoc); ignoring" + echo "ok=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Checkout wiki repository + if: steps.auth.outputs.ok == 'true' + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + fetch-depth: 0 + + - name: Revert the wiki commit + if: steps.auth.outputs.ok == 'true' + id: revert + env: + TITLE: ${{ github.event.issue.title }} + run: | + target="$(printf '%s' "$TITLE" \ + | sed 's/^revert:[[:space:]]*//; s/[[:space:]].*$//' \ + | tr 'A-Z' 'a-z')" + case "$target" in + *[!0-9a-f]*|"") + echo "invalid commit sha: '$target'" + echo "result=invalid" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac + echo "target=$target" >> "$GITHUB_OUTPUT" + cd wiki + if ! git cat-file -e "$target^{commit}" 2>/dev/null; then + echo "result=notfound" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git revert --no-edit "$target"; then + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + echo "result=reverted" >> "$GITHUB_OUTPUT" + else + git revert --abort || true + echo "result=conflict" >> "$GITHUB_OUTPUT" + fi + + - name: Reply and close + if: steps.auth.outputs.ok == 'true' + uses: actions/github-script@v9 + env: + RESULT: ${{ steps.revert.outputs.result }} + TARGET: ${{ steps.revert.outputs.target }} + with: + script: | + const result = process.env.RESULT; + const target = process.env.TARGET; + const messages = { + reverted: `Wiki commit \`${target}\` has been reverted.`, + conflict: `Reverting \`${target}\` conflicts with later edits; please revert manually from the page history.`, + notfound: `Commit \`${target}\` was not found in the wiki repository.`, + invalid: "Could not parse a commit sha from the issue title." + }; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: messages[result] || "No action taken." + }); + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: "closed", + state_reason: result === "reverted" ? "completed" : "not_planned" + }); diff --git a/.github/workflows/vtag.yml b/.github/workflows/vtag.yml new file mode 100644 index 00000000..e9f7e8df --- /dev/null +++ b/.github/workflows/vtag.yml @@ -0,0 +1,32 @@ +name: Mirror version tag + +# Historical release tags are plain version numbers ("3.1.3") and cannot be +# renamed. When a plain version tag is pushed (including the tag created by +# publishing a GitHub release), mirror it as a "v"-prefixed tag ("v3.1.3") +# pointing to the same object, so both forms exist. +# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and +# refs created with GITHUB_TOKEN do not fire workflows anyway. + +on: + push: + tags: + - '[0-9]*' + +permissions: + contents: write + +jobs: + vtag: + if: github.repository == 'acmesh-official/acme.sh' + runs-on: ubuntu-latest + steps: + - name: Create the v-prefixed tag + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${{ github.repository }}/git/ref/tags/v${{ github.ref_name }}" >/dev/null 2>&1; then + echo "Tag v${{ github.ref_name }} already exists, nothing to do." + exit 0 + fi + gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/tags/v${{ github.ref_name }}" -f sha="${{ github.sha }}" + echo "Created tag v${{ github.ref_name }} -> ${{ github.sha }}" diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml new file mode 100644 index 00000000..dbe3dd8d --- /dev/null +++ b/.github/workflows/wiki-guard.yml @@ -0,0 +1,325 @@ +name: Wiki Guard + +# Rules enforced here: +# - Only the maintainer and write-access members may delete or rename wiki +# pages. Anyone else doing so gets blacklisted and the page restored to +# its last good revision. +# - Only the maintainer and write-access members may edit the Blacklist +# wiki page. Anyone else touching it gets blacklisted and the page +# reverted. +# - Any wiki change made by a blacklisted identity is reverted. +# A "good" revision is one authored by the maintainer, by this bot, or by +# a non-blacklisted user -- restoring from the deleted commit's parent is +# NOT safe, because vandals replace a page before destroying it and the +# parent would launder their version into a bot commit. +# The gollum event only fires on page create/update, never on deletion, +# so violations are caught by polling the wiki git history. + +on: + schedule: + - cron: "*/10 * * * *" + gollum: + # Piggyback on frequent repo activity, because the cron schedule is + # best-effort and often delayed well beyond its interval. + issues: + types: [opened] + issue_comment: + types: [created] + workflow_dispatch: + +permissions: + contents: write + issues: write + +concurrency: + group: wiki-guard + cancel-in-progress: false + +jobs: + guard: + # Forks have no .wiki repository, so the checkout below would + # fail there -- run only in the upstream repository. + if: github.repository == 'acmesh-official/acme.sh' + runs-on: ubuntu-latest + steps: + - name: Checkout wiki repository + uses: actions/checkout@v7 + with: + repository: ${{ github.repository }}.wiki + path: wiki + fetch-depth: 0 + + - name: Enforce wiki rules + id: guard + env: + # WIKI_GUARD_TOKEN: a PAT with read:org, needed to enumerate + # members whose write access comes via the organization -- the + # repo-scoped GITHUB_TOKEN only sees direct collaborators. + GH_TOKEN: ${{ secrets.WIKI_GUARD_TOKEN || secrets.GITHUB_TOKEN }} + run: | + # Logins with write (push) access to the repository, including + # organization members -- they may delete/rename pages and edit + # the blacklist just like the maintainer. If the API call fails, + # the list stays empty and enforcement falls back to + # maintainer-only, which is the safe direction. + gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \ + -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ + | tr 'A-Z' 'a-z' | sort -u > writers.txt || true + echo "write-access members loaded: $(wc -l < writers.txt)" + cd wiki + git config core.quotePath false + + # Any author email under this domain is the maintainer. + OWNER_DOMAIN="neilpang.com" + # Our own enforcement commits. + BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" + BL_PAGE="Blacklist.md" + # Rolling window; the cron runs every 10 minutes, so 7 days gives + # ample overlap without re-judging old changes the maintainer + # already accepted. + WINDOW="7 days ago" + + : > ../actions.txt + : > ../bl_new.txt + + is_owner() { + case "$1" in + *@"$OWNER_DOMAIN") return 0 ;; + esac + return 1 + } + + is_bot() { + [ "$1" = "$BOT_EMAIL" ] + } + + author_email() { + git show -s --format=%ae "$1" | tr 'A-Z' 'a-z' + } + + # Identity of a commit author: the GitHub login when the email is a + # users.noreply.github.com address, otherwise the email itself. + identity_of() { + case "$1" in + *+*@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/^[^+]*+//; s/@users\.noreply\.github\.com$//' + ;; + *@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/@users\.noreply\.github\.com$//' + ;; + *) + printf '%s\n' "$1" + ;; + esac + } + + is_blacklisted() { + grep -Fxq "$1" ../bl_all.txt + } + + # Trusted committers: the maintainer (by email domain), this bot, + # and anyone whose GitHub login has write access to the repo. + is_trusted() { + if is_owner "$1" || is_bot "$1"; then + return 0 + fi + grep -Fxq "$(identity_of "$1")" ../writers.txt + } + + # Newest commit on file $1 authored by a non-blacklisted user. + last_good_for() { + for g in $(git log --format=%H --no-renames -- "$1"); do + gae="$(author_email "$g")" + if is_trusted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + gid="$(identity_of "$gae")" + if ! is_blacklisted "$gid" && ! is_blacklisted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + done + return 0 + } + + if [ -e "$BL_PAGE" ]; then + page_existed=1 + else + page_existed="" + fi + + # ---- 1. Last good version of the blacklist page: the newest + # revision authored by the maintainer or by this bot. Everything + # else on that page is tampering and is discarded. + bl_good_commit="" + for c in $(git log --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + bl_good_commit="$c" + break + fi + done + if [ -n "$bl_good_commit" ] && git cat-file -e "$bl_good_commit:$BL_PAGE" 2>/dev/null; then + git show "$bl_good_commit:$BL_PAGE" > ../bl_page.txt + else + { + echo "# Blacklist" + echo "" + echo "Users listed below violated the wiki rules (deleted or renamed" + echo "pages, or tampered with this page). Their new issues and pull" + echo "requests are closed on sight and their wiki edits are reverted" + echo "automatically. Only the maintainer and write-access members" + echo "may edit this page." + echo "" + echo "To pardon a user while their violation is still inside the" + echo "scan window, replace their entry with: pardon: username" + echo "" + } > ../bl_page.txt + fi + sed -n 's/^- *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_good.txt + sed -n 's/^[Pp]ardon: *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_pardon.txt + + bl_add() { + if grep -Fxq "$1" ../bl_pardon.txt; then + return 0 + fi + if ! grep -Fxq "$1" ../bl_good.txt && ! grep -Fxq "$1" ../bl_new.txt; then + printf '%s\n' "$1" >> ../bl_new.txt + printf '%s\n' "- blacklisted \`$1\`: $2" >> ../actions.txt + fi + } + + # ---- 2. Blacklist everyone who deleted or renamed a page. + # --no-renames makes a rename count as a deletion of the old path. + for c in $(git log --since="$WINDOW" --diff-filter=D --no-renames --format=%H); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "deleted or renamed pages in $c ($an <$ae>)" + done + + # ---- 3. Blacklist everyone else who touched the blacklist page. + # The revert of their tampering falls out of steps 5 and 6. + for c in $(git log --since="$WINDOW" --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "tampered with \`$BL_PAGE\` in $c ($an <$ae>)" + done + + sort -u ../bl_new.txt > ../bl_new_u.txt + cat ../bl_good.txt ../bl_new_u.txt | sort -u > ../bl_all.txt + + # ---- 4. Restore pages that are currently missing because a + # non-maintainer deleted them, using the last good revision. + git log --since="$WINDOW" --diff-filter=D --no-renames --name-only --format= \ + | sort -u \ + | while IFS= read -r f; do + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ] || [ -e "$f" ]; then + continue + fi + del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")" + if [ -z "$del" ]; then + continue + fi + ae="$(author_email "$del")" + if is_trusted "$ae"; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + git checkout "$good" -- "$f" + printf '%s\n' "- restored \`$f\` (deleted in $del) from its last good revision $good" >> ../actions.txt + fi + done + + # ---- 5. Revert every recent change made by a blacklisted + # identity: each touched file goes back to its newest revision + # authored by a non-blacklisted user; a file that has no such + # revision (they created it) is removed. + if [ -s ../bl_all.txt ]; then + for c in $(git log --since="$WINDOW" --format=%H --no-renames); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + id="$(identity_of "$ae")" + if ! is_blacklisted "$id" && ! is_blacklisted "$ae"; then + continue + fi + git show --name-only --no-renames --format= "$c" \ + | while IFS= read -r f; do + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ]; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + want="$(git rev-parse "$good:$f")" + have="$(git hash-object -- "$f" 2>/dev/null || echo missing)" + if [ "$want" != "$have" ]; then + git checkout "$good" -- "$f" + printf '%s\n' "- reverted \`$f\` to its last good revision $good (undoing change by \`$id\` in $c)" >> ../actions.txt + fi + elif [ -e "$f" ]; then + git rm -q -- "$f" + printf '%s\n' "- removed \`$f\` created by blacklisted \`$id\` in $c" >> ../actions.txt + fi + done + done + fi + + # ---- 6. Regenerate the blacklist page: the last good text plus + # any newly blacklisted identities. This both reverts tampering + # and records new violators; manual edits by the maintainer are + # preserved as the new good text. + cp ../bl_page.txt ../bl_page_new.txt + if [ -s ../bl_page_new.txt ] && [ -n "$(tail -c1 ../bl_page_new.txt)" ]; then + echo >> ../bl_page_new.txt + fi + while IFS= read -r id; do + if [ -n "$id" ] && ! grep -Fxiq -- "- $id" ../bl_page_new.txt; then + printf -- '- %s\n' "$id" >> ../bl_page_new.txt + fi + done < ../bl_new_u.txt + if ! cmp -s ../bl_page_new.txt "$BL_PAGE" 2>/dev/null; then + cp ../bl_page_new.txt "$BL_PAGE" + git add -- "$BL_PAGE" + if [ -n "$page_existed" ] || [ -s ../bl_new_u.txt ]; then + printf '%s\n' "- updated \`$BL_PAGE\`" >> ../actions.txt + fi + fi + + # ---- 7. Commit, push, notify. + if [ -n "$(git status --porcelain)" ]; then + git config user.name "github-actions[bot]" + git config user.email "$BOT_EMAIL" + git commit -m "wiki-guard: restore pages and enforce blacklist" + git push origin HEAD || { git pull --rebase && git push origin HEAD; } + fi + if [ -s ../actions.txt ]; then + { + echo "The wiki guard handled the following rule violations:" + echo "" + cat ../actions.txt + echo "" + echo "Blacklist: https://github.com/${GITHUB_REPOSITORY}/wiki/Blacklist" + echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki" + } > ../guard-msg.txt + echo "acted=true" >> "$GITHUB_OUTPUT" + else + echo "No rule violations found." + echo "acted=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create issue to notify Neilpang + if: steps.guard.outputs.acted == 'true' + uses: peter-evans/create-issue-from-file@v6 + with: + title: "Wiki guard: rule violations handled" + content-filepath: ./guard-msg.txt + assignees: Neilpang diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml index a706529a..89bb1f3a 100644 --- a/.github/workflows/wiki-monitor.yml +++ b/.github/workflows/wiki-monitor.yml @@ -9,13 +9,14 @@ jobs: if: github.actor != 'neilpang' steps: - name: Checkout wiki repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ${{ github.repository }}.wiki path: wiki fetch-depth: 0 - name: Generate wiki change message + id: msg run: | actor="${{ github.actor }}" sender_url=$(jq -r '.sender.html_url' "$GITHUB_EVENT_PATH") @@ -27,6 +28,17 @@ jobs: now="$(date '+%Y-%m-%d %H:%M:%S')" cd wiki + # Skip notification when the change was authored by the + # maintainer himself (any author email under neilpang.com), + # e.g. a direct git push to the wiki repository. + author_email=$(git show -s --format=%ae "$page_sha" 2>/dev/null | tr 'A-Z' 'a-z') + case "$author_email" in + *@neilpang.com) + echo "Change authored by maintainer ($author_email); skipping notification." + echo "notify=false" >> "$GITHUB_OUTPUT" + exit 0 + ;; + esac 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 @@ -41,17 +53,21 @@ jobs: 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 "[Click here to Revert](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=revert%3A+${page_sha}&body=Revert+wiki+commit+${page_sha}+by+@${actor}.)" + echo "" + echo "[Click here to Blacklist @$actor](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=blacklist%3A+${actor}&body=Blacklist+@${actor},+requested+from+the+wiki+monitor.)" echo "" echo "----" - echo "### diff:" + echo "### diff:" echo '```diff' cat wiki.diff echo '```' } > wiki-change-msg.txt + echo "notify=true" >> "$GITHUB_OUTPUT" - name: Create issue to notify Neilpang - uses: peter-evans/create-issue-from-file@v5 + if: steps.msg.outputs.notify == 'true' + uses: peter-evans/create-issue-from-file@v6 with: title: "Wiki edited" content-filepath: ./wiki-change-msg.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..33294ce7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing + +1. Do NOT send pull request to `master` branch. +Please send to `dev` branch instead. +Any PR to `master` branch will NOT be merged. + +2. For dns api support, read this guide first: https://github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide +You will NOT get any review without passing this guide. You also need to fix the CI errors. diff --git a/Dockerfile b/Dockerfile index 55a9cc67..229e4830 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,8 +81,8 @@ if [ \"\$1\" = \"daemon\" ]; then \n \ echo \"\$LE_CONFIG_HOME/crontab not found, generating one\" \n \ time=\$(date -u \"+%s\") \n \ random_minute=\$((\$time % 60)) \n \ - random_hour=\$((\$time / 60 % 24)) \n \ - echo \"\$random_minute \$random_hour * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \ + random_hour=\$((\$time / 60 % 6)) \n \ + echo \"\$random_minute \$random_hour,\$((\$random_hour + 6)),\$((\$random_hour + 12)),\$((\$random_hour + 18)) * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \ fi \n \ echo \"Running Supercronic using crontab at \$LE_CONFIG_HOME/crontab\" \n \ exec -- /usr/bin/supercronic \"\$LE_CONFIG_HOME/crontab\" \n \ diff --git a/README.md b/README.md index 188af0e1..90280e94 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@

- - zerossl.com - + + + + + + + + ZeroSSL + +

🔐 acme.sh

@@ -17,9 +31,13 @@ Solaris DragonFlyBSD MidnightBSD + GhostBSD Omnios OpenIndiana + Tribblix Haiku + Hurd + OpenEuler

@@ -112,6 +130,10 @@ |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) |25|[![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 +|26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix +|27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD +|28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd +|29|[![OpenEuler](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) @@ -146,6 +168,7 @@ | 🌐 DNS mode | Use DNS TXT records | | 🔗 [DNS alias mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode) | Use DNS alias for verification | | 📡 [Stateless mode](https://github.com/acmesh-official/acme.sh/wiki/Stateless-Mode) | Stateless verification | +| 📌 [DNS persist mode](https://github.com/acmesh-official/acme.sh/wiki/DNS-persist-mode) | Persistent DNS TXT record ([draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/)) | --- @@ -396,7 +419,53 @@ acme.sh --renew -d example.com --- -### 🔟 Issue Certificates of Different Key Types (ECC or RSA) +### 🔟 Use DNS Persist Mode + +📖 Wiki: https://github.com/acmesh-official/acme.sh/wiki/DNS-persist-mode + +📚 Spec: [draft-ietf-acme-dns-persist-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) + +DNS persist mode lets you place a **single, long‑lived `_validation-persist` TXT record** in your zone and reuse it for every subsequent issuance and renewal. There is no per-issuance challenge token, so renewals require **no DNS edits** — useful when DNS API access is not available but you still want unattended renewals. + +#### 🪄 Step 1: Print the TXT record value + +```bash +acme.sh --make-dns-persist-value -d example.com [--server letsencrypt] [--dns-persist-wildcard] [--dns-persist-ca-name "sectigo.com"] [--dns-persist-days 365] +``` + +Options: + +| Flag | Description | +|------|-------------| +| `--server ` | Pick the CA (default is your configured default). The account is registered automatically if you have not used this CA before. | +| `--dns-persist-wildcard` | Adds `policy=wildcard` to the record so it also authorizes wildcard / subdomain certs. | +| `--dns-persist-ca-name ` | Use a specific CA identity domain (e.g. `sectigo.com`). If omitted, identities are read from the ACME directory's `caaIdentities` field and one record per identity is printed — you only need to add **any one** of them. | +| `--dns-persist-days ` | Adds `persistUntil=` to the record, set to N days from now. The CA will refuse new validations against the record after that time. Omit for a record with no expiry. | + +You should get an output like: + +```sh +TXT persist domain:_validation-persist.example.com +TXT persist value :"letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/123456789" +``` + +#### ✍️ Step 2: Add the TXT record to your DNS + +Add the printed `TXT persist domain` / `TXT persist value` pair as a TXT record at your DNS provider, then wait for it to propagate. + +#### 📜 Step 3: Issue the certificate + +```bash +acme.sh --issue -d example.com --dns-persist +``` + +✅ **Done!** No challenge token is provisioned during issuance — the CA reads the persistent TXT record directly. + +> 🔄 Renewals just work: `acme.sh --renew -d example.com` (or the cron job) reuses the same TXT record automatically — no further DNS edits needed. + +--- + +### 1️⃣1️⃣ Issue Certificates of Different Key Types (ECC or RSA) Just set the `keylength` to a valid, supported value. @@ -427,7 +496,7 @@ acme.sh --issue -w /home/wwwroot/example.com -d example.com -d www.example.com - --- -### 1️⃣1️⃣ Issue Wildcard Certificates +### 1️⃣2️⃣ Issue Wildcard Certificates It's simple! Just give a wildcard domain as the `-d` parameter: @@ -439,9 +508,9 @@ acme.sh --issue -d example.com -d '*.example.com' --dns dns_cf --- -### 1️⃣2️⃣ How to Renew Certificates +### 1️⃣3️⃣ How to Renew Certificates -> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days. +> 🔄 No need to renew manually! All certs will be renewed automatically every **30** days, **or earlier when the CA's ARI says so** (see below). However, you can force a renewal: @@ -455,9 +524,43 @@ acme.sh --renew -d example.com --force acme.sh --renew -d example.com --force --ecc ``` +#### 📡 ACME Renewal Information (ARI) — RFC 9773 + +📖 Wiki: https://github.com/acmesh-official/acme.sh/wiki/ARI + +If the CA exposes a `renewalInfo` endpoint in its ACME directory (Let's Encrypt, ZeroSSL, etc.), `acme.sh` follows [RFC 9773](https://www.rfc-editor.org/rfc/rfc9773.html) automatically — **no flag needed, no opt-in**: + +| What | When | Why | +|------|------|-----| +| 🔍 **Polls `suggestedWindow`** | Every cron run, before deciding to skip | Lets the CA shift the renewal time forward in case of an incident (key compromise, mass revocation, etc.) | +| 🎯 **Picks a random renewal time** inside the window | Right after a successful issuance/renewal | Disperses renewals across the network so all clients don't hit the CA at the same instant | +| 🔗 **Sends `replaces=`** in `newOrder` | On renewal | Lets the CA correlate the new order with the certificate it supersedes (RFC 9773 §5) | +| ↩️ **Retries without `replaces`** | If the CA rejects with `alreadyReplaced` or an ARI validation error | Robust against edge cases (e.g. switching CAs, retired issuers) | + +**Renewal trigger logic:** the cert is renewed if **any one** of the following becomes true: + +1. `--force` is given +2. The CA's **ARI `suggestedWindow` has started** +3. The cached `Le_NextRenewTime` has passed (default fallback for CAs without ARI) + +You can see the resulting next renewal time (already ARI-picked when applicable) in: + +```sh +acme.sh --info -d example.com +# Look for: Le_NextRenewTimeStr=... +``` + +For the live ARI window the CA is currently advertising, run with `--debug 2`: + +```sh +acme.sh --renew -d example.com --debug 2 2>&1 | grep -i 'ARI suggestedWindow' +``` + +> 💡 If your CA does not advertise `renewalInfo`, `acme.sh` falls back to the classic 30-day rule — no behavior change. + --- -### 1️⃣3️⃣ How to Stop Certificate Renewal +### 1️⃣4️⃣ How to Stop Certificate Renewal To stop renewal of a cert, you can execute the following to remove the cert from the renewal list: @@ -471,7 +574,7 @@ The cert/key file is not removed from the disk. --- -### 1️⃣4️⃣ How to Upgrade acme.sh +### 1️⃣5️⃣ How to Upgrade acme.sh > 🚀 acme.sh is in constant development — it's strongly recommended to use the latest code. @@ -495,25 +598,25 @@ acme.sh --upgrade --auto-upgrade 0 --- -### 1️⃣5️⃣ Issue a Certificate from an Existing CSR +### 1️⃣6️⃣ Issue a Certificate from an Existing CSR 📚 https://github.com/acmesh-official/acme.sh/wiki/Issue-a-cert-from-existing-CSR --- -### 1️⃣6️⃣ Send Notifications in Cronjob +### 1️⃣7️⃣ Send Notifications in Cronjob 📚 https://github.com/acmesh-official/acme.sh/wiki/notify --- -### 1️⃣7️⃣ Under the Hood +### 1️⃣8️⃣ Under the Hood > 🔧 Speak ACME language using shell, directly to "Let's Encrypt". --- -### 1️⃣8️⃣ Acknowledgments +### 1️⃣9️⃣ Acknowledgments | Project | Link | |---------|------| @@ -530,6 +633,8 @@ This project exists thanks to all the people who contribute. +If you want to become a contributor make sure to read [CONTRIBUTING.md](./CONTRIBUTING.md). + ### 💰 Financial Contributors Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/acmesh/contribute)] @@ -555,7 +660,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 1️⃣9️⃣ License & Others +### 2️⃣0️⃣ License & Others 📄 **License:** GPLv3 @@ -565,7 +670,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 2️⃣0️⃣ Donate +### 2️⃣1️⃣ Donate > 💝 Your donation makes **acme.sh** better! @@ -577,7 +682,7 @@ Support this project with your organization. Your logo will show up here with a --- -### 2️⃣1️⃣ About This Repository +### 2️⃣2️⃣ About This Repository > [!NOTE] > This repository is officially maintained by ZeroSSL as part of our commitment to providing secure and reliable SSL/TLS solutions. We welcome contributions and feedback from the community! diff --git a/acme.sh b/acme.sh index e780f61d..4d4f6cbe 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.3 +VER=3.1.5 PROJECT_NAME="acme.sh" @@ -59,6 +59,7 @@ DEFAULT_OPENSSL_BIN="openssl" VTYPE_HTTP="http-01" VTYPE_DNS="dns-01" VTYPE_ALPN="tls-alpn-01" +VTYPE_DNS_PERSIST="dns-persist-01" ID_TYPE_DNS="dns" ID_TYPE_IP="ip" @@ -71,6 +72,7 @@ NO_VALUE="no" W_DNS="dns" W_ALPN="alpn" +W_DNS_PERSIST="dns_persist" DNS_ALIAS_PREFIX="=" MODE_STATELESS="stateless" @@ -916,6 +918,15 @@ _json_decode() { echo "$_j_str" } +#extract the authorization URLs from an order response on stdin, as a +#comma-separated list. The entries are quoted URL strings and a quote cannot +#occur inside a URL, so the first '"]' is always the end of the array. A +#char-class scan would stop early on the brackets of an IPv6 host +#(https://[2001:db8::1]/...). Outputs nothing if the field is missing. +_authorizations_from_order() { + sed -n 's/.*"authorizations" *: *\[//p' | sed 's/" *\].*//' | tr -d '" ' +} + #options file _sed_i() { options="$1" @@ -1013,6 +1024,24 @@ _checkcert() { fi } +#file +_enddate() { + _cf="$1" + _res="$(${ACME_OPENSSL_BIN:-openssl} x509 -noout -enddate -in "$_cf")" + if [ "$?" != "0" ] || [ -z "$_res" ]; then + return 1 + fi + + case "$_res" in + notAfter=*) + echo "${_res#notAfter=}" + ;; + *) + return 1 + ;; + esac +} + #Usage: hashalg [outputhex] #Output Base64-encoded digest _digest() { @@ -1037,6 +1066,25 @@ _digest() { } +#Usage: certpath hashalg +#Output certificate fingerprint without colons +_fingerprint() { + cert="$1" + alg="$2" + if [ -z "$alg" ]; then + _usage "Usage: _fingerprint certpath hashalg" + return 1 + fi + + if [ "$alg" = "sha256" ] || [ "$alg" = "sha1" ] || [ "$alg" = "md5" ]; then + # openssl prints "SHA1 Fingerprint=AA:BB:CC:..."; strip prefix and colons. + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$cert" -noout -fingerprint -"$alg" | sed 's/.*=//; s/://g' + else + _err "$alg is not supported yet" + return 1 + fi +} + #Usage: hashalg secret_hex [outputhex] #Output binary hmac _hmac() { @@ -1140,6 +1188,11 @@ _createkey() { length="$1" f="$2" _debug2 "_createkey for file:$f" + if ! _exists "${ACME_OPENSSL_BIN:-openssl}"; then + _err "Please install openssl first. ACME_OPENSSL_BIN=$ACME_OPENSSL_BIN" + _err "We need openssl to generate keys." + return 1 + fi eccname="$length" if _startswith "$length" "ec-"; then length=$(printf "%s" "$length" | cut -d '-' -f 2-100) @@ -1162,6 +1215,7 @@ _createkey() { _debug "Using length $length" + _new_key_file="" if ! [ -e "$f" ]; then if ! touch "$f" >/dev/null 2>&1; then _f_path="$(dirname "$f")" @@ -1175,6 +1229,7 @@ _createkey() { return 1 fi chmod 600 "$f" + _new_key_file="1" fi if _isEccKey "$length"; then @@ -1183,6 +1238,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for ECC key named $eccname" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi else @@ -1195,6 +1254,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for RSA key of length $length" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi fi @@ -1244,6 +1307,23 @@ _idn() { } #_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage +#cn +#The x509 Common Name is limited to 64 characters (RFC 5280 ub-common-name, +#enforced by openssl in ASN1_mbstring_ncopy), and an IP address or an empty +#name is not usable as CN either. When this rejects the name, _createcsr +#omits CN from the CSR subject and the CA takes the identifiers from the +#subjectAltName extension (issue 4867). +_is_valid_cn() { + _cn_v="$1" + if [ -z "$_cn_v" ] || [ "${#_cn_v}" -gt 64 ]; then + return 1 + fi + if _isIP "$_cn_v"; then + return 1 + fi + return 0 +} + _createcsr() { _debug _createcsr domain="$1" @@ -1307,16 +1387,16 @@ _createcsr() { _csr_cn="$(_idn "$domain")" _debug2 _csr_cn "$_csr_cn" if _contains "$(uname -a)" "MINGW"; then - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi else - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi fi } @@ -1342,7 +1422,9 @@ _readSubjectFromCSR() { _usage "_readSubjectFromCSR mycsr.csr" return 1 fi - ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' + # -config /dev/null: reading a CSR needs no config, but a missing default + # openssl.cnf is fatal on some systems (e.g. NetBSD does not install one) + ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject -config /dev/null | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' } #_csrfile @@ -1357,16 +1439,17 @@ _readSubjectAltNamesFromCSR() { _csrsubj="$(_readSubjectFromCSR "$_csrfile")" _debug _csrsubj "$_csrsubj" - _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')" + _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null | grep "^ *DNS:.*" | tr -d ' \n')" _debug _dnsAltnames "$_dnsAltnames" - if _contains "$_dnsAltnames," "DNS:$_csrsubj,"; then + # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below + _escapedAltnames="$(echo "$_dnsAltnames" | tr '*' '#')" + _debug _escapedAltnames "$_escapedAltnames" + _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" + _debug _escapedSubject "$_escapedSubject" + if _contains "$_escapedAltnames," "DNS:$_escapedSubject,"; then _debug "AltNames contains subject" - _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" - _debug _excapedAlgnames "$_excapedAlgnames" - _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" - _debug _escapedSubject "$_escapedSubject" - _dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")" + _dnsAltnames="$(echo "$_escapedAltnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")" _debug _dnsAltnames "$_dnsAltnames" else _debug "AltNames doesn't contain subject" @@ -1383,7 +1466,7 @@ _readKeyLengthFromCSR() { return 1 fi - _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile")" + _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null)" _debug2 _outcsr "$_outcsr" if _contains "$_outcsr" "Public Key Algorithm: id-ecPublicKey"; then _debug "ECC CSR" @@ -1464,6 +1547,22 @@ _toPkcs() { } +_toPkcs8() { + _cpkcs8="$1" + _ckey="$2" + pkcs8Password="$3" + + if [ "$pkcs8Password" ]; then + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -v2 aes256 -passout "pass:$pkcs8Password" -in "$_ckey" -out "$_cpkcs8" + else + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$_ckey" -out "$_cpkcs8" + fi + if [ "$?" = "0" ]; then + _savedomainconf "Le_PKCS8Password" "$pkcs8Password" "base64" + fi + +} + #domain [password] [isEcc] toPkcs() { domain="$1" @@ -1485,20 +1584,21 @@ toPkcs() { } -#domain [isEcc] +#domain [password] [isEcc] toPkcs8() { domain="$1" + pkcs8Password="$2" if [ -z "$domain" ]; then - _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--ecc]" + _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--password ] [--ecc]" return 1 fi - _isEcc="$2" + _isEcc="$3" _initpath "$domain" "$_isEcc" - ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$CERT_KEY_PATH" -out "$CERT_PKCS8_PATH" + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$pkcs8Password" if [ "$?" = "0" ]; then _info "Success, $CERT_PKCS8_PATH" @@ -1832,22 +1932,89 @@ _date2time() { if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then return fi - #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + #Omnios. Pass the date as argv (sys.argv[1]) instead of interpolating it into + #the -c program text, so a quote in the input cannot inject Python code. + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi _err "Cannot parse _date2time $1" return 1 } +#support the output format of openssl -enddate: +# Apr 01 08:10:33 2022 GMT to 1641283833 +_ssldate2time() { + #Linux + if date -u -d "$1" +"%s" 2>/dev/null; then + return + fi + #Solaris + if gdate -u -d "$1" +"%s" 2>/dev/null; then + return + fi + #Mac/BSD + if date -j -f "%b %d %T %Y %Z" "$1" +"%s" 2>/dev/null; then + return + fi + _err "Cannot parse _ssldate2time $1" + return 1 +} + _utc_date() { date -u "+%Y-%m-%d %H:%M:%S" } +#Usage: _calc_next_renew_time createtime renewaldays [endtime] +#Prints createtime + renewaldays*86400 - 86400, capped so it never passes +#the certificate expiry: with short-lived certs (internal CAs, upcoming +#CA/B SC-081 47-day maximum) a fixed RenewalDays would otherwise schedule +#the renewal after notAfter and leave an expired cert in place. +#The cap is one day before endtime, or one hour before for certs whose +#lifetime is 24 hours or less, mirroring the --valid-to scheduling. +_calc_next_renew_time() { + _cnrt_create="$1" + _cnrt_days="$2" + _cnrt_end="$3" + _cnrt_next=$(_math "$_cnrt_create" + "$_cnrt_days" \* 24 \* 60 \* 60 - 86400) + if [ -z "$_cnrt_end" ]; then + printf "%s" "$_cnrt_next" + return 0 + fi + if [ "$(_math "$_cnrt_end" - "$_cnrt_create")" -gt 86400 ]; then + _cnrt_cap=$(_math "$_cnrt_end" - 86400) + else + _cnrt_cap=$(_math "$_cnrt_end" - 3600) + fi + if [ "$_cnrt_next" -gt "$_cnrt_cap" ]; then + _cnrt_next="$_cnrt_cap" + fi + printf "%s" "$_cnrt_next" +} + +#Usage: _calc_validto_renew_time notaftertime renewaldays now +#Prints the next renew time for a cert issued with a relative --valid-to. +#A negative renewaldays is anchored to the expiry: notaftertime + +#renewaldays*86400. Otherwise the cert renews one day before the expiry, +#or one hour before for certs whose lifetime is 24 hours or less. +_calc_validto_renew_time() { + _cvrt_end="$1" + _cvrt_days="$2" + _cvrt_now="$3" + if [ "$_cvrt_days" ] && [ "$_cvrt_days" -lt 0 ]; then + _math "$_cvrt_end" + "$_cvrt_days" \* 24 \* 60 \* 60 + return 0 + fi + if [ "$(_math "$_cvrt_end" - "$_cvrt_now")" -gt 86400 ]; then + _math "$_cvrt_end" - 86400 + else + _math "$_cvrt_end" - 3600 + fi +} + _mktemp() { if _exists mktemp; then if mktemp 2>/dev/null; then @@ -2474,6 +2641,13 @@ _savedeployconf() { _cleardomainconf "$1" } +#key +_cleardeployconf() { + _cleardomainconf "SAVED_$1" + #remove later + _cleardomainconf "$1" +} + #key _getdeployconf() { _rac_key="$1" @@ -2541,6 +2715,21 @@ _clearcaconf() { _clear_conf "$CA_CONF" "$1" } +#Starts a socat listener in the background, the pid is set to _socat_pid. +#It uses the content, _content_len, _NC and _SOCAT_ERR of _startserver. +#options +_startsocat() { + _socat_opts="$1" + _debug "_NC" "$_NC $_socat_opts" + $_NC $_socat_opts SYSTEM:"sleep 1; \ +echo 'HTTP/1.0 200 OK'; \ +echo 'Content-Length\: $_content_len'; \ +echo ''; \ +printf '%s' '$content';" 2>>"$_SOCAT_ERR" & + _socat_pid="$!" + _debug "_socat_pid" "$_socat_pid" +} + # content localaddress _startserver() { content="$1" @@ -2554,16 +2743,24 @@ _startserver() { _debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V6 "$Le_Listen_V6" + _serverproc_v6="" if _exists "socat"; then _NC="socat" - if [ "$Le_Listen_V6" ]; then + SOCAT_OPTIONS6="" + if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then _NC="$_NC -6" SOCAT_OPTIONS=TCP6-LISTEN - elif [ "$Le_Listen_V4" ]; then + elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then _NC="$_NC -4" SOCAT_OPTIONS=TCP4-LISTEN - else + elif [ "$ncaddr" ]; then + #a single local address belongs to a single family, let socat pick it SOCAT_OPTIONS=TCP-LISTEN + else + #listen on both ipv4 and ipv6, with one socket for each family: + #ipv4-mapped ipv6 addresses are not available everywhere. + SOCAT_OPTIONS=TCP4-LISTEN + SOCAT_OPTIONS6=TCP6-LISTEN fi if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then @@ -2571,6 +2768,10 @@ _startserver() { fi SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork + if [ "$SOCAT_OPTIONS6" ]; then + #ipv6only keeps this socket from colliding with the ipv4 one + SOCAT_OPTIONS6=$SOCAT_OPTIONS6:$Le_HTTPPort,crlf,reuseaddr,fork,ipv6only=1 + fi #Adding bind to local-address if [ "$ncaddr" ]; then @@ -2579,14 +2780,14 @@ _startserver() { _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="$!" + _startsocat "$SOCAT_OPTIONS" + serverproc="$_socat_pid" + if [ "$SOCAT_OPTIONS6" ]; then + #best effort, the host may have no ipv6 support at all + _startsocat "$SOCAT_OPTIONS6" + _serverproc_v6="$_socat_pid" + fi else _PYTHON="" if _exists "python3"; then @@ -2598,21 +2799,40 @@ printf '%s' '$content';" 2>"$_SOCAT_ERR" & 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" + #a comma separated list of addresses to listen on, one socket for each + _BIND_ADDR="0.0.0.0,::" + if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then _BIND_ADDR="::" + elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then + _BIND_ADDR="0.0.0.0" fi if [ "$ncaddr" ]; then _BIND_ADDR="$ncaddr" fi + _debug "_BIND_ADDR" "$_BIND_ADDR" 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]; + $_PYTHON -c "import socket,sys,select +res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3] +ads=sys.argv[2].split(',') +ls=[] +for ad in ads: + try: + sk=socket.socket(socket.AF_INET6 if ':' in ad else socket.AF_INET,socket.SOCK_STREAM) + sk.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + if ':' in ad and len(ads)>1: + sk.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) + sk.bind((ad,int(sys.argv[1]))) + sk.listen(5) + ls.append(sk) + except Exception: + sys.stderr.write(str(sys.exc_info()[1])+'\n') +if not ls: + sys.exit(1) 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" & + for sk in select.select(ls,[],[])[0]: + c,a=sk.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 @@ -2635,6 +2855,11 @@ while True: _stopserver() { pid="$1" _debug "pid" "$pid" + if [ "$_serverproc_v6" ]; then + _debug "_serverproc_v6" "$_serverproc_v6" + kill $_serverproc_v6 >/dev/null 2>&1 + _serverproc_v6="" + fi if [ -z "$pid" ]; then rm -f "$_SOCAT_ERR" return @@ -2708,9 +2933,11 @@ _starttlsserver() { _debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V6 "$Le_Listen_V6" - if [ "$Le_Listen_V4" ]; then + #openssl s_server binds a single socket, so both options together can only + #mean: do not force a family, same as when neither of them is given. + if [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then __S_OPENSSL="$__S_OPENSSL -4" - elif [ "$Le_Listen_V6" ]; then + elif [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then __S_OPENSSL="$__S_OPENSSL -6" fi @@ -2776,14 +3003,45 @@ __initHome() { _debug "Using default home: $DEFAULT_INSTALL_HOME" LE_WORKING_DIR="$DEFAULT_INSTALL_HOME" fi + # Convert a relative --home to an absolute path: later code cd's around + # (e.g. installOnline extracts and enters the archive dir), where a + # relative path would point into the wrong directory. + # https://github.com/acmesh-official/acme.sh/issues/6477 + case "$LE_WORKING_DIR" in + /*) ;; + *) + if [ -d "$LE_WORKING_DIR" ]; then + LE_WORKING_DIR="$(cd "$LE_WORKING_DIR" && pwd)" + fi + ;; + esac export LE_WORKING_DIR if [ -z "$LE_CONFIG_HOME" ]; then LE_CONFIG_HOME="$LE_WORKING_DIR" fi + case "$LE_CONFIG_HOME" in + /*) ;; + *) + if [ -d "$LE_CONFIG_HOME" ]; then + LE_CONFIG_HOME="$(cd "$LE_CONFIG_HOME" && pwd)" + fi + ;; + esac _debug "Using config home: $LE_CONFIG_HOME" export LE_CONFIG_HOME + # Paths with whitespace break the unquoted $_CURL/$_WGET command expansion, + # so fail early with a clear error instead of a cryptic curl/wget failure. + # https://github.com/acmesh-official/acme.sh/issues/2163 + case "$LE_WORKING_DIR$LE_CONFIG_HOME" in + *" "*) + _err "The --home or --config-home path can not contain spaces: '$LE_WORKING_DIR'" + _err "Please install $PROJECT_NAME to a path without spaces." + exit 1 + ;; + esac + _DEFAULT_ACCOUNT_CONF_PATH="$LE_CONFIG_HOME/account.conf" if [ -z "$ACCOUNT_CONF_PATH" ]; then @@ -2879,6 +3137,11 @@ _initAPI() { return 0 fi _err "Cannot init API for $_api_server" + if [ "$_api_server" = "$CA_ZEROSSL" ]; then + _info "$(__green "If this host is IPv6-only: ZeroSSL currently has no IPv6 endpoint.")" + _info "$(__green "Try another CA, e.g.: $PROJECT_ENTRY --set-default-ca --server letsencrypt")" + _info "See: $(__green "https://github.com/acmesh-official/acme.sh/issues/6872")" + fi return 1 } @@ -3309,9 +3572,14 @@ _setNginx() { fi echo "$NGINX_START -location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { - default_type text/plain; - return 200 \"\$1.$_thumbpt\"; +location ^~ /.well-known/acme-challenge/ { + # the ^~ prefix wins over regex-skipping blocks like \"location ^~ /\", + # the nested regex location still captures the token as \$1 + location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { + default_type text/plain; + return 200 \"\$1.$_thumbpt\"; + } + return 404; } #NGINX_START " >>"$FOUND_REAL_NGINX_CONF" @@ -3455,7 +3723,7 @@ _restoreNginx() { done _info "Reloading nginx" - if ! nginx -s reload >/dev/null; then + if ! nginx -s reload >/dev/null 2>&1; then _err "An error occurred while reloading nginx, please open an issue on $PROJECT." return 1 fi @@ -3759,10 +4027,10 @@ _on_issue_success() { #account_key_length eab-kid eab-hmac-key registeraccount() { _account_key_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _initpath - _regAccount "$_account_key_length" "$_eab_id" "$_eab_hmac_key" + _regAccount "$_account_key_length" "$_eab_kid" "$_eab_hmac_key" } __calcAccountKeyHash() { @@ -3773,6 +4041,16 @@ __calc_account_thumbprint() { printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace } +#Reads a comma- or space-separated email list from stdin and prints +#the ACME contact list items: "mailto:a@example.com","mailto:b@example.com" +_mailto_contacts() { + _mc_out="" + for _mc_m in $(tr ',' ' '); do + _mc_out="$_mc_out,\"mailto:$_mc_m\"" + done + echo "$_mc_out" | cut -c 2- +} + _getAccountEmail() { if [ "$ACCOUNT_EMAIL" ]; then echo "$ACCOUNT_EMAIL" @@ -3792,14 +4070,14 @@ _getAccountEmail() { _regAccount() { _initpath _reg_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _debug3 _regAccount "$_regAccount" _initAPI mkdir -p "$CA_DIR" - if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + if [ ! -s "$ACCOUNT_KEY_PATH" ]; then if ! _create_account_key "$_reg_length"; then _err "Error creating account key." return 1 @@ -3809,13 +4087,13 @@ _regAccount() { if ! _calcjwk "$ACCOUNT_KEY_PATH"; then return 1 fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - _savecaconf CA_EAB_KEY_ID "$_eab_id" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi - _eab_id=$(_readcaconf "CA_EAB_KEY_ID") + _eab_kid=$(_readcaconf "CA_EAB_KEY_ID") _eab_hmac_key=$(_readcaconf "CA_EAB_HMAC_KEY") - _secure_debug3 _eab_id "$_eab_id" + _secure_debug3 _eab_kid "$_eab_kid" _secure_debug3 _eab_hmac_key "$_eab_hmac_key" _email="$(_getAccountEmail)" if [ "$_email" ]; then @@ -3823,7 +4101,7 @@ _regAccount() { fi if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then - if [ -z "$_eab_id" ] || [ -z "$_eab_hmac_key" ]; then + if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then _info "No EAB credentials found for ZeroSSL, let's obtain them" if [ -z "$_email" ]; then _info "$(__green "$PROJECT_NAME is using ZeroSSL as default CA now.")" @@ -3832,17 +4110,19 @@ _regAccount() { _info "See: $(__green "$_ZEROSSL_WIKI")" return 1 fi - _eabresp=$(_post "email=$_email" $_ZERO_EAB_ENDPOINT) + #the ZeroSSL EAB endpoint takes a single address, use the first one + _eab_email="$(echo "$_email" | tr ',' ' ' | awk '{print $1}')" + _eabresp=$(_post "email=$_eab_email" $_ZERO_EAB_ENDPOINT) if [ "$?" != "0" ]; then _debug2 "$_eabresp" _err "Cannot get EAB credentials from ZeroSSL." return 1 fi _secure_debug2 _eabresp "$_eabresp" - _eab_id="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" - _secure_debug2 _eab_id "$_eab_id" - if [ -z "$_eab_id" ]; then - _err "Cannot resolve _eab_id" + _eab_kid="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" + _secure_debug2 _eab_kid "$_eab_kid" + if [ -z "$_eab_kid" ]; then + _err "Cannot resolve _eab_kid" return 1 fi _eab_hmac_key="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_hmac_key"' | cut -d : -f 2 | tr -d '"')" @@ -3851,12 +4131,12 @@ _regAccount() { _err "Cannot resolve _eab_hmac_key" return 1 fi - _savecaconf CA_EAB_KEY_ID "$_eab_id" + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_id\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_kid\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" _debug3 eab_protected "$eab_protected" eab_protected64=$(printf "%s" "$eab_protected" | _base64 | _url_replace) @@ -3870,6 +4150,10 @@ _regAccount() { key_hex="$(_durl_replace_base64 "$_eab_hmac_key" | _dbase64 | _hex_dump | tr -d ' ')" _debug3 key_hex "$key_hex" + if [ -z "$key_hex" ]; then + _err "Cannot base64-decode the eab-hmac-key. Please check the value, and your openssl version." + return 1 + fi eab_signature=$(printf "%s" "$eab_sign_t" | _hmac sha256 $key_hex | _base64 | _url_replace) _debug3 eab_signature "$eab_signature" @@ -3878,7 +4162,7 @@ _regAccount() { _debug3 externalBinding "$externalBinding" fi if [ "$_email" ]; then - email_sg="\"contact\": [\"mailto:$_email\"], " + email_sg="\"contact\": [$(echo "$_email" | _mailto_contacts)], " fi regjson="{$email_sg\"termsOfServiceAgreed\": true$externalBinding}" @@ -3914,7 +4198,7 @@ _regAccount() { fi _savecaconf "ACCOUNT_URL" "$_accUri" else - ACCOUNT_URL="$(_readcaconf ACCOUNT_URL)" + _accUri="$(_readcaconf ACCOUNT_URL)" fi export ACCOUNT_URL="$_accUri" @@ -3922,7 +4206,9 @@ _regAccount() { _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" _savecaconf CA_KEY_HASH "$CA_KEY_HASH" - if [ "$code" = '403' ]; then + #RFC 8555 sec 7.3.6 requires 401 for requests from a deactivated account, + #but Boulder (Let's Encrypt) historically returns 403. Accept both. + if [ "$code" = '403' ] || [ "$code" = '401' ]; then _err "It seems that the account key has been deactivated, please use a new account key." return 1 fi @@ -3956,7 +4242,7 @@ updateaccount() { _email="$(_getAccountEmail)" if [ "$_email" ]; then - updjson='{"contact": ["mailto:'$_email'"]}' + updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}' else updjson='{"contact": []}' fi @@ -3966,6 +4252,12 @@ updateaccount() { if [ "$code" = '200' ]; then echo "$response" >"$ACCOUNT_JSON_PATH" _info "Account update success for $_accUri." + # persist the effective mailbox like _regAccount does; otherwise + # "--update-account -m new@..." updates the CA but the local conf + # keeps showing the old address (issue 4673) + if [ "$_email" ]; then + _savecaconf "CA_EMAIL" "$_email" + fi ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)" _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT" @@ -3975,6 +4267,93 @@ updateaccount() { fi } +#Implement account key rollover +updateaccountkey() { + _length="$1" + _initpath + + if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + _err "Account key not found at: $ACCOUNT_KEY_PATH" + return 1 + fi + ACCOUNT_KEY_PATH_NEW="$ACCOUNT_KEY_PATH.new" + + _accUri=$(_readcaconf "ACCOUNT_URL") + _debug _accUri "$_accUri" + + if [ -z "$_accUri" ]; then + _err "The account URL is empty, please run '--update-account' first to update the account info, then try again." + return 1 + fi + if ! _calcjwk "$ACCOUNT_KEY_PATH"; then + return 1 + fi + _inner_payload="{\"account\": \"$_accUri\", \"oldKey\": $jwk}" + + _initAPI + if [ -z "$ACME_KEY_CHANGE" ]; then + _err "Server does not expose keyChange url." + return 1 + fi + + _url="$ACME_KEY_CHANGE" + if _createkey "$_length" "$ACCOUNT_KEY_PATH_NEW"; then + _info "New account key creation OK." + else + _err "New account key creation error." + return 1 + fi + + if ! _calcjwk "$ACCOUNT_KEY_PATH_NEW"; then + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _inner_protected="{\"url\": \"${_url}$JWK_HEADERPLACE_PART2, \"jwk\": $jwk"'}' + _inner_protected64="$(printf "%s" "$_inner_protected" | _base64 | _url_replace)" + _inner_payload64="$(printf "%s" "$_inner_payload" | _base64 | _url_replace)" + if ! _inner_sig_t="$(printf "%s" "$_inner_protected64.$_inner_payload64" | _sign "$ACCOUNT_KEY_PATH_NEW" "sha256")"; then + _err "Sign request failed." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _debug3 _inner_sig_t "$_inner_sig_t" + + _inner_sig="$(printf "%s" "$_inner_sig_t" | _url_replace)" + _debug3 _inner_sig "$_inner_sig" + + _body="{\"protected\": \"$_inner_protected64\", \"payload\": \"$_inner_payload64\", \"signature\": \"$_inner_sig\"}" + + if ! _send_signed_request "$_url" "$_body" "" "$ACCOUNT_KEY_PATH"; then + _err "Error rotating account key: $response." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + if [ "$code" = '200' ]; then + echo "$response" >"$ACCOUNT_JSON_PATH" + mv -f "$ACCOUNT_KEY_PATH_NEW" "$ACCOUNT_KEY_PATH" + _info "Account key rotation success for $_accUri." + elif [ "$code" = "409" ]; then + _err "An existing account is using the new key" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + else + _err "Account key rollover error: $response" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + __CACHED_JWK_KEY_FILE="" + _calcjwk "$ACCOUNT_KEY_PATH" + + ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)" + _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT" + + CA_KEY_HASH="$(__calcAccountKeyHash)" + _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" + _savecaconf CA_KEY_HASH "$CA_KEY_HASH" +} + #Implement deactivate account deactivateaccount() { _initpath @@ -4002,7 +4381,8 @@ deactivateaccount() { if _send_signed_request "$_accUri" "$_djson" && _contains "$response" '"deactivated"'; then _info "Successfully deactivated account $_accUri." _accid=$(echo "$response" | _egrep_o "\"id\" *: *[^,]*," | cut -d : -f 2 | tr -d ' ,') - elif [ "$code" = "403" ]; then + elif [ "$code" = "403" ] || [ "$code" = "401" ]; then + #RFC 8555 sec 7.3.6: 401 from a deactivated account; Boulder returns 403 _info "The account is already deactivated." _accid=$(_getfield "$_accUri" "999" "/") else @@ -4028,6 +4408,134 @@ deactivateaccount() { fi } +#domain +#Print the Validation Domain Name where the persistent TXT record must be +#published: the "_validation-persist" label prepended to the domain being +#validated (draft-ietf-acme-dns-persist-01 sec 4). +#A wildcard identifier is validated by the record at its base domain, so the +#leading "*." label is dropped: the wildcard scope comes from 'policy=wildcard' +#in the record value, not from a "*" label in the record name (sec 5.1, 10.2). +_dns_persist_txt_name() { + _dpt_domain="$1" + if _startswith "$_dpt_domain" "*."; then + _dpt_domain="$(echo "$_dpt_domain" | sed 's/^\*\.//')" + fi + if [ -z "$_dpt_domain" ]; then + return 1 + fi + echo "_validation-persist.$_dpt_domain" +} + +#domain wildcard ca_name days +#Print the TXT record(s) the user must add to enable persistent DNS validation +#per draft-ietf-acme-dns-persist-01. +makednspersistvalue() { + _mdpv_domain="$1" + _mdpv_wildcard="$2" + _mdpv_ca_name="$3" + _mdpv_days="$4" + + if [ -z "$_mdpv_domain" ]; then + _err "Please specify a domain with -d." + return 1 + fi + + _txt_name="$(_dns_persist_txt_name "$_mdpv_domain")" + if [ -z "$_txt_name" ]; then + _err "Invalid domain: $_mdpv_domain" + return 1 + fi + _debug _txt_name "$_txt_name" + + #A wildcard identifier can only be issued if the record carries + #'policy=wildcard', so don't print a record that is guaranteed to fail. + if _startswith "$_mdpv_domain" "*." && [ "$_mdpv_wildcard" != "1" ]; then + _info "$_mdpv_domain is a wildcard domain, adding 'policy=wildcard' automatically." + _mdpv_wildcard="1" + fi + + if [ -n "$_mdpv_days" ]; then + case "$_mdpv_days" in + '' | *[!0-9]*) + _err "--dns-persist-days must be a positive integer, got: $_mdpv_days" + return 1 + ;; + esac + if [ "$_mdpv_days" -lt 1 ]; then + _err "--dns-persist-days must be at least 1." + return 1 + fi + fi + + _initpath + + _accUri="$(_readcaconf ACCOUNT_URL)" + if [ -z "$_accUri" ]; then + _info "No account is registered for $ACME_DIRECTORY yet, registering one now..." + if ! _regAccount "$DEFAULT_ACCOUNT_KEY_LENGTH"; then + _err "Cannot register account." + return 1 + fi + _accUri="$(_readcaconf ACCOUNT_URL)" + fi + + if [ -z "$_accUri" ]; then + _err "Cannot determine the ACME account URL." + return 1 + fi + _debug "Account URL" "$_accUri" + + _txt_suffix="; accounturi=$_accUri" + if [ "$_mdpv_wildcard" = "1" ]; then + _txt_suffix="$_txt_suffix; policy=wildcard" + fi + if [ -n "$_mdpv_days" ]; then + _persist_until=$(_math "$(_time)" + "$_mdpv_days" \* 86400) + _txt_suffix="$_txt_suffix; persistUntil=$_persist_until" + _info "persistUntil set to $(__green "$(_time2str "$_persist_until")") ($_mdpv_days days from now)" + fi + + if [ -n "$_mdpv_ca_name" ]; then + _info "" + _info "Add the following DNS TXT record to enable persistent DNS validation:" + _info "" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_mdpv_ca_name$_txt_suffix\"")")" + _info "" + return 0 + fi + + _info "Fetching ACME directory: $ACME_DIRECTORY" + _dir_resp="$(_get "$ACME_DIRECTORY" "" 30)" + if [ "$?" != "0" ] || [ -z "$_dir_resp" ]; then + _err "Cannot fetch ACME directory: $ACME_DIRECTORY" + return 1 + fi + _dir_resp="$(echo "$_dir_resp" | _json_decode)" + _debug2 _dir_resp "$_dir_resp" + + _caa_array="$(echo "$_dir_resp" | tr -d ' \r\n\t' | _egrep_o '"caaIdentities":\[[^]]*\]')" + _debug2 _caa_array "$_caa_array" + _caaids="$(echo "$_caa_array" | sed 's/.*\[//' | sed 's/\].*//' | tr ',' '\n' | tr -d '"')" + _debug2 _caaids "$_caaids" + + if [ -z "$_caaids" ]; then + _err "The directory does not include 'caaIdentities'. Please specify --dns-persist-ca-name explicitly." + return 1 + fi + + _info "" + _info "Add ANY ONE of the following DNS TXT records to enable persistent DNS validation." + _info "(You only need to add one; pick whichever issuer identity you prefer.)" + for _id in $_caaids; do + [ -z "$_id" ] && continue + _info "" + _info "$(printf 'TXT persist domain:%s' "$(__green "$_txt_name")")" + _info "$(printf 'TXT persist value :%s' "$(__green "\"$_id$_txt_suffix\"")")" + done + _info "" +} + # domain folder file _findHook() { _hookdomain="$1" @@ -4377,16 +4885,25 @@ _match_issuer() { #ip _isIPv4() { - for seg in $(echo "$1" | tr '.' ' '); do - _debug2 seg "$seg" - if [ "$(echo "$seg" | tr -d '[0-9]')" ]; then - #not all number + #splitting must not glob: a "*" segment would match files in cwd + set -f + _ipv4_saved_ifs="$IFS" + IFS='.' + # shellcheck disable=SC2086 + set -- $1 + IFS="$_ipv4_saved_ifs" + set +f + if [ $# -ne 4 ]; then + return 1 + fi + for _ipv4_seg in "$@"; do + _debug2 _ipv4_seg "$_ipv4_seg" + case "$_ipv4_seg" in + *[!0-9]* | "") return 1 ;; + esac + if [ "${#_ipv4_seg}" -gt 3 ] || [ "$_ipv4_seg" -gt 255 ]; then return 1 fi - if [ $seg -ge 0 ] && [ $seg -lt 256 ]; then - continue - fi - return 1 done return 0 } @@ -4485,11 +5002,18 @@ issue() { if [ -z "$_ACME_IS_RENEW" ]; then _initpath "$_main_domain" "$_key_length" mkdir -p "$DOMAIN_PATH" - elif ! _hasfield "$_web_roots" "$W_DNS"; then + elif [ -z "$Le_Vlist" ]; then + # Whether the saved order is resumed is decided by Le_Vlist below, so key + # this on Le_Vlist too. With no pending order to resume a new one is + # created, and a stale order link from the previous issuance must not be + # reused. https://github.com/acmesh-official/acme.sh/issues/3635 Le_OrderFinalize="" Le_LinkOrder="" - Le_LinkCert="" fi + # Per-run state only: it is set after finalize and never read back from the + # saved domain conf. Carrying it over would make a run that gives up while + # the order is still 'processing' download the previous certificate again. + Le_LinkCert="" if _hasfield "$_web_roots" "$W_DNS" && [ -z "$FORCE_DNS_MANUAL" ]; then _err "$_DNS_MANUAL_ERROR" @@ -4499,7 +5023,7 @@ issue() { if [ -f "$DOMAIN_CONF" ]; then Le_NextRenewTime=$(_readdomainconf Le_NextRenewTime) _debug Le_NextRenewTime "$Le_NextRenewTime" - if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _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" @@ -4551,6 +5075,13 @@ issue() { else _cleardomainconf "Le_ChallengeAlias" fi + # Save Le_DNSSleep unconditionally here: the save inside the dns_entries + # branch is skipped when all authorizations are already valid (e.g. issuing + # the ECC twin of a just-issued RSA cert), which left the setting out of + # that cert's conf. https://github.com/acmesh-official/acme.sh/issues/6986 + if [ "$Le_DNSSleep" ]; then + _savedomainconf "Le_DNSSleep" "$Le_DNSSleep" + fi if [ "$_preferred_chain" ]; then _savedomainconf "Le_Preferred_Chain" "$_preferred_chain" "base64" else @@ -4580,7 +5111,7 @@ issue() { _debug2 _saved_account_key_hash "$_saved_account_key_hash" if [ -z "$ACCOUNT_URL" ] || [ -z "$_saved_account_key_hash" ] || [ "$_saved_account_key_hash" != "$(__calcAccountKeyHash)" ]; then - if ! _regAccount "$_accountkeylength"; then + if ! _regAccount "$_accountkeylength" "$_eab_kid" "$_eab_hmac_key"; then _on_issue_err "$_post_hook" return 1 fi @@ -4709,13 +5240,45 @@ issue() { if [ "$_certificate_profile" ]; then _newOrderObj="$_newOrderObj,\"profile\": \"$_certificate_profile\"" fi + + # RFC 9773 Section 5: include "replaces" only when this is an actual + # renewal (--renew path), the CA advertises renewalInfo, and a prior + # cert exists. --issue (even with --force) is not a renewal per RFC 9773 + # which speaks of "a clear predecessor certificate" issued by this CA. + # NO_ARI=1 (env, account.conf, or ca.conf) disables ARI entirely, so the + # "replaces" field is also omitted. + _replaces_certID="" + if [ "$NO_ARI" = "1" ]; then + _debug "NO_ARI=1, omitting ARI 'replaces' field from newOrder" + elif [ "$_ACME_IS_RENEW" = "1" ] && [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then + _replaces_certID="$(_getARICertID "$CERT_PATH")" + _debug "Adding ARI replaces" "$_replaces_certID" + fi + _debug "STEP 1, Ordering a Certificate" - if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _newOrderReplacesObj="$_newOrderObj" + if [ "$_replaces_certID" ]; then + _newOrderReplacesObj="$_newOrderObj,\"replaces\": \"$_replaces_certID\"" + fi + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderReplacesObj}"; then _err "Error creating new order." _clearup _on_issue_err "$_post_hook" return 1 fi + # RFC 9773 Section 5 only defines the "alreadyReplaced" error, but real CAs + # (Let's Encrypt) may also reject with a malformed error if the prior cert + # was issued by a different issuer / different CA. Retry without "replaces" + # whenever the failure mentions ARI or the replaces field. + if [ "$_replaces_certID" ] && { _contains "$response" "alreadyReplaced" || _contains "$response" "urn:ietf:params:acme:error:malformed" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then + _info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'." + if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then + _err "Error creating new order." + _clearup + _on_issue_err "$_post_hook" + return 1 + fi + fi if _contains "$response" "invalid"; then if echo "$response" | _normalizeJson | grep '"status":"invalid"' >/dev/null 2>&1; then _err "Create new order with invalid status." @@ -4740,7 +5303,7 @@ issue() { #for dns manual mode _savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize" - _authorizations_seg="$(echo "$response" | _json_decode | _egrep_o '"authorizations" *: *\[[^\[]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." @@ -4806,7 +5369,9 @@ $_authorizations_map" vtype="$VTYPE_HTTP" #todo, v2 wildcard force to use dns - if _startswith "$_currentRoot" "$W_DNS"; then + if [ "$_currentRoot" = "$W_DNS_PERSIST" ]; then + vtype="$VTYPE_DNS_PERSIST" + elif _startswith "$_currentRoot" "$W_DNS"; then vtype="$VTYPE_DNS" fi @@ -4849,7 +5414,7 @@ $_authorizations_map" fi # 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'"[^\}]*')" + entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^{]*"type":"'$vtype'"[^}]*')" _debug entry "$entry" if [ -z "$keyauthorization" -a -z "$entry" ]; then @@ -4864,18 +5429,7 @@ $_authorizations_map" fi if [ -z "$keyauthorization" ]; then - token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')" - _debug token "$token" - - if [ -z "$token" ]; then - _err "Cannot get domain token $entry" - _clearup - _on_issue_err "$_post_hook" - return 1 - fi - uri="$(echo "$entry" | _egrep_o '"url":"[^"]*' | cut -d '"' -f 4 | _head_n 1)" - _debug uri "$uri" if [ -z "$uri" ]; then @@ -4884,8 +5438,26 @@ $_authorizations_map" _on_issue_err "$_post_hook" return 1 fi - keyauthorization="$token.$thumbprint" - _debug keyauthorization "$keyauthorization" + + if [ "$vtype" = "$VTYPE_DNS_PERSIST" ]; then + # dns-persist-01 challenges have no token; the TXT record is + # provisioned out-of-band. Use a non-empty placeholder so the + # downstream code does not treat this entry as already verified. + keyauthorization="$VTYPE_DNS_PERSIST" + _debug keyauthorization "$keyauthorization" + else + token="$(echo "$entry" | _egrep_o '"token":"[^"]*' | cut -d : -f 2 | tr -d '"')" + _debug token "$token" + + if [ -z "$token" ]; then + _err "Cannot get domain token $entry" + _clearup + _on_issue_err "$_post_hook" + return 1 + fi + keyauthorization="$token.$thumbprint" + _debug keyauthorization "$keyauthorization" + fi fi dvlist="$d$sep$keyauthorization$sep$uri$sep$vtype$sep$_currentRoot$sep$_authz_url" @@ -4921,6 +5493,8 @@ $_authorizations_map" fi _d_alias="$(_getfield "$_challenge_alias" "$_alias_index")" test "$_d_alias" = "$NO_VALUE" && _d_alias="" + # strip the trailing dot of a fully-qualified alias domain + _d_alias="${_d_alias%.}" _alias_index="$(_math "$_alias_index" + 1)" _debug "_d_alias" "$_d_alias" if [ "$_d_alias" ]; then @@ -5115,6 +5689,8 @@ $_authorizations_map" fi fi elif [ "$vtype" = "$VTYPE_ALPN" ]; then + _ncaddr="$(_getfield "$_local_addr" "$_ncIndex")" + _ncIndex="$(_math $_ncIndex + 1)" acmevalidationv1="$(printf "%s" "$keyauthorization" | _digest "sha256" "hex")" _debug acmevalidationv1 "$acmevalidationv1" if ! _starttlsserver "$d" "" "$Le_TLSPort" "$keyauthorization" "$_ncaddr" "$acmevalidationv1"; then @@ -5170,7 +5746,7 @@ $_authorizations_map" status=$(echo "$response" | _egrep_o '"status":"[^"]*' | cut -d : -f 2 | tr -d '"') _debug2 status "$status" if _contains "$status" "invalid"; then - error="$(echo "$response" | _egrep_o '"error":\{[^\}]*')" + error="$(echo "$response" | _egrep_o '"error":[{][^}]*')" _debug2 error "$error" errordetail="$(echo "$error" | _egrep_o '"detail": *"[^"]*' | cut -d '"' -f 4)" _debug2 errordetail "$errordetail" @@ -5345,7 +5921,14 @@ $_authorizations_map" return 1 fi - echo "$response" >"$CERT_PATH" + if ! _contains "$response" "$BEGIN_CERT"; then + response="$(echo "$response" | _dbase64 "multiline" | tr -d '\0' | _normalizeJson)" + _err "Signing failed: $(echo "$response" | _egrep_o '"detail":"[^"]*"')" + _on_issue_err "$_post_hook" + return 1 + fi + + echo "$response" | _strip_blank_lines >"$CERT_PATH" _split_cert_chain "$CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CA_CERT_PATH" if [ -z "$_preferred_chain" ]; then _preferred_chain=$(_readcaconf DEFAULT_PREFERRED_CHAIN) @@ -5364,10 +5947,15 @@ $_authorizations_map" _err "$response" continue fi + + if ! _contains "$response" "$BEGIN_CERT"; then + _debug2 "Skipping alternate cert link due to unexpected response format." + continue + fi _relcert="$CERT_PATH.alt" _relfullchain="$CERT_FULLCHAIN_PATH.alt" _relca="$CA_CERT_PATH.alt" - echo "$response" >"$_relcert" + echo "$response" | _strip_blank_lines >"$_relcert" _split_cert_chain "$_relcert" "$_relfullchain" "$_relca" if [ "$DEBUG" ]; then _debug "rel chain issuers: " "$(_get_chain_issuers "$_relfullchain")" @@ -5427,7 +6015,7 @@ $_authorizations_map" Le_CertCreateTimeStr=$(_time2str "$Le_CertCreateTime") _savedomainconf "Le_CertCreateTimeStr" "$Le_CertCreateTimeStr" - if [ -z "$Le_RenewalDays" ] || [ "$Le_RenewalDays" -lt "0" ]; then + if [ -z "$Le_RenewalDays" ]; then Le_RenewalDays="$DEFAULT_RENEW" else _savedomainconf "Le_RenewalDays" "$Le_RenewalDays" @@ -5451,12 +6039,17 @@ $_authorizations_map" _clearaccountconf "HTTPS_INSECURE" fi - if [ "$Le_Listen_V4" ]; then - _savedomainconf "Le_Listen_V4" "$Le_Listen_V4" - _cleardomainconf Le_Listen_V6 - elif [ "$Le_Listen_V6" ]; then - _savedomainconf "Le_Listen_V6" "$Le_Listen_V6" - _cleardomainconf Le_Listen_V4 + if [ "$Le_Listen_V4" ] || [ "$Le_Listen_V6" ]; then + if [ "$Le_Listen_V4" ]; then + _savedomainconf "Le_Listen_V4" "$Le_Listen_V4" + else + _cleardomainconf Le_Listen_V4 + fi + if [ "$Le_Listen_V6" ]; then + _savedomainconf "Le_Listen_V6" "$Le_Listen_V6" + else + _cleardomainconf Le_Listen_V6 + fi fi if [ "$Le_ForceNewDomainKey" = "1" ]; then @@ -5472,25 +6065,75 @@ $_authorizations_map" _info "It cannot be renewed automatically" _info "See: $_VALIDITY_WIKI" else - _now=$(_time) - _debug2 "_now" "$_now" - _lifetime=$(_math $Le_NextRenewTime - $_now) - _debug2 "_lifetime" "$_lifetime" - if [ $_lifetime -gt 86400 ]; then - #if lifetime is logner than one day, it will renew one day before - Le_NextRenewTime=$(_math $Le_NextRenewTime - 86400) - Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") - else - #if lifetime is less than 24 hours, it will renew one hour before - Le_NextRenewTime=$(_math $Le_NextRenewTime - 3600) - Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") - fi + Le_NextRenewTime=$(_calc_validto_renew_time "$Le_NextRenewTime" "$Le_RenewalDays" "$(_time)") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi + elif [ "$Le_RenewalDays" -lt "0" ]; then + _enddate_value=$(_enddate "$CERT_PATH") + if [ "$?" != "0" ] || [ -z "$_enddate_value" ]; then + _err "Failed to get certificate end date for $CERT_PATH" + return 1 + fi + + _endtime=$(_ssldate2time "$_enddate_value") + if [ "$?" != "0" ] || [ -z "$_endtime" ]; then + _err "Cannot parse _enddate_value: $_enddate_value" + return 1 + fi + Le_NextRenewTime=$(_math "$_endtime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") else - Le_NextRenewTime=$(_math "$Le_CertCreateTime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) - Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400) + _endtime_for_cap="" + _enddate_value=$(_enddate "$CERT_PATH") + if [ "$?" = "0" ] && [ "$_enddate_value" ]; then + _endtime_for_cap=$(_ssldate2time "$_enddate_value") + fi + Le_NextRenewTime=$(_calc_next_renew_time "$Le_CertCreateTime" "$Le_RenewalDays" "$_endtime_for_cap") Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi + + # RFC 9773 ARI: if the CA exposes renewalInfo, override Le_NextRenewTime + # with a time picked at random within the suggestedWindow. This both gives + # the CA full control over renewal scheduling and disperses renewals across + # the network so all clients don't hit the CA at the same instant. + # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and fall back to + # the legacy time-based renewal calculation. + if [ "$NO_ARI" = "1" ]; then + _debug "NO_ARI=1, skipping ARI suggestedWindow override" + elif [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then + _ari_resp_new="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp_new" "$_ari_resp_new" + _ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end_new="$(echo "$_ari_resp_new" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then + _ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")" + _ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")" + if [ "$_ari_start_t_new" ] && [ "$_ari_end_t_new" ] && [ "$_ari_end_t_new" -gt "$_ari_start_t_new" ]; then + _ari_window=$(_math "$_ari_end_t_new" - "$_ari_start_t_new") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")" + _info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + fi + fi + fi + + # Warn when the scheduled renewal falls after the cert has already expired, + # e.g. a 1-day cert from an internal CA combined with the default 30-day + # schedule, which computes from the creation date and never looks at + # notAfter. Skip the warning for a fixed-date --valid-to: there + # Le_NextRenewTime equals the expiry by design and the non-renewable state + # was already reported above. https://github.com/acmesh-official/acme.sh/issues/6917 + if [ -z "$_valid_to" ] || _startswith "$_valid_to" "+"; then + _renew_chk_enddate="$(_enddate "$CERT_PATH")" + _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")" + if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then + _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")" + _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")" + fi + fi + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" @@ -5500,6 +6143,12 @@ $_authorizations_map" _toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword" fi + #convert to pkcs8 + Le_PKCS8Password="$(_readdomainconf Le_PKCS8Password)" + if [ "$Le_PKCS8Password" ]; then + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$Le_PKCS8Password" + fi + if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then _savedomainconf "Le_RealCertPath" "$_real_cert" _savedomainconf "Le_RealCACertPath" "$_real_ca" @@ -5518,6 +6167,18 @@ $_authorizations_map" } #in_out_cert out_fullchain out_ca +#Reads a PEM chain from stdin, prints it without the blank lines. +#Some CAs (Let's Encrypt) separate the certificates of a chain with a blank +#line, others (ZeroSSL) don't. The blank lines are valid PEM (RFC 7468), but +#some devices and APIs reject them, so the certs are stored back to back. +#https://github.com/acmesh-official/acme.sh/issues/1940 +_strip_blank_lines() { + #spell out space and tab: Solaris sed treats [[:space:]] as a literal + #bracket set and silently stops matching the blank lines + _sbl_tab="$(printf '\t')" + sed "/^[ $_sbl_tab]*\$/d" +} + _split_cert_chain() { _certf="$1" _fullchainf="$2" @@ -5546,7 +6207,7 @@ renew() { _debug "_renewServer" "$_renewServer" _initpath "$Le_Domain" "$_isEcc" - + _info "Renew: $Le_Domain" _set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT} _info "$(__green "Renewing: '$Le_Domain'")" if [ ! -f "$DOMAIN_CONF" ]; then @@ -5578,6 +6239,14 @@ renew() { fi _info "Renewing using Le_API=$Le_API" + # Honor --local-address given on the renew/renewAll command line: it overrides + # the value saved at issue time (and gets re-saved by issue() below), so certs + # issued before the machine gained multiple addresses can still be renewed. + # https://github.com/acmesh-official/acme.sh/issues/7009 + if [ "$_local_address" ]; then + Le_LocalAddress="$_local_address" + fi + _clearAPI _clearCA export ACME_DIRECTORY="$Le_API" @@ -5586,7 +6255,77 @@ renew() { _debug2 "initpath again." _initpath "$Le_Domain" "$_isEcc" + # ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check. + # If the window has started, renew now even if Le_NextRenewTime is in the future. + # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only + # Le_NextRenewTime for the renewal decision. + if [ "$NO_ARI" = "1" ]; then + _debug "NO_ARI=1, skipping ARI suggestedWindow check" + elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then + if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then + _ari_resp="$(_get_ARI "$CERT_PATH")" + _debug2 "_ari_resp" "$_ari_resp" + _ari_start="$(echo "$_ari_resp" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" + _ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" + _debug "ARI suggestedWindow.start" "$_ari_start" + _debug "ARI suggestedWindow.end" "$_ari_end" + if [ "$_ari_start" ] && [ "$_ari_end" ]; then + _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _ari_end_t="$(_date2time "$(echo "$_ari_end" | sed 's/\.[0-9]*//')")" + _ari_explanation_url="$(echo "$_ari_resp" | _egrep_o '"explanationURL" *: *"[^"]*' | sed 's/.*"//')" + _debug "_ari_start_t" "$_ari_start_t" + _debug "_ari_end_t" "$_ari_end_t" + _debug "_ari_explanation_url" "$_ari_explanation_url" + _debug "Le_NextRenewTime" "$Le_NextRenewTime" + # Update ARI if needed + if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then + _ari_old_time_str="$Le_NextRenewTimeStr" + _info "Current renewal time: $(__green "$_ari_old_time_str")" + _ari_window=$(_math "$_ari_end_t" - "$_ari_start_t") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")" + _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + _savedomainconf Le_NextRenewTime "$Le_NextRenewTime" + _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr" + fi + if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then + _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal." + if [ "$_ari_explanation_url" ]; then + _info "For more information on this renewal: $(__green "$_ari_explanation_url")" + fi + fi + fi + fi + fi + if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + _renew_retry_fixed="" + res="0" + _ensure_install "$Le_Domain" + res="$?" + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then + _ensure_deploy "$Le_Domain" + res="$?" + fi + if [ "$res" != "0" ]; then + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_ERROR ]; then + _send_notify "Renew $Le_Domain error" "There is an error." "$NOTIFY_HOOK" 1 + fi + fi + return 1 + fi + if [ "$_renew_retry_fixed" ]; then + _info "Install/deploy retry succeeded, no renewal is needed." + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_RENEW ]; then + _send_notify "Renew $Le_Domain success" "Good, the cert install/deploy retry succeeded." "$NOTIFY_HOOK" 0 + fi + fi + return 0 + fi _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -5629,11 +6368,8 @@ renew() { fi issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile" "$Le_ExtKeyUse" res="$?" - if [ "$res" != "0" ]; then - return "$res" - fi - if [ "$Le_DeployHook" ]; then + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then _deploy "$Le_Domain" "$Le_DeployHook" res="$?" fi @@ -5673,6 +6409,10 @@ renewAll() { _set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT} _debug "_set_level" "$_set_level" export _ACME_IN_RENEWALL=1 + if ! [ -d "$CERT_HOME" ]; then + _err "$CERT_HOME is not a directory, please check your configuration." + return 1 + fi for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do _debug di "$di" if ! [ -d "$di" ]; then @@ -5681,12 +6421,19 @@ renewAll() { fi d=$(basename "$di") _debug d "$d" + _d_ari="$di.ari" + _debug _d_ari "$_d_ari" ( if _endswith "$d" "$ECC_SUFFIX"; then _isEcc=$(echo "$d" | cut -d "$ECC_SEP" -f 2) d=$(echo "$d" | cut -d "$ECC_SEP" -f 1) fi renew "$d" "$_isEcc" "$_server" + rc="$?" + if [ "$rc" = "0" ] && [ "$_ari_explanation_url" ]; then + echo "$_ari_explanation_url" >"$_d_ari" + fi + return $rc ) rc="$?" _debug "Return code: $rc" @@ -5701,8 +6448,13 @@ renewAll() { _send_notify "Renew $d success" "Good, the cert is renewed." "$NOTIFY_HOOK" 0 fi fi + _renewal_explanation="" + if [ -f "$_d_ari" ]; then + _renewal_explanation=" ($(cat "$_d_ari"))" + rm -f "$_d_ari" + fi - _success_msg="${_success_msg} $d + _success_msg="${_success_msg} $d$_renewal_explanation " elif [ "$rc" = "$RENEW_SKIP" ]; then if [ $_error_level -gt $NOTIFY_LEVEL_SKIP ]; then @@ -5945,7 +6697,7 @@ list_profiles() { fi normalized_response=$(echo "$response" | _normalizeJson) - profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *\{[^\}]*\}') + 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." @@ -6007,6 +6759,45 @@ _deploy() { _info "$(__green Success)" fi done + + _deploy_success_time="$(_time)" + _savedomainconf "Le_DeploySuccessTime" "$_deploy_success_time" + _savedomainconf "Le_DeploySuccessTimeStr" "$(_time2str "$_deploy_success_time")" +} + +_ensure_deploy() { + _d="$1" + if [ -z "$Le_DeployHook" ]; then + return 0 + fi + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _deploy_success_time="$(_readdomainconf Le_DeploySuccessTime)" + if [ -z "$_deploy_success_time" ]; then + _debug "Le_DeploySuccessTime is empty, skip deploy retry check." + return 0 + fi + case "$_deploy_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_DeploySuccessTime or Le_CertCreateTime is not a number, skip deploy retry check." + return 0 + ;; + esac + + if [ "$_deploy_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful deploy, retrying deploy hooks." + if _deploy "$_d" "$Le_DeployHook"; then + _info "Deploy retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Deploy retry failed." + return 1 + fi + + return 0 } #domain hooks @@ -6027,7 +6818,13 @@ deploy() { fi _debug2 DOMAIN_CONF "$DOMAIN_CONF" - . "$DOMAIN_CONF" + # The cert dir may exist without a domain conf (e.g. the conf was deleted, or + # the cert was placed here manually). Deploy can still proceed using env-provided + # settings, and _savedomainconf below will recreate the conf, so only source it + # when present instead of failing on a missing file. + if [ -f "$DOMAIN_CONF" ]; then + . "$DOMAIN_CONF" + fi _savedomainconf Le_DeployHook "$_hooks" @@ -6160,9 +6957,54 @@ _installcert() { _info "$(__green "Reload successful")" else _err "Reload error for: $_main_domain" + return 1 fi fi + _installcert_success_time="$(_time)" + _savedomainconf "Le_InstallCertSuccessTime" "$_installcert_success_time" + _savedomainconf "Le_InstallCertSuccessTimeStr" "$(_time2str "$_installcert_success_time")" +} + +_ensure_install() { + _d="$1" + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _real_cert="$(_readdomainconf Le_RealCertPath)" + _real_key="$(_readdomainconf Le_RealKeyPath)" + _real_ca="$(_readdomainconf Le_RealCACertPath)" + _reload_cmd="$(_readdomainconf Le_ReloadCmd)" + _real_fullchain="$(_readdomainconf Le_RealFullChainPath)" + if [ -z "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then + return 0 + fi + + _installcert_success_time="$(_readdomainconf Le_InstallCertSuccessTime)" + if [ -z "$_installcert_success_time" ]; then + _debug "Le_InstallCertSuccessTime is empty, skip install retry check." + return 0 + fi + case "$_installcert_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_InstallCertSuccessTime or Le_CertCreateTime is not a number, skip install retry check." + return 0 + ;; + esac + + if [ "$_installcert_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful install, retrying install cert." + if _installcert "$_d" "$_real_cert" "$_real_key" "$_real_ca" "$_real_fullchain" "$_reload_cmd"; then + _info "Install cert retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Install cert retry failed." + return 1 + fi + + return 0 } __read_password() { @@ -6182,6 +7024,7 @@ _install_win_taskscheduler() { _lesh="$1" _centry="$2" _randomminute="$3" + _randomhour="$4" if ! _exists cygpath; then _err "cygpath not found" return 1 @@ -6208,8 +7051,10 @@ _install_win_taskscheduler() { _info "$PROJECT_NAME will not save your password." _info "Please input your Windows password for: $(__green "$_myname")" _password="$(__read_password)" - #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null - echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null + #schtasks.exe /ST requires the HH:mm format, so the minute must be zero-padded (issue 4950) + _st_minute="$(printf "%02d" "$_randomminute")" + #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null + echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null echo } @@ -6251,7 +7096,7 @@ installcronjob() { fi _t=$(_time) random_minute=$(_math $_t % 60) - random_hour=$(_math $_t / 60 % 24) + random_hour=$(_math $_t / 60 % 6) if ! _exists "$_CRONTAB" && _exists "fcrontab"; then _CRONTAB="fcrontab" @@ -6260,7 +7105,7 @@ installcronjob() { if ! _exists "$_CRONTAB"; then if _exists cygpath && _exists schtasks.exe; then _info "It seems you are on Windows, let's install the Windows scheduler task." - if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute"; then + if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute" "$random_hour"; then _info "Successfully installed Windows scheduler task." return 0 else @@ -6274,15 +7119,39 @@ installcronjob() { return 1 fi _info "Installing cron job" - if ! $_CRONTAB -l | grep "$PROJECT_ENTRY --cron"; then + _cron_entry="$random_minute $random_hour,$(_math "$random_hour" + 6),$(_math "$random_hour" + 12),$(_math "$random_hour" + 18) * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + _cron_entries="$($_CRONTAB -l 2>/dev/null)" + if [ "$?" != "0" ]; then + #when the user has no crontab yet, crontab -l also exits non-zero; + #only that case may proceed with an empty list. Any other listing + #failure must abort: piping an incomplete list back into 'crontab -' + #would wipe the user's existing cron jobs (issue 3079) + _cron_list_err="$($_CRONTAB -l 2>&1 >/dev/null)" + #separate greps: BRE alternation \| is a GNU extension and Solaris + #grep takes only a single -e pattern + if echo "$_cron_list_err" | grep -i "no crontab" >/dev/null || + echo "$_cron_list_err" | grep -i "no fcrontab" >/dev/null || + echo "$_cron_list_err" | grep -i "can't open" >/dev/null; then + _cron_entries="" + else + _err "Can not list the current cron jobs: $_cron_list_err" + _err "Refusing to install the cron job, that could wipe your existing cron jobs." + _err "Please add this cron job manually:" + _err "$_cron_entry" + return 1 + fi + fi + if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then if _exists uname && uname -a | grep SunOS >/dev/null; then _CRONTAB_STDIN="$_CRONTAB --" else _CRONTAB_STDIN="$_CRONTAB -" fi - $_CRONTAB -l | { - cat - echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + { + if [ "$_cron_entries" ]; then + echo "$_cron_entries" + fi + echo "$_cron_entry" } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then @@ -6478,7 +7347,7 @@ _deactivate() { _err "Cannot get new order for domain." return 1 fi - _authorizations_seg="$(echo "$response" | _egrep_o '"authorizations" *: *\[[^\]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." @@ -6511,7 +7380,7 @@ _deactivate() { _debug "Trigger validation." vtype="$(_getIdType "$_d_domain")" # 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'"[^\}]*')" + 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" @@ -6595,34 +7464,60 @@ deactivate() { done } +#reads the output of "openssl x509 -text" from stdin, prints the hex AKI +#the value is on the line right after the extension header; "grep -A" is not +#portable (Solaris /usr/bin/grep: "illegal option -- A"), so select from the +#header to EOF and keep the second line of that range +_extractAKI() { + sed -n '/X509v3 Authority Key Identifier/,$p' | _head_n 2 | _tail_n 1 | tr -d ': ' | sed "s/keyid//" +} + #cert _getAKI() { _cert="$1" - openssl x509 -in "$_cert" -text -noout | grep "X509v3 Authority Key Identifier" -A 1 | _tail_n 1 | tr -d ' :' + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | _extractAKI } #cert _getSerial() { _cert="$1" - openssl x509 -in "$_cert" -serial -noout | cut -d = -f 2 + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -serial -noout | cut -d = -f 2 } #cert -_get_ARI() { +#Compute the ARI/replaces certID for a cert: base64url(AKI).base64url(Serial) +#per RFC 9773 Section 4.1. +_getARICertID() { _cert="$1" _aki=$(_getAKI "$_cert") _ser=$(_getSerial "$_cert") _debug2 "_aki" "$_aki" _debug2 "_ser" "$_ser" - _akiurl="$(echo "$_aki" | _h2b | _base64 | tr -d = | _url_encode)" + # RFC 9773 Section 4.1 requires the DER-encoded INTEGER value bytes of + # serialNumber. When the high bit of the first byte is set (>= 0x80) DER + # prepends a 0x00 sign byte to keep the integer positive; openssl's hex + # output strips that, so add it back. Boulder (LE) accepts either form, + # but Sectigo (ZeroSSL) is strict and rejects newOrder with HTTP 401 + # "replaces field does not identify a certificate" if the byte is missing. + case "$_ser" in + [89aAbBcCdDeEfF]*) _ser="00$_ser" ;; + esac + + _akiurl="$(echo "$_aki" | _h2b | _base64 | _url_replace)" _debug2 "_akiurl" "$_akiurl" - _serurl="$(echo "$_ser" | _h2b | _base64 | tr -d = | _url_encode)" + _serurl="$(echo "$_ser" | _h2b | _base64 | _url_replace)" _debug2 "_serurl" "$_serurl" - _ARI_URL="$ACME_RENEWAL_INFO/$_akiurl.$_serurl" - _get "$_ARI_URL" + printf "%s.%s" "$_akiurl" "$_serurl" +} +#cert +_get_ARI() { + _cert="$1" + _ari_certID="$(_getARICertID "$_cert")" + _ARI_URL="$ACME_RENEWAL_INFO/$_ari_certID" + _get "$_ARI_URL" } # Detect profile file if not specified as environment variable @@ -6710,9 +7605,9 @@ _precheck() { fi 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." + _info "It is recommended to install socat or python first." + _info "We use socat or python for the standalone server, which is used for standalone mode." + _info "If you don't want to use standalone mode, you may ignore this warning." fi return 0 @@ -6736,6 +7631,15 @@ _installalias() { _c_home="$1" _initpath + _alias_bin="$LE_WORKING_DIR/$PROJECT_ENTRY" + if [ ! -f "$_alias_bin" ]; then + #ACME_PACKAGED install: no copy in LE_WORKING_DIR, alias the current script + _script="$(_readlink "$_SCRIPT_")" + if [ -f "$_script" ]; then + _alias_bin="$_script" + fi + fi + _envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env" if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile" @@ -6753,7 +7657,11 @@ _installalias() { else _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" fi - _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$_alias_bin$_c_entry\"" + if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then + #the completion file does nothing when sourced by a non-bash shell + _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\"" + fi _profile="$(_detect_profile)" if [ "$_profile" ]; then @@ -6776,7 +7684,7 @@ _installalias() { else _sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile" fi - _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\"" _setopt "$_csh_profile" "source \"$_cshfile\"" fi @@ -6788,7 +7696,7 @@ _installalias() { if [ "$_c_home" ]; then _setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\"" fi - _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\"" _setopt "$_tcsh_profile" "source \"$_cshfile\"" fi @@ -6862,25 +7770,38 @@ install() { chmod 700 "$LE_CONFIG_HOME" fi - cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY" + if [ "$ACME_PACKAGED" ]; then + #the script and its hooks are managed by a system package manager, + #do not copy them into LE_WORKING_DIR. https://github.com/acmesh-official/acme.sh/issues/7135 + _info "ACME_PACKAGED is set, skipping the script copy." + else + cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY" - if [ "$?" != "0" ]; then - _err "Installation failed, cannot copy $PROJECT_ENTRY" - return 1 + if [ "$?" != "0" ]; then + _err "Installation failed, cannot copy $PROJECT_ENTRY" + return 1 + fi + + _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" + + if [ -f "$PROJECT_ENTRY.completion" ]; then + cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" + _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi fi - _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" - if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then _installalias "$_c_home" fi - for subf in $_SUB_FOLDERS; do - if [ -d "$subf" ]; then - mkdir -p "$LE_WORKING_DIR/$subf" - cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/ - fi - done + if [ -z "$ACME_PACKAGED" ]; then + for subf in $_SUB_FOLDERS; do + if [ -d "$subf" ]; then + mkdir -p "$LE_WORKING_DIR/$subf" + cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/ + fi + done + fi if [ ! -f "$ACCOUNT_CONF_PATH" ]; then _initconf @@ -6892,6 +7813,12 @@ install() { if [ "$_DEFAULT_CERT_HOME" != "$CERT_HOME" ]; then _saveaccountconf "CERT_HOME" "$CERT_HOME" + # Create the custom cert home now instead of on first issuance, so the + # user can see --install honored it. + # https://github.com/acmesh-official/acme.sh/issues/4756 + if [ ! -d "$CERT_HOME" ]; then + mkdir -p "$CERT_HOME" + fi fi if [ "$_DEFAULT_ACCOUNT_KEY_PATH" != "$ACCOUNT_KEY_PATH" ]; then @@ -6902,7 +7829,7 @@ install() { installcronjob "$_c_home" fi - if [ -z "$NO_DETECT_SH" ]; then + if [ -z "$NO_DETECT_SH" ] && [ -z "$ACME_PACKAGED" ]; then #Modify shebang if _exists bash; then _bash_path="$(bash -c "command -v bash 2>/dev/null")" @@ -6927,7 +7854,9 @@ install() { if [ "$_accountemail" ]; then _saveaccountconf "ACCOUNT_EMAIL" "$_accountemail" fi - _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)" + if [ -z "$ACME_PACKAGED" ]; then + _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)" + fi _info OK } @@ -6941,7 +7870,12 @@ uninstall() { _uninstallalias - rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + if [ -z "$ACME_PACKAGED" ]; then + #don't remove the script when it is managed by a system package manager, + #LE_WORKING_DIR may point to the packaged files + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." } @@ -6977,20 +7911,24 @@ cron() { _initpath _info "$(__green "===Starting cron===")" if [ "$AUTO_UPGRADE" = "1" ]; then - export LE_WORKING_DIR - ( - if ! upgrade; then - _err "Cron: Upgrade failed!" - return 1 + if [ "$ACME_PACKAGED" ]; then + _info "ACME_PACKAGED is set, skipping the auto upgrade." + else + export LE_WORKING_DIR + ( + if ! upgrade; then + _err "Cron: Upgrade failed!" + return 1 + fi + ) + . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null + + if [ -t 1 ]; then + __INTERACTIVE="1" fi - ) - . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null - if [ -t 1 ]; then - __INTERACTIVE="1" + _info "Automatically upgraded to: $VER" fi - - _info "Automatically upgraded to: $VER" fi _TREAT_SKIP_AS_SUCCESS="1" renewAll @@ -7039,6 +7977,14 @@ _send_notify() { continue fi if ! ( + # The dns/deploy hooks export _H1.._H5 in the main process, so the + # values are inherited here. Clear them: a stale Authorization header + # from another service must not leak into the notify request. + export _H1="" + export _H2="" + export _H3="" + export _H4="" + export _H5="" if ! . "$_n_hook_file"; then _err "Error loading file $_n_hook_file. Please check your API file and try again." return 1 @@ -7156,8 +8102,11 @@ Commands: -ccr, --create-csr Create CSR, professional use. --create-domain-key Create an domain private key, professional use. --update-account Update account info. + --update-account-key Rotate account key. --register-account Register account key. --deactivate-account Deactivate the account. + --make-dns-persist-value Print the DNS TXT record(s) to enable persistent DNS validation + (draft-ietf-acme-dns-persist-01). Use with -d . --create-account-key Create an account private key, professional use. --install-cronjob Install the cron job to renew certs, you don't need to call this. The 'install' command can automatically install the cron job. --uninstall-cronjob Uninstall the cron job. The 'uninstall' command can do this automatically. @@ -7205,16 +8154,34 @@ Parameters: --dns [dns_hook] Use dns manual mode or dns api. Defaults to manual mode when argument is omitted. See: $_DNS_API_WIKI + --dns-persist Use dns-persist-01 validation (draft-ietf-acme-dns-persist-01). + Requires the persistent _validation-persist TXT record to already + exist. Use '--make-dns-persist-value' to print the value to add. + --dnssleep The time in seconds to wait for all the txt records to propagate in dns api mode. It's not necessary to use this by default, $PROJECT_NAME polls dns status by DOH automatically. - -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256, ec-384, ec-521. - -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096 + -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521. + -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521. --log [file] Specifies the log file. Defaults to \"$DEFAULT_LOG_FILE\" if argument is omitted. --log-level <1|2> Specifies the log level, default is $DEFAULT_LOG_LEVEL. --syslog <0|3|6|7> Syslog level, 0: disable syslog, 3: error, 6: info, 7: debug. --eab-kid Key Identifier for External Account Binding. --eab-hmac-key HMAC key for External Account Binding. + --dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the + generated TXT record so the issuer is also authorized for wildcards + and subdomains (draft-ietf-acme-dns-persist-01). It is implied when + the domain given to -d is a wildcard (e.g. '*.example.com'); the + record itself is always published at the base domain. + --dns-persist-ca-name Used with '--make-dns-persist-value'. Use the given CA identity domain + (e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If + omitted, the identities are read from the ACME directory's + 'caaIdentities' field and one record is printed per identity. + --dns-persist-days Used with '--make-dns-persist-value'. Add a 'persistUntil' field to + the TXT record so the record self-expires N days from now (the CA + will refuse new validations against the record after that time). + If omitted, the record has no expiry. + These parameters are to install the cert to nginx/Apache or any other server after issue/renew a cert: @@ -7233,8 +8200,11 @@ Parameters: --config-home Specifies the home dir to save all the configurations. --useragent Specifies the user agent string. it will be saved for future use too. -m, --email Specifies the account email, only valid for the '--install' and '--update-account' command. + Multiple emails can be given as a comma-separated list: 'a@example.com,b@example.com' --accountkey Specifies the account key path, only valid for the '--install' command. --days Specifies the days to renew the cert when using '--issue' command. The default value is $DEFAULT_RENEW days. + A negative value renews that many days before the cert expiry. + Negative values could be used to specify a number of days relative to the expiration date of the certificate. --httpport Specifies the standalone listening port. Only valid if the server is behind a reverse proxy or load balancer. --tlsport Specifies the standalone tls listening port. Only valid if the server is behind a reverse proxy or load balancer. --local-address Specifies the standalone/tls server listening address, in case you have multiple ip addresses. @@ -7260,8 +8230,9 @@ Parameters: --ocsp, --ocsp-must-staple Generate OCSP-Must-Staple extension. --always-force-new-domain-key Generate new domain key on renewal. Otherwise, the domain key is not changed by default. --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. + --listen-v4 Force standalone/tls server to listen at ipv4 only. + By default the standalone server listens on both ipv4 and ipv6. + --listen-v6 Force standalone/tls server to listen at ipv6 only. --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. @@ -7283,13 +8254,17 @@ Parameters: --revoke-reason <0-10> The reason for revocation, can be used in conjunction with the '--revoke' command. See: $_REVOKE_WIKI - --password Add a password to exported pfx file. Use with --to-pkcs12. + --password Add a password to the exported pfx or pkcs8 file. Use with '--to-pkcs12' or '--to-pkcs8'. " } installOnline() { + if [ "$ACME_PACKAGED" ]; then + _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade." + return 1 + fi _info "Installing from online archive." _branch="$BRANCH" @@ -7313,7 +8288,9 @@ installOnline() { cd "$PROJECT_NAME-$_branch" chmod +x $PROJECT_ENTRY - if ./$PROJECT_ENTRY --install "$@"; then + ./$PROJECT_ENTRY --install "$@" + _install_rc="$?" + if [ "$_install_rc" = "0" ]; then _info "Install success!" fi @@ -7321,6 +8298,9 @@ installOnline() { rm -rf "$PROJECT_NAME-$_branch" rm -f "$localname" + # Propagate the install result so a failed upgrade is not reported as + # success. https://github.com/acmesh-official/acme.sh/issues/6477 + exit "$_install_rc" ) } @@ -7342,6 +8322,10 @@ _getUpgradeHash() { } upgrade() { + if [ "$ACME_PACKAGED" ]; then + _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade." + exit 1 + fi if ( _initpath [ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0 @@ -7413,9 +8397,16 @@ _checkSudo() { return 0 fi if [ -n "$SUDO_COMMAND" ]; then - #it's a normal user doing "sudo su", or `sudo -i` or `sudo -s`, or `sudo su acmeuser1` - _endswith "$SUDO_COMMAND" /bin/su || _contains "$SUDO_COMMAND" "/bin/su " || grep "^$SUDO_COMMAND\$" /etc/shells >/dev/null 2>&1 - return $? + #The SUDO_* env vars are often inherited into shells that were not + #started as `sudo acme.sh` at all (e.g. `sudo su - user`, or + #`sudo pct enter ` on Proxmox, which copies them into the + #container). Only warn when sudo was used to run acme.sh itself; + #anything else means the sudo happened further up and is fine. + #https://github.com/acmesh-official/acme.sh/issues/6400 + if _contains "$SUDO_COMMAND" "$PROJECT_ENTRY"; then + return 1 + fi + return 0 fi #otherwise return 1 @@ -7585,6 +8576,9 @@ _process() { _valid_to="" _certificate_profile="" _extended_key_usage="" + _dns_persist_wildcard="" + _dns_persist_ca_name="" + _dns_persist_days="" while [ ${#} -gt 0 ]; do case "${1}" in @@ -7673,12 +8667,29 @@ _process() { --update-account | --updateaccount) _CMD="updateaccount" ;; + --update-account-key | --updateaccountkey) + _CMD="updateaccountkey" + ;; --register-account | --registeraccount) _CMD="registeraccount" ;; --deactivate-account) _CMD="deactivateaccount" ;; + --make-dns-persist-value | --makednspersistvalue) + _CMD="makednspersistvalue" + ;; + --dns-persist-wildcard | --dnspersistwildcard) + _dns_persist_wildcard="1" + ;; + --dns-persist-ca-name | --dnspersistcaname) + _dns_persist_ca_name="$2" + shift + ;; + --dns-persist-days | --dnspersistdays) + _dns_persist_days="$2" + shift + ;; --set-notify) _CMD="setnotify" ;; @@ -7822,6 +8833,14 @@ _process() { _webroot="$_webroot,$wvalue" fi ;; + --dns-persist) + wvalue="$W_DNS_PERSIST" + if [ -z "$_webroot" ]; then + _webroot="$wvalue" + else + _webroot="$_webroot,$wvalue" + fi + ;; --dnssleep) _dnssleep="$2" Le_DNSSleep="$_dnssleep" @@ -8188,6 +9207,22 @@ _process() { _debug2 LE_WORKING_DIR "$LE_WORKING_DIR" + # --valid-to pins the cert lifetime, so a creation-anchored (positive) + # --days schedule can not apply and is rejected. A negative --days is + # anchored to the expiry and composes with a relative --valid-to: the + # cert renews that many days before the expiry. + if [ "$_days" ] && [ "$_valid_to" ]; then + if ! _startswith "$_valid_to" "+"; then + _err "--days can not be used together with a fixed-date --valid-to: such a cert can not be renewed automatically." + return 1 + fi + if ! _startswith "$_days" "-"; then + _err "A positive --days can not be used together with --valid-to, the renewal time is derived from the expiry time." + _err "Use a negative --days to renew that many days before the expiry, or omit --days to renew 1 day before the expiry." + return 1 + fi + fi + if [ "$DEBUG" ]; then version if [ "$_server" ]; then @@ -8235,9 +9270,15 @@ _process() { updateaccount) updateaccount ;; + updateaccountkey) + updateaccountkey "$_accountkeylength" + ;; deactivateaccount) deactivateaccount ;; + makednspersistvalue) + makednspersistvalue "$_domain" "$_dns_persist_wildcard" "$_dns_persist_ca_name" "$_dns_persist_days" + ;; list) list "$_listraw" "$_domain" ;; @@ -8251,7 +9292,7 @@ _process() { toPkcs "$_domain" "$_password" "$_ecc" ;; toPkcs8) - toPkcs8 "$_domain" "$_ecc" + toPkcs8 "$_domain" "$_password" "$_ecc" ;; createAccountKey) createAccountKey "$_accountkeylength" diff --git a/acme.sh.completion b/acme.sh.completion new file mode 100644 index 00000000..26cb88da --- /dev/null +++ b/acme.sh.completion @@ -0,0 +1,341 @@ +# Bash completion for acme.sh: https://github.com/acmesh-official/acme.sh +# +# "acme.sh --install" copies this file to the acme.sh home dir and wires +# it into acme.sh.env, so the completion is loaded automatically in new +# bash sessions after installation. +# +# To use it without installing acme.sh, source it from ~/.bashrc, or copy +# it to /usr/share/bash-completion/completions/acme.sh +# +# Zsh users can load it with: +# autoload -U +X bashcompinit && bashcompinit +# . /path/to/acme.sh.completion + +# This file may also be sourced by non-bash shells via acme.sh.env, +# so silently do nothing if the "complete" builtin is not available. +if ! command -v complete >/dev/null 2>&1; then + return 0 2>/dev/null || exit 0 +fi + +# Add each word of $1 that starts with $cur to COMPREPLY. +# The words are read line by line, so that candidates like a wildcard +# domain "*.example.com" are never glob-expanded against the cwd. +_acme_sh_add_matches() { + local _word + while read -r _word; do + [ -n "$_word" ] || continue + case "$_word" in + "$cur"*) COMPREPLY=("${COMPREPLY[@]}" "$_word") ;; + esac + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +_acme_sh_dirs() { + local _dir + while IFS= read -r _dir; do + [ -n "$_dir" ] || continue + COMPREPLY=("${COMPREPLY[@]}" "$_dir") + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +# Complete the domains that already have a cert: every directory in the +# config home that contains a ".conf" file ("_ecc" suffix stripped). +_acme_sh_domains() { + local _dir _name _domains="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _dir in "$_acme_conf_home"/*/; do + [ -d "$_dir" ] || continue + _name="${_dir%/}" + _name="${_name##*/}" + _name="${_name%_ecc}" + if [ -f "${_dir}${_name}.conf" ]; then + case " $_domains " in + *" $_name "*) ;; + *) _domains="$_domains $_name" ;; + esac + fi + done + _acme_sh_add_matches "$_domains" +} + +# Complete hook names from a subfolder of the acme.sh home dir. +# $1: subfolder (dnsapi/deploy/notify), $2: file name prefix or empty. +_acme_sh_hooks() { + local _file _hooks="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _file in "$_acme_home/$1/$2"*.sh; do + [ -f "$_file" ] || continue + _file="${_file##*/}" + _hooks="$_hooks ${_file%.sh}" + done + _acme_sh_add_matches "$_hooks" +} + +_acme_sh_completion() { + local cur prev _acme_home _acme_conf_home + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD - 1]}" + fi + _acme_home="${LE_WORKING_DIR:-$HOME/.acme.sh}" + _acme_conf_home="${LE_CONFIG_HOME:-$_acme_home}" + + # The first argument is the command. + if [ "$COMP_CWORD" -eq 1 ]; then + _acme_sh_add_matches " + --help + --version + --install + --install-online + --uninstall + --upgrade + --issue + --deploy + --sign-csr + --show-csr + --install-cert + --renew + --renew-all + --revoke + --remove + --list + --list-profiles + --info + --to-pkcs12 + --to-pkcs8 + --create-account-key + --create-domain-key + --create-csr + --deactivate + --update-account + --register-account + --deactivate-account + --make-dns-persist-value + --install-cronjob + --uninstall-cronjob + --cron + --set-notify + --set-default-ca + --set-default-chain + " + return 0 + fi + + # Complete the value of the previous option. + case "$prev" in + -d | --domain | --challenge-alias | --domain-alias) + _acme_sh_domains + return 0 + ;; + --dns) + # The dns hook argument is optional, keep completing options if the + # current word already looks like one. + case "$cur" in + -*) ;; + *) + _acme_sh_hooks "dnsapi" "dns_" + return 0 + ;; + esac + ;; + --deploy-hook) + _acme_sh_hooks "deploy" "" + return 0 + ;; + --notify-hook) + _acme_sh_hooks "notify" "" + return 0 + ;; + --server) + _acme_sh_add_matches "letsencrypt letsencrypt_test zerossl sslcom google google_test actalis" + return 0 + ;; + -k | --keylength | -ak | --accountkeylength) + _acme_sh_add_matches "2048 3072 4096 8192 ec-256 ec-384 ec-521" + return 0 + ;; + --debug) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + esac + ;; + --log) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --nginx) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --auto-upgrade | --always-force-new-domain-key) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1" + return 0 + ;; + esac + ;; + --log-level) + _acme_sh_add_matches "1 2" + return 0 + ;; + --syslog) + _acme_sh_add_matches "0 3 6 7" + return 0 + ;; + --notify-level) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + --notify-mode) + _acme_sh_add_matches "0 1" + return 0 + ;; + --revoke-reason) + _acme_sh_add_matches "0 1 2 3 4 5 6 7 8 9 10" + return 0 + ;; + --cert-file | --key-file | --ca-file | --fullchain-file | --csr | --accountconf | --accountkey | --ca-bundle | --openssl-bin) + _acme_sh_files + return 0 + ;; + -w | --webroot | --home | --cert-home | --config-home | --ca-path) + _acme_sh_dirs + return 0 + ;; + -m | --email | --password | --useragent | --days | --valid-from | --valid-to | --httpport | --tlsport | --local-address | --dnssleep | --pre-hook | --post-hook | --renew-hook | --reloadcmd | --extended-key-usage | -b | --branch | --notify-source | --eab-kid | --eab-hmac-key | --preferred-chain | --cert-profile | --certificate-profile | --dns-persist-ca-name | --dns-persist-days) + # These options take a free-form value, offer nothing. + return 0 + ;; + esac + + # Complete the parameters. + _acme_sh_add_matches " + --accountconf + --accountkey + --accountkeylength + --alpn + --always-force-new-domain-key + --apache + --auto-upgrade + --branch + --ca-bundle + --ca-file + --ca-path + --cert-file + --cert-home + --cert-profile + --challenge-alias + --config-home + --csr + --days + --debug + --deploy-hook + --dns + --dns-persist + --dns-persist-ca-name + --dns-persist-days + --dns-persist-wildcard + --dnssleep + --domain + --domain-alias + --eab-hmac-key + --eab-kid + --ecc + --email + --extended-key-usage + --force + --force-color + --fullchain-file + --home + --httpport + --insecure + --key-file + --keylength + --listen-v4 + --listen-v6 + --listraw + --local-address + --log + --log-level + --nginx + --no-color + --no-cron + --no-profile + --notify-hook + --notify-level + --notify-mode + --notify-source + --ocsp-must-staple + --openssl-bin + --output-insecure + --password + --post-hook + --pre-hook + --preferred-chain + --reloadcmd + --renew-hook + --revoke-reason + --server + --staging + --standalone + --stateless + --stop-renew-on-error + --syslog + --tlsport + --treat-skip-as-success + --use-wget + --useragent + --valid-from + --valid-to + --webroot + --yes-I-know-dns-manual-mode-enough-go-ahead-please + " + return 0 +} + +complete -F _acme_sh_completion acme.sh diff --git a/deploy/baidu_cdn.sh b/deploy/baidu_cdn.sh new file mode 100644 index 00000000..7fe31f9b --- /dev/null +++ b/deploy/baidu_cdn.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034,SC2154 + +# Deploy hook: Baidu Cloud CDN +# +# Code generated by GitHub Copilot with Claude Sonnet 4.6 and OpenAI Codex with GPT-5.6 Sol +# +# API Doc: https://cloud.baidu.com/doc/CDN/s/Zkna2r57w +# +# Uses the same credential variables as dnsapi/dns_baidu.sh: +# export Baidu_AK="your-access-key-id" +# export Baidu_SK="your-secret-access-key" +# +# To deploy to a CDN domain different from the certificate CN +# (e.g. wildcard or multi-domain certs): +# export DEPLOY_BAIDU_CDN_DOMAIN="cdn.example.com" +# +# Multiple CDN domains sharing the same certificate: +# export DEPLOY_BAIDU_CDN_DOMAIN="cdn1.example.com cdn2.example.com" + +BAIDU_CDN_HOST="cdn.baidubce.com" +_BAIDU_CDN_BCE_AUTH_RESULT="" + +baidu_cdn_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 ! _baidu_cdn_load_credentials; then + return 1 + fi + + _getdeployconf DEPLOY_BAIDU_CDN_DOMAIN + if [ "$DEPLOY_BAIDU_CDN_DOMAIN" ]; then + _savedeployconf DEPLOY_BAIDU_CDN_DOMAIN "$DEPLOY_BAIDU_CDN_DOMAIN" + else + DEPLOY_BAIDU_CDN_DOMAIN="$_cdomain" + fi + + # Build JSON "domains" array from space-separated domain list + _domains_json="" + for _d in $DEPLOY_BAIDU_CDN_DOMAIN; do + _d_e="$(_baidu_cdn_json_escape "$_d")" + if [ -z "$_domains_json" ]; then + _domains_json="\"${_d_e}\"" + else + _domains_json="${_domains_json},\"${_d_e}\"" + fi + done + + # Build a valid cert name: must start with a letter, allow [A-Za-z0-9-/.], max 65 chars + _cert_name="$(printf "%s" "$_cdomain" | sed 's/\*\./wildcard./g;s/[^A-Za-z0-9./]/-/g' | cut -c 1-65)" + case "$_cert_name" in + [A-Za-z]*) ;; + *) _cert_name="c${_cert_name}" ;; + esac + + # PEM content is already Base64 inside the -----BEGIN/END----- wrappers. + # The API expects the raw PEM as a JSON string, so newlines must be escaped as \n. + _cert_pem="$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n')" + _key_pem="$(sed 's/$/\\n/' "$_ckey" | tr -d '\n')" + + _debug2 _cert_name "$_cert_name" + _debug2 _domains_json "[$_domains_json]" + + # Build JSON payload + _payload="{\"domains\":[${_domains_json}],\"certificate\":{\"certName\":\"${_cert_name}\",\"certServerData\":\"${_cert_pem}\",\"certPrivateData\":\"${_key_pem}\"}}" + + # Generate BCE v1 authorization header (query string included in canonical request) + _cdn_path="/v2/domain/certificate" + _cdn_query="action=put" + _ts="$(_utc_date | sed 's/ /T/')Z" + _content_type="application/json; charset=utf-8" + _payload_hash="$(printf "%s" "$_payload" | _digest sha256 hex)" + + if ! _baidu_cdn_bce_auth "POST" "$_cdn_path" "$_cdn_query" "$BAIDU_CDN_HOST" "$_ts" "3600" "$_content_type" "$_payload_hash"; then + _err "Failed to sign request" + return 1 + fi + + _H1="Authorization: $_BAIDU_CDN_BCE_AUTH_RESULT" + _H2="x-bce-date: $_ts" + _H3="x-bce-content-sha256: $_payload_hash" + _H4="Host: $BAIDU_CDN_HOST" + _H5="" + + _url="https://${BAIDU_CDN_HOST}${_cdn_path}?${_cdn_query}" + response="$(_post "$_payload" "$_url" "" "POST" "$_content_type")" + if [ "$?" != "0" ]; then + _err "Failed to call Baidu Cloud CDN API" + return 1 + fi + + _debug2 response "$response" + + if _contains "$response" "\"certId\""; then + _info "Certificate deployed to Baidu Cloud CDN for: $DEPLOY_BAIDU_CDN_DOMAIN" + return 0 + fi + + _err "Failed to deploy certificate to Baidu Cloud CDN: $response" + return 1 +} + +# BCE v1 signing with canonical query string support. +# The CDN endpoint uses ?action=put so it must be included in the canonical request. +_baidu_cdn_bce_auth() { + _method="$1" + _uri="$2" + _query="$3" + _host="$4" + _ts="$5" + _expire="$6" + _ct="$7" + _payload_hash="$8" + + _BAIDU_CDN_BCE_AUTH_RESULT="" + + _auth_prefix="bce-auth-v1/${Baidu_AK}/${_ts}/${_expire}" + _signed_headers="content-type;host;x-bce-content-sha256;x-bce-date" + _canonical_uri="$(_baidu_cdn_bce_encode_path "$_uri")" + + _host_e="$(printf "%s" "$_host" | _url_encode upper-hex)" + _date_e="$(printf "%s" "$_ts" | _url_encode upper-hex)" + _ct_e="$(printf "%s" "$_ct" | _url_encode upper-hex)" + _hash_e="$(printf "%s" "$_payload_hash" | _url_encode upper-hex)" + + _canonical_headers="content-type:${_ct_e} +host:${_host_e} +x-bce-content-sha256:${_hash_e} +x-bce-date:${_date_e}" + + _canonical_request="${_method} +${_canonical_uri} +${_query} +${_canonical_headers}" + + _sk_hex="$(printf "%s" "$Baidu_SK" | _hex_dump | tr -d " ")" + _signing_key="$(_baidu_cdn_hmac_sha256_hexkey "$_sk_hex" "$_auth_prefix")" + _signing_key_hex="$(printf "%s" "$_signing_key" | _hex_dump | tr -d " ")" + _signature="$(_baidu_cdn_hmac_sha256_hexkey "$_signing_key_hex" "$_canonical_request")" + + _BAIDU_CDN_BCE_AUTH_RESULT="${_auth_prefix}/${_signed_headers}/${_signature}" +} + +_baidu_cdn_load_credentials() { + Baidu_AK="${Baidu_AK:-$(_readaccountconf_mutable Baidu_AK)}" + Baidu_SK="${Baidu_SK:-$(_readaccountconf_mutable Baidu_SK)}" + + Baidu_AK="$(_baidu_cdn_trim_ws "$Baidu_AK")" + Baidu_SK="$(_baidu_cdn_trim_ws "$Baidu_SK")" + + if [ -z "$Baidu_AK" ] || [ -z "$Baidu_SK" ]; then + _err "Baidu_AK and Baidu_SK are required" + return 1 + fi + + _saveaccountconf_mutable Baidu_AK "$Baidu_AK" + _saveaccountconf_mutable Baidu_SK "$Baidu_SK" + + return 0 +} + +_baidu_cdn_bce_encode_path() { + _p="$1" + _out="" + if [ "${_p#"/"}" != "$_p" ]; then + _out="/" + fi + + _rest="${_p#/}" + while [ -n "$_rest" ]; do + _seg="${_rest%%/*}" + if [ "$_seg" ]; then + if [ -z "$_out" ] || [ "$_out" = "/" ]; then + _out="${_out}$(printf "%s" "$_seg" | _url_encode upper-hex)" + else + _out="${_out}/$(printf "%s" "$_seg" | _url_encode upper-hex)" + fi + fi + if [ "${_rest#*/}" = "$_rest" ]; then + break + fi + _rest="${_rest#*/}" + done + + if [ -z "$_out" ]; then + _out="/" + fi + printf "%s" "$_out" +} + +_baidu_cdn_trim_ws() { + printf "%s" "$1" | tr '\r\n\t' ' ' | tr -s ' ' | sed 's/^ *//;s/ *$//' +} + +_baidu_cdn_json_escape() { + _s="$1" + _s="$(printf "%s" "$_s" | tr -d '\r\n')" + printf "%s" "$_s" | + sed 's/\\/\\\\/g; s/ /\\t/g' | + _baidu_cdn_json_encode +} + +_baidu_cdn_json_encode() { + _j_str="$(sed 's/"/\\"/g' | sed "s/\r/\\r/g")" + printf "%s" "$_j_str" | _hex_dump | _lower_case | sed 's/0a/5c 6e/g' | tr -d ' ' | _h2b | tr -d "\r\n" +} + +_baidu_cdn_hmac_sha256_hexkey() { + _key_hex="$1" + _msg="$2" + printf "%s" "$_msg" | _hmac sha256 "$_key_hex" hex +} diff --git a/deploy/byteplus_alb.sh b/deploy/byteplus_alb.sh index 8443bb99..394b431f 100644 --- a/deploy/byteplus_alb.sh +++ b/deploy/byteplus_alb.sh @@ -163,8 +163,8 @@ byteplus_alb_deploy() { # ── 3. Read cert and key ───────────────────────────────────────────────────── # BytePlus requires NO blank lines between PEM blocks in the certificate chain - _public_key=$(sed '/^[[:space:]]*$/d' "$_cfullchain" | tr -d '\r') - _private_key=$(sed '/^[[:space:]]*$/d' "$_ckey" | tr -d '\r') + _public_key=$(_strip_blank_lines <"$_cfullchain" | tr -d '\r') + _private_key=$(_strip_blank_lines <"$_ckey" | tr -d '\r') if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then _err "Failed to read certificate or key file." diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index e5381b61..02ef6b3e 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -52,7 +52,15 @@ cpanel_uapi_deploy() { # read cert and key files and urlencode both _cert=$(_url_encode <"$_ccert") - _key=$(_url_encode <"$_ckey") + # with --signcsr the private key was never handed to acme.sh, so the key + # file does not exist; skip it instead of spilling a shell redirection + # error on every renewal (cPanel keeps using the already-installed key) + if [ -f "$_ckey" ]; then + _key=$(_url_encode <"$_ckey") + else + _debug "Key file $_ckey does not exist (csr mode), not sending a key." + _key="" + fi _debug2 _cert "$_cert" _debug2 _key "$_key" @@ -79,7 +87,11 @@ cpanel_uapi_deploy() { # Auto mode if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then # call API for site config - _response=$(uapi DomainInfo list_domains) + if [ -n "$_uapi_user" ]; then + _response=$(uapi --user="$_uapi_user" DomainInfo list_domains) + else + _response=$(uapi DomainInfo list_domains) + fi # exit if error in response if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then _err "Error in deploying certificate - cannot retrieve sitelist:" @@ -194,7 +206,8 @@ __cpanel_parse_response() { printf("%s%s=%s\n", prefix, $2, $3); } }' | - sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' + sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' | + sed -e 's/^"//' -e 's/"$//' # YAML double-quotes values starting with '*' (wildcard subdomains) } # Load parameter by prefix+name - fallback to default if not set, and save to config diff --git a/deploy/docker.sh b/deploy/docker.sh index 264963ae..276172aa 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -3,6 +3,8 @@ #DEPLOY_DOCKER_CONTAINER_LABEL="xxxxxxx" #DEPLOY_DOCKER_CONTAINER_KEY_FILE="/path/to/key.pem" +#DEPLOY_DOCKER_CONTAINER_KEY_MODE="0640" +#DEPLOY_DOCKER_CONTAINER_KEY_OWNER="1000:1000" #DEPLOY_DOCKER_CONTAINER_CERT_FILE="/path/to/cert.pem" #DEPLOY_DOCKER_CONTAINER_CA_FILE="/path/to/ca.pem" #DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/path/to/fullchain.pem" @@ -71,6 +73,18 @@ docker_deploy() { _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_FILE "$DEPLOY_DOCKER_CONTAINER_KEY_FILE" fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + fi + + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_CERT_FILE _debug2 DEPLOY_DOCKER_CONTAINER_CERT_FILE "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then @@ -112,6 +126,20 @@ docker_deploy() { if ! _docker_cp "$_cid" "$_ckey" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then return 1 fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _info "Setting key file owner to $DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if ! _docker_exec "$_cid" chown "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change owner of key file in container" + return 1 + fi + fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _info "Setting key file mode to $DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if ! _docker_exec "$_cid" chmod "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change mode of key file in container" + return 1 + fi + fi fi if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then @@ -189,10 +217,22 @@ _docker_exec() { _debug2 cjson "$cjson" execid="$(echo "$cjson" | cut -d '"' -f 4)" _debug execid "$execid" - ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": false,\"Tty\": false}")" + #Detach:true is required for podman's docker-compatible API: with + #Detach:false it streams the command output on the connection, so the + #non-empty response was misread as an error (issue #4977). The real + #result is checked via the exec inspect ExitCode below instead. + ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": true,\"Tty\": false}")" _debug2 ejson "$ejson" - if [ "$ejson" ]; then - _err "$ejson" + _et=0 + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + while _contains "$ijson" "\"Running\":true" && [ "$_et" -lt 10 ]; do + sleep 1 + _et="$(_math "$_et" + 1)" + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + done + _debug2 ijson "$ijson" + if ! echo "$ijson" | _egrep_o "\"ExitCode\": *0[,}]" >/dev/null 2>&1; then + _err "docker exec error: $ijson" return 1 fi else diff --git a/deploy/fortigate.sh b/deploy/fortigate.sh new file mode 100644 index 00000000..f00ca1cb --- /dev/null +++ b/deploy/fortigate.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env sh +# Script to deploy a certificate to FortiGate via API and set it as the current web GUI certificate. +# +# FortiGate's native ACME integration does not support wildcard certificates or domain validation, +# and is not supported if you have a custom management web port (eg. DNAT web traffic). +# +# REQUIRED: +# export FGT_HOST="fortigate_hostname-or-ip" +# export FGT_TOKEN="fortigate_api_token" +# +# OPTIONAL: +# export FGT_PORT="10443" # Custom HTTPS port (defaults to 443 if not set) +# +# Run `acme.sh --deploy -d example.com --deploy-hook fortigate --insecure` to use this script. +# `--insecure` is required on first run if not already using a valid SSL certificate on firewall. + +# Function to parse a FortiGate API response +_fortigate_parse_response() { + _fortigate_response="$1" + _fortigate_func="$2" + _fortigate_status=$(echo "$_fortigate_response" | _egrep_o '"status":[ ]*"[^"]*"' | cut -d '"' -f 4) + + if [ "$_fortigate_status" != "success" ]; then + _err "[$_fortigate_func] Operation failed. Deploy with --insecure if current certificate is invalid. Try deploying with --debug to troubleshoot." + return 1 + fi + + _debug "[$_fortigate_func] Operation successful." + return 0 +} + +# Function to deploy a base64-encoded certificate to the firewall +_fortigate_deployer() { + _fortigate_cert_base64=$(_base64 <"$_fortigate_cfullchain" | tr -d '\n') + _fortigate_key_base64=$(_base64 <"$_fortigate_ckey" | tr -d '\n') + _fortigate_payload=$( + cat <" + + return 0 +} diff --git a/deploy/keyhelp.sh b/deploy/keyhelp.sh index 97f9c21c..f66d27ce 100644 --- a/deploy/keyhelp.sh +++ b/deploy/keyhelp.sh @@ -83,7 +83,7 @@ keyhelp_deploy() { _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/ *$//') + _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') _info "_message" "$_message" if [ -z "$_message" ]; then _err "Fail to upload certificate." @@ -118,7 +118,7 @@ keyhelp_deploy() { _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/ *$//') + _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') _info "_message" "$_message" if [ -z "$_message" ]; then _err "Fail to apply certificate." diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh index ef920f64..4a8c9dc9 100644 --- a/deploy/multideploy.sh +++ b/deploy/multideploy.sh @@ -10,6 +10,10 @@ # 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" +# A name without a leading '/' is looked up in the certificate directory +# of the domain. An absolute path is used as is, so a single deploy file +# can be shared by all domains, e.g. +# - export MULTIDEPLOY_FILENAME="/etc/acme/multideploy.yml" # # 2. Run command: # acme.sh --deploy --deploy-hook multideploy -d example.com @@ -49,7 +53,7 @@ multideploy_deploy() { _debug _cfullchain "$_cfullchain" _debug _cpfx "$_cpfx" - MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf 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'." @@ -75,7 +79,8 @@ multideploy_deploy() { # 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. +# $@ - Posible deploy file names. A name starting with '/' is treated as an +# absolute path, any other name is relative to the domain directory. # Usage: # _preprocess_deployfile "" "?" _preprocess_deployfile() { @@ -87,15 +92,21 @@ _preprocess_deployfile() { _debug3 "yq is installed." # Check if deploy file exists + found_file="" for file in "$@"; do - _debug3 "Checking file" "$DOMAIN_PATH/$file" - if [ -f "$DOMAIN_PATH/$file" ]; then + if _startswith "$file" "/"; then + _multideploy_path="$file" + else + _multideploy_path="$DOMAIN_PATH/$file" + fi + _debug3 "Checking file" "$_multideploy_path" + if [ -f "$_multideploy_path" ]; 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" + found_file="$_multideploy_path" else _debug3 "File not found" fi @@ -105,12 +116,12 @@ _preprocess_deployfile() { _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" + if ! _check_deployfile "$found_file"; then + _err "Deploy file is not valid: $found_file" return 1 fi - echo "$DOMAIN_PATH/$found_file" + echo "$found_file" } # Description: @@ -210,7 +221,7 @@ _clear_envs() { echo "$env_pairs" | while IFS='=' read -r _key _value; do _debug3 "Deleting key" "$_key" - _cleardomainconf "SAVED_$_key" + _cleardeployconf "$_key" unset -v "$_key" done } diff --git a/deploy/mydevil.sh b/deploy/mydevil.sh index bd9868aa..8954f822 100755 --- a/deploy/mydevil.sh +++ b/deploy/mydevil.sh @@ -54,6 +54,8 @@ mydevil_deploy() { # Usage: ip=$(mydevil_get_ip domain.com) # echo $ip mydevil_get_ip() { - devil dns list "$1" | cut -w -s -f 3,7 | grep "^A$(printf '\t')" | cut -w -s -f 2 || return 1 + # tr squeezes runs of blanks into one tab so plain cut works everywhere; + # cut -w is BSD-only and unknown to GNU coreutils + devil dns list "$1" | tr -s ' \t' '\t' | cut -s -f 3,7 | grep "^A$(printf '\t')" | cut -s -f 2 || return 1 return 0 } diff --git a/deploy/panos.sh b/deploy/panos.sh index 00badffc..fcfd6fb5 100644 --- a/deploy/panos.sh +++ b/deploy/panos.sh @@ -296,9 +296,20 @@ panos_deploy() { _err "Unable to generate an API key. The user and pass may be invalid or not authorized to generate a new key. Please check the PANOS_USER and PANOS_PASS credentials and try again" return 1 else - deployer cert - deployer key - deployer commit + # A commit of a failed import would leave a mismatched cert/key pair + # on the firewall and can lock the admin out of the management + # interface, see https://github.com/acmesh-official/acme.sh/issues/4716 + if ! deployer cert; then + _err "Cert import failed. Aborting without committing." + return 1 + fi + if ! deployer key; then + _err "Key import failed. Aborting without committing. Warning: the firewall now has an uncommitted mismatched cert/key pair in its candidate config." + return 1 + fi + if ! deployer commit; then + return 1 + fi if [ "$_panos_template_stack" ]; then # try to get job status for 20 times in 30 sec interval i=0 diff --git a/deploy/proxmoxbs.sh b/deploy/proxmoxbs.sh index 179b0369..30599a44 100644 --- a/deploy/proxmoxbs.sh +++ b/deploy/proxmoxbs.sh @@ -116,17 +116,24 @@ HEREDOC export HTTPS_INSECURE=1 export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}" response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") + _retval=$? + # The API errors out with a non-2xx HTTP status and an empty body, + # so the status line is checked too, not only the response body. + _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 "HTTP status" "$_status_code" response="$(echo "$response" | _json_decode | _normalizeJson)" message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') - _retval=$? - if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then - _debug3 response "$response" - _info "Certificate successfully deployed" - return 0 - else - _err "Certificate deployment failed: $message" - _debug "Response" "$response" - return 1 - fi + case "$_status_code" in + 2[0-9][0-9]) + if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then + _debug3 response "$response" + _info "Certificate successfully deployed" + return 0 + fi + ;; + esac + _err "Certificate deployment failed (HTTP status $_status_code). $message" + _debug "Response" "$response" + return 1 } diff --git a/deploy/proxmoxve.sh b/deploy/proxmoxve.sh index b6298ee7..fd8d69d8 100644 --- a/deploy/proxmoxve.sh +++ b/deploy/proxmoxve.sh @@ -128,17 +128,24 @@ HEREDOC export HTTPS_INSECURE=1 export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}" response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") + _retval=$? + # The API errors out with a non-2xx HTTP status and an empty body, + # so the status line is checked too, not only the response body. + _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 "HTTP status" "$_status_code" response="$(echo "$response" | _json_decode | _normalizeJson)" message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') - _retval=$? - if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then - _debug3 response "$response" - _info "Certificate successfully deployed" - return 0 - else - _err "Certificate deployment failed: $message" - _debug "Response" "$response" - return 1 - fi + case "$_status_code" in + 2[0-9][0-9]) + if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then + _debug3 response "$response" + _info "Certificate successfully deployed" + return 0 + fi + ;; + esac + _err "Certificate deployment failed (HTTP status $_status_code). $message" + _debug "Response" "$response" + return 1 } diff --git a/deploy/routeros.sh b/deploy/routeros.sh index ef9c6954..328fabbd 100644 --- a/deploy/routeros.sh +++ b/deploy/routeros.sh @@ -125,7 +125,7 @@ routeros_deploy() { _savedeployconf ROUTER_OS_PORT "$ROUTER_OS_PORT" _savedeployconf ROUTER_OS_SSH_CMD "$ROUTER_OS_SSH_CMD" _savedeployconf ROUTER_OS_SCP_CMD "$ROUTER_OS_SCP_CMD" - _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES" + _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES" "base64" # push key to routeros if ! _scp_certificate "$_ckey" "$ROUTER_OS_USERNAME@$ROUTER_OS_HOST:$_cdomain.key"; then @@ -143,6 +143,7 @@ comment=\"generated by routeros deploy script in acme.sh\" \ source=\"/certificate remove [ find name=$_cdomain.cer_0 ];\ \n/certificate remove [ find name=$_cdomain.cer_1 ];\ \n/certificate remove [ find name=$_cdomain.cer_2 ];\ +\n/certificate remove [ find name=$_cdomain.cer_3 ];\ \ndelay 1;\ \n/certificate import file-name=\\\"$_cdomain.cer\\\" passphrase=\\\"\\\";\ \n/certificate import file-name=\\\"$_cdomain.key\\\" passphrase=\\\"\\\";\ diff --git a/deploy/shelly.sh b/deploy/shelly.sh new file mode 100644 index 00000000..dbdab346 --- /dev/null +++ b/deploy/shelly.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to a Shelly Gen3+ device. +# Deploy the HTTPS server certificate to a Shelly device on the local network. +# +# ```sh +# export SHELLY_HOST=192.168.1.100 +# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device +# acme.sh --deploy -d shelly.example.com --deploy-hook shelly +# ``` +# +# Environment variables: +# SHELLY_HOST (required) IP or hostname of the Shelly device +# SHELLY_PASSWORD (optional) Admin password for digest authentication. +# Omit if auth is disabled on the device. +# SHELLY_USER (optional) Username for auth. Default: admin +# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot. +# Default: 1 (reboot after upload) +# +# Requirements: +# - Shelly Gen3+ device (Gen4 recommended) +# - Firmware 2.0.0+ for HTTPS server certificate support +# - curl or wget +# - openssl (for SHA-256 digest and random cnonce) +# +# The device must be reachable via HTTP on the local network. +# The hook uploads the fullchain.pem and private key, +# then reboots the device to apply the new certificate. +# +# Authentication uses standard RFC 7616 HTTP Digest (SHA-256) since +# firmware 2.0.0. The JSON-RPC auth object is not used for HTTP transport. +# +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +shelly_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" + + _getdeployconf SHELLY_HOST + _getdeployconf SHELLY_PASSWORD + _getdeployconf SHELLY_USER + _getdeployconf SHELLY_REBOOT + + _debug SHELLY_HOST "$SHELLY_HOST" + _debug SHELLY_USER "$SHELLY_USER" + _secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD" + _debug SHELLY_REBOOT "$SHELLY_REBOOT" + + if [ -z "$SHELLY_HOST" ]; then + _err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device." + return 1 + fi + + SHELLY_USER="${SHELLY_USER:-admin}" + SHELLY_REBOOT="${SHELLY_REBOOT:-1}" + + _savedeployconf SHELLY_HOST "$SHELLY_HOST" + _savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD" + _savedeployconf SHELLY_USER "$SHELLY_USER" + _savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT" + + # --- Auth handshake (only if password is set) --- + _shelly_auth_header="" + if [ -n "$SHELLY_PASSWORD" ]; then + _info "Authenticating to Shelly device at $SHELLY_HOST" + if ! _shelly_handshake; then + _err "Authentication handshake failed. Check SHELLY_PASSWORD and device accessibility." + return 1 + fi + _info "Authentication successful" + fi + + # --- Upload certificate --- + _info "Uploading certificate to Shelly device at $SHELLY_HOST" + if ! _shelly_upload_cert; then + _err "Certificate upload failed" + return 1 + fi + + # --- Upload key --- + _info "Uploading private key to Shelly device" + if ! _shelly_upload_key; then + _err "Private key upload failed" + return 1 + fi + + _info "Certificate and key uploaded successfully" + + # --- Reboot --- + if [ "$SHELLY_REBOOT" != "0" ]; then + _info "Rebooting Shelly device to apply certificate" + # Reboot may close the connection before sending a response + _shelly_rpc "Shelly.Reboot" '{}' || _debug "Reboot may have closed connection (expected)" + _info "Reboot command sent. Device will restart shortly." + else + _info "Skipping reboot (SHELLY_REBOOT=0). Certificate will apply on next restart." + fi + + # Clear auth header so it does not leak to other hooks + export _H1="" + + return 0 +} + +# --- Helper functions --- + +# Perform RFC 7616 HTTP Digest auth handshake. +# Sets _shelly_auth_header on success (the Authorization header value). +_shelly_handshake() { + _inithttp + + _debug "Probing device for auth challenge" + + # Use a protected method (Shelly.GetStatus) to trigger 401. + # Shelly.GetDeviceInfo is excluded from auth and would miss the challenge. + _post '{"id":1,"method":"Shelly.GetStatus"}' \ + "http://${SHELLY_HOST}/rpc" "" "" "application/json" + + # Detect auth from HTTP status line rather than response body + if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then + # No auth challenge — device accepted the request without credentials + _debug "Device responded without auth challenge. Proceeding without auth." + return 0 + fi + + _shelly_realm="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*realm="//;s/".*//')" + _shelly_nonce="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*nonce="//;s/".*//')" + _shelly_qop="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*qop="//;s/".*//')" + + if [ -z "$_shelly_nonce" ]; then + _err "Failed to extract nonce from WWW-Authenticate header. Is SHELLY_PASSWORD correct?" + return 1 + fi + + _shelly_qop="${_shelly_qop:-auth}" + + _debug "Shelly realm: $_shelly_realm" + _debug "Shelly qop: $_shelly_qop" + _secure_debug "Shelly nonce" "$_shelly_nonce" + + # ha1 = SHA256(username:realm:password) + _shelly_ha1="$(printf '%s' "${SHELLY_USER}:${_shelly_realm}:${SHELLY_PASSWORD}" | _digest sha256 hex)" + _secure_debug "Shelly ha1" "$_shelly_ha1" + + # Generate client nonce (openssl is required for _digest, so always available) + _shelly_cnonce="$(${ACME_OPENSSL_BIN:-openssl} rand -hex 8 2>/dev/null)" + _debug "Shelly cnonce: $_shelly_cnonce" + + # Build the digest Authorization header value (stored for reuse) + _shelly_nc=1 + _shelly_build_auth_header + + return 0 +} + +# Check whether the HTTP response headers contain a digest auth challenge. +# Returns 0 (true) if a 401 with WWW-Authenticate is present. +_shelly_has_auth_challenge() { + _shelly_headers_file="$1" + _shelly_status="$(grep -i '^HTTP/' "$_shelly_headers_file" | _tail_n 1 | awk '{print $2}')" + [ "$_shelly_status" = "401" ] && grep -qi '^WWW-Authenticate:' "$_shelly_headers_file" +} + +# Build or rebuild the RFC 7616 Authorization header. +# Uses: _shelly_ha1, _shelly_nonce, _shelly_cnonce, _shelly_qop, _shelly_realm, _shelly_nc +# Sets: _shelly_auth_header +_shelly_build_auth_header() { + _shelly_nc_hex="$(printf '%08x' "$_shelly_nc")" + + # ha2 = SHA256(POST:/rpc) + _shelly_ha2="$(printf '%s' "POST:/rpc" | _digest sha256 hex)" + + # response = SHA256(ha1:nonce:nc:cnonce:qop:ha2) + _shelly_digest_response="$(printf '%s' "${_shelly_ha1}:${_shelly_nonce}:${_shelly_nc_hex}:${_shelly_cnonce}:${_shelly_qop}:${_shelly_ha2}" | _digest sha256 hex)" + + # Build the Authorization header value (without the "Authorization: " prefix) + _shelly_auth_header="Digest username=\"${SHELLY_USER}\", realm=\"${_shelly_realm}\", nonce=\"${_shelly_nonce}\", uri=\"/rpc\", qop=${_shelly_qop}, nc=${_shelly_nc_hex}, cnonce=\"${_shelly_cnonce}\", response=\"${_shelly_digest_response}\", algorithm=SHA-256" + + _secure_debug "Authorization header" "$_shelly_auth_header" +} + +# Make a Shelly JSON-RPC call. +# Usage: _shelly_rpc +# Returns 0 on success, 1 on error. +_shelly_rpc() { + _shelly_method="$1" + _shelly_params="$2" + + _shelly_body='{"id":1,"method":"'"$_shelly_method"'","params":'"$_shelly_params"'}' + + _debug "RPC method: $_shelly_method" + _debug2 "RPC body: $_shelly_body" + + # shellcheck disable=SC2090 + if [ -n "$_shelly_auth_header" ]; then + export _H1="Authorization: $_shelly_auth_header" + else + export _H1="" + fi + + _post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json" + _shelly_ret=$? + + if [ "$_shelly_ret" != "0" ]; then + _err "HTTP request failed for $_shelly_method (curl/wget error $_shelly_ret)" + return 1 + fi + + # Empty response means something went wrong (auth required but not provided, etc.) + if [ -z "$response" ]; then + _err "Empty response from Shelly device. If authentication is enabled on the device, set SHELLY_PASSWORD." + return 1 + fi + + # Validate response looks like a Shelly JSON-RPC response. + # Catches non-JSON responses such as HTTP 429 "Too Many Requests" which + # would otherwise pass the empty and "error" checks below. + if ! _startswith "$response" '{' || ! _contains "$response" '"id"'; then + _err "Invalid response from Shelly device: $response" + return 1 + fi + + # Check for JSON-RPC error in response + if _contains "$response" '"error"'; then + _err "RPC error from Shelly: $response" + return 1 + fi + + _debug "RPC response: $response" + + # Increment nonce counter and rebuild auth header for next request + if [ -n "$_shelly_auth_header" ]; then + _shelly_nc=$((_shelly_nc + 1)) + _shelly_build_auth_header + fi + + return 0 +} + +# Upload the certificate to the device. +# Note: We do NOT clear the existing certificate first, because the Shelly +# auto-removes all three files (cert, key, CA) when any one is cleared. +# Uploading overwrites in place — no clearing needed. +_shelly_upload_cert() { + _shelly_cert_data="$(_json_encode <"$_cfullchain")" + + _debug "Uploading certificate" + if ! _shelly_rpc "Shelly.PutHTTPServerCert" '{"data":"'"$_shelly_cert_data"'"}'; then + _err "Failed to upload certificate to device" + return 1 + fi + + return 0 +} + +# Upload the private key to the device. +# Note: Do not clear first — see _shelly_upload_cert for rationale. +_shelly_upload_key() { + _shelly_key_data="$(_json_encode <"$_ckey")" + + _debug "Uploading key" + if ! _shelly_rpc "Shelly.PutHTTPServerKey" '{"data":"'"$_shelly_key_data"'"}'; then + _err "Failed to upload key to device" + return 1 + fi + + return 0 +} diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 848380a5..0bf3ee48 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -25,7 +25,8 @@ # export DEPLOY_SSH_MULTI_CALL="" # yes or no, default to no or previously saved value # export DEPLOY_SSH_USE_SCP="" yes or no, default to no # export DEPLOY_SSH_SCP_CMD="" defaults to "scp -q" -# +# export DEPLOY_SSH_REMOTE_SHELL="" # defaults to sh -c +# export DEPLOY_SSH_REMOTE_CMD_QUOTE="" # yes or no, defaults to yes ######## Public functions ##################### #domain keyfile certfile cafile fullchain @@ -71,6 +72,24 @@ ssh_deploy() { fi _savedeployconf DEPLOY_SSH_CMD "$DEPLOY_SSH_CMD" + # REMOTE_SHELL is optional. If not provided then use sh + _migratedeployconf Le_Deploy_ssh_remote_shell DEPLOY_SSH_REMOTE_SHELL + _getdeployconf DEPLOY_SSH_REMOTE_SHELL + _debug2 DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL" + if [ -z "$DEPLOY_SSH_REMOTE_SHELL" ]; then + DEPLOY_SSH_REMOTE_SHELL="sh -c" + fi + _savedeployconf DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL" + + # REMOTE_CMD_QUOTE is optional. If not provided then yes + _migratedeployconf Le_Deploy_ssh_remote_cmd_quote DEPLOY_SSH_REMOTE_CMD_QUOTE + _getdeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE + _debug2 DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE" + if [ -z "$DEPLOY_SSH_REMOTE_CMD_QUOTE" ]; then + DEPLOY_SSH_REMOTE_CMD_QUOTE="yes" + fi + _savedeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE" + # BACKUP is optional. If not provided then default to previously saved value or yes. _migratedeployconf Le_Deploy_ssh_backup DEPLOY_SSH_BACKUP _getdeployconf DEPLOY_SSH_BACKUP @@ -170,10 +189,16 @@ ssh_deploy() { _info "Required commands batched and sent in single call to remote host" fi + _returnCode=0 _deploy_ssh_servers="$DEPLOY_SSH_SERVER" for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do - _ssh_deploy + if ! _ssh_deploy; then + # in case of an error, remember it, but keep going for the remaining servers + _returnCode=1 + fi done + + return $_returnCode } _ssh_deploy() { @@ -428,9 +453,13 @@ _ssh_remote_cmd() { _secure_debug "Remote commands to execute: $_cmd" _info "Submitting sequence of commands to remote server by $_ssh_cmd" - # quotations in bash cmd below intended. Squash travis spellcheck error - # shellcheck disable=SC2029 - $_ssh_cmd "$DEPLOY_SSH_USER@$_host" sh -c "'$_cmd'" + if [ "$DEPLOY_SSH_REMOTE_CMD_QUOTE" = "yes" ]; then + # quotations in bash cmd below intended. Squash travis spellcheck error + # shellcheck disable=SC2029 + $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "'$_cmd'" + else + $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "$_cmd" + fi _err_code="$?" if [ "$_err_code" != "0" ]; then diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index e28a4036..336980a5 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env sh ################################################################################ # ACME.sh 3rd party deploy plugin for Synology DSM @@ -72,7 +72,7 @@ synology_dsm_deploy() { if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then if ! _exists synouser || ! _exists synogroup || ! _exists synosetkeyvalue; then - _err "Missing required tools to creat temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead." + _err "Missing required tools to create temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead." _err "Notice: temp admin user authorization method only supports local deployment on DSM." return 1 fi @@ -234,11 +234,11 @@ synology_dsm_deploy() { fi fi - error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*') + error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+') _debug2 error_code "$error_code" # Account has 2FA-OTP enabled, since error 403 reported. # https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf - if [ "$error_code" == "403" ]; then + if [ "$error_code" = "403" ]; then if [ -z "$SYNO_DEVICE_NAME" ]; then printf "Enter device name or leave empty for default (CertRenewal): " read -r SYNO_DEVICE_NAME @@ -269,27 +269,27 @@ synology_dsm_deploy() { _secure_debug2 SYNO_DEVICE_ID "$SYNO_DEVICE_ID" fi fi - error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*') + error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+') _debug2 error_code "$error_code" fi if [ -n "$error_code" ]; then - if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then + if [ "$error_code" = "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then _cleardeployconf SYNO_DEVICE_ID - _err "Failed to authenticate with SYNO_DEVICE_ID (may expired or invalid), please try again in a new terminal window." - elif [ "$error_code" == "404" ]; then + _err "Failed to authenticate with SYNO_DEVICE_ID (may be expired or invalid), please try again in a new terminal window." + elif [ "$error_code" = "404" ]; then _err "Failed to authenticate with provided 2FA-OTP code, please try again in a new terminal window." - elif [ "$error_code" == "406" ]; then + elif [ "$error_code" = "406" ]; then if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then _err "Failed with unexcepted error, please report this by providing full log with '--debug 3'." else _err "Enforce auth with 2FA-OTP enabled, please configure the user to enable 2FA-OTP to continue." fi - elif [ "$error_code" == "400" ]; then + elif [ "$error_code" = "400" ]; then _err "Failed to authenticate, no such account or incorrect password." - elif [ "$error_code" == "401" ]; then + elif [ "$error_code" = "401" ]; then _err "Failed to authenticate with a non-existent account." - elif [ "$error_code" == "408" ] || [ "$error_code" == "409" ] || [ "$error_code" == "410" ]; then + elif [ "$error_code" = "408" ] || [ "$error_code" = "409" ] || [ "$error_code" = "410" ]; then _err "Failed to authenticate, the account password has expired or must be changed." else _err "Failed to authenticate with error: $error_code." @@ -322,8 +322,8 @@ synology_dsm_deploy() { _savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN" _savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME" else - _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" - _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" + _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" "base64" + _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" "base64" _savedeployconf SYNO_DEVICE_ID "$SYNO_DEVICE_ID" _savedeployconf SYNO_DEVICE_NAME "$SYNO_DEVICE_NAME" fi @@ -336,7 +336,7 @@ synology_dsm_deploy() { id=$(echo "$response" | sed -n "s/.*\"desc\":\"$escaped_certificate\",\"id\":\"\([^\"]*\).*/\1/p") _debug2 id "$id" - error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*') + error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+') _debug2 error_code "$error_code" if [ -n "$error_code" ]; then if [ "$error_code" -eq 105 ]; then @@ -344,6 +344,7 @@ synology_dsm_deploy() { else _err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more." fi + _logout _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi @@ -354,6 +355,7 @@ synology_dsm_deploy() { if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then _err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set." + _logout _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi @@ -389,13 +391,13 @@ synology_dsm_deploy() { else _info "Restart HTTP services not necessary." fi - _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _logout + _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 0 else - _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _err "Unable to update certificate, got error response: $response." _logout + _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi } @@ -403,6 +405,8 @@ synology_dsm_deploy() { #################### Private functions below ################################## _logout() { # Logout CERT user only to not occupy a permanent session, e.g. in DSM's "Connected Users" widget (based on previous variables) + # Must be called before _temp_admin_cleanup: once the temp admin is deleted, its session can no longer be logged out. + # Note: this overwrites $response, so print any error message that needs it before calling. response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=logout&_sid=$sid") _debug3 response "$response" } @@ -424,11 +428,6 @@ _temp_admin_cleanup() { fi } -#_cleardeployconf key -_cleardeployconf() { - _cleardomainconf "SAVED_$1" -} - # key _check2cleardeployconfexp() { _key="$1" diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh index df34f927..33e3dfa0 100644 --- a/deploy/truenas_ws.sh +++ b/deploy/truenas_ws.sh @@ -16,7 +16,12 @@ # # # API KEY # # Use the folowing URL to create a new API token: /ui/apikeys -# export DEPLOY_TRUENAS_APIKEY="/dev/null)" + if [ -z "$otpcode" ]; then + _err "Failed to generate TOTP code from One984HOSTING_TOTP_Secret." + return 1 + fi + otpkey="$(printf '%s' "$otpcode" | _url_encode)" + fi + + # Fetch the login page to obtain CSRF and session cookies. + # Note: _get sets the global 'url', so assign the auth URL afterwards. + _get "https://1984.hosting/accounts/login/" >/dev/null + csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')" + sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')" if [ -z "$csrftoken" ] || [ -z "$sessionid" ]; then _err "One or more cookies are empty: '$csrftoken', '$sessionid'." @@ -140,17 +158,23 @@ _1984hosting_login() { csrf_header=$(echo "$csrftoken" | sed 's/csrftoken=//' | _head_n 1) export _H3="X-CSRFToken: $csrf_header" - response="$(_post "username=$username&password=$password&otpkey=" $url)" + url="https://1984.hosting/api/auth/" + response="$(_post "username=$username&password=$password&otpkey=$otpkey" "$url")" response="$(echo "$response" | _normalizeJson)" _debug2 response "$response" if _contains "$response" '"loggedin": true'; then - 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 ';')" + One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')" + One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')" export One984HOSTING_SESSIONID_COOKIE export One984HOSTING_CSRFTOKEN_COOKIE _saveaccountconf_mutable One984HOSTING_Username "$One984HOSTING_Username" _saveaccountconf_mutable One984HOSTING_Password "$One984HOSTING_Password" + if [ -n "$One984HOSTING_TOTP_Secret" ]; then + _saveaccountconf_mutable One984HOSTING_TOTP_Secret "$One984HOSTING_TOTP_Secret" + else + _clearaccountconf_mutable One984HOSTING_TOTP_Secret + fi _saveaccountconf_mutable One984HOSTING_SESSIONID_COOKIE "$One984HOSTING_SESSIONID_COOKIE" _saveaccountconf_mutable One984HOSTING_CSRFTOKEN_COOKIE "$One984HOSTING_CSRFTOKEN_COOKIE" return 0 @@ -161,6 +185,7 @@ _1984hosting_login() { _check_credentials() { One984HOSTING_Username="${One984HOSTING_Username:-$(_readaccountconf_mutable One984HOSTING_Username)}" One984HOSTING_Password="${One984HOSTING_Password:-$(_readaccountconf_mutable One984HOSTING_Password)}" + One984HOSTING_TOTP_Secret="${One984HOSTING_TOTP_Secret:-$(_readaccountconf_mutable One984HOSTING_TOTP_Secret)}" if [ -z "$One984HOSTING_Username" ] || [ -z "$One984HOSTING_Password" ]; then One984HOSTING_Username="" One984HOSTING_Password="" @@ -225,9 +250,15 @@ _get_root() { # Usage: _get_zone_id url domain.com # Returns zone id for domain.com +# Memoized per-domain so add/rm don't re-fetch the same zone list within a run. +# Keyed on domain (not url) since the url is always the domains listing. _get_zone_id() { url=$1 domain=$2 + if [ "$_zone_id_for" = "$domain" ] && [ -n "$_zone_id" ]; then + _debug2 _zone_id "$_zone_id (cached)" + return 0 + fi _htmlget "$url" "$domain" _zone_id="$(echo "$_response" | _egrep_o 'zone\/[0-9]+' | _head_n 1)" _debug2 _zone_id "$_zone_id" @@ -235,6 +266,7 @@ _get_zone_id() { _err "Error getting _zone_id for $2." return 1 fi + _zone_id_for="$domain" return 0 } @@ -257,9 +289,8 @@ _htmlget() { # Add extra headers to request _authpost() { - url="https://1984.hosting/domains" - _get_zone_id "$url" "$_domain" - csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | _egrep_o "=[^=][0-9a-zA-Z]*" | tr -d "=")" + _get_zone_id "https://1984.hosting/domains" "$_domain" + csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | sed 's/csrftoken=//' | _head_n 1)" export _H1="Cookie: $One984HOSTING_CSRFTOKEN_COOKIE; $One984HOSTING_SESSIONID_COOKIE" export _H2="Referer: https://1984.hosting/domains/$_zone_id" export _H3="X-CSRFToken: $csrf_header" diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index f3f50233..a21f8ef0 100755 --- a/dnsapi/dns_acmedns.sh +++ b/dnsapi/dns_acmedns.sh @@ -37,6 +37,16 @@ dns_acmedns_add() { ACMEDNS_PASSWORD="${ACMEDNS_PASSWORD:-$(_readdomainconf ACMEDNS_PASSWORD)}" ACMEDNS_SUBDOMAIN="${ACMEDNS_SUBDOMAIN:-$(_readdomainconf ACMEDNS_SUBDOMAIN)}" + #for compatibility: old versions stored ACMEDNS_UPDATE_URL in the account + #conf (issue 3899). Do not clear it here: it must stay available for the + #other domains that have not migrated to their domain conf yet. + if [ -z "$ACMEDNS_BASE_URL" ]; then + _acmedns_update_url="$(_readaccountconf_mutable ACMEDNS_UPDATE_URL)" + if [ "$_acmedns_update_url" ]; then + ACMEDNS_BASE_URL="$(echo "$_acmedns_update_url" | sed 's#/update$##')" + fi + fi + if [ "$ACMEDNS_BASE_URL" = "" ]; then ACMEDNS_BASE_URL="https://auth.acme-dns.io" fi @@ -71,7 +81,7 @@ dns_acmedns_add() { data="{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\", \"txt\": \"$txtvalue\"}" _debug data "$data" - response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST")" + response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST" "application/json")" _debug response "$response" if ! echo "$response" | grep "\"$txtvalue\"" >/dev/null; then diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 90196c69..b8ca9169 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -18,7 +18,9 @@ Ali_DNS_API="https://alidns.aliyuncs.com/" #Usage: dns_ali_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_ali_add() { - fulldomain=$1 + # the API only accepts punycode for IDN domains, and a raw UTF-8 domain + # also breaks the request signature (issue 4733) + fulldomain=$(_idn "$1") txtvalue=$2 _prepare_ali_credentials || return 1 @@ -33,7 +35,7 @@ dns_ali_add() { } dns_ali_rm() { - fulldomain=$1 + fulldomain=$(_idn "$1") txtvalue=$2 Ali_Key="${Ali_Key:-$(_readaccountconf_mutable Ali_Key)}" Ali_Secret="${Ali_Secret:-$(_readaccountconf_mutable Ali_Secret)}" @@ -69,8 +71,8 @@ _ali_rest() { ign="$2" mtd="${3:-GET}" - signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _url_encode upper-hex)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) - signature=$(printf "%s" "$signature" | _url_encode upper-hex) + signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _ali_urlencode_upper)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) + signature=$(printf "%s" "$signature" | _ali_urlencode_upper) url="$endpoint?Signature=$signature" if [ "$mtd" = "GET" ]; then @@ -96,6 +98,20 @@ _ali_rest() { fi } +# stdin stdout +# The Aliyun signature requires percent-encoding with upper-case hex. +# Do not use "_url_encode upper-hex" here: this file is also bundled by +# third parties (e.g. Proxmox VE proxmox-acme) whose older copies of the +# acme.sh function library ignore the upper-hex argument and output +# lower-case hex, which invalidates the signature. +# https://github.com/acmesh-official/acme.sh/issues/6272 +_ali_urlencode_upper() { + { + _url_encode + echo + } | sed 's/%a/%A/g;s/%b/%B/g;s/%c/%C/g;s/%d/%D/g;s/%e/%E/g;s/%f/%F/g;s/%\(.\)a/%\1A/g;s/%\(.\)b/%\1B/g;s/%\(.\)c/%\1C/g;s/%\(.\)d/%\1D/g;s/%\(.\)e/%\1E/g;s/%\(.\)f/%\1F/g' +} + _ali_nonce() { if [ "$ACME_OPENSSL_BIN" ]; then "$ACME_OPENSSL_BIN" rand -hex 16 2>/dev/null && return 0 diff --git a/dnsapi/dns_arubabusiness.sh b/dnsapi/dns_arubabusiness.sh new file mode 100644 index 00000000..90b3f18d --- /dev/null +++ b/dnsapi/dns_arubabusiness.sh @@ -0,0 +1,490 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_arubabusiness_info='ArubaBusiness +Site: business.aruba.it +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_arubabusiness +Options: + AB_Key Your ArubaBusiness API Key + AB_User Your account user + AB_Pass Your account password +' + +# +# A word of warning: as of this writing, api.arubabusiness.it only supports oauth authentication using the "password" grant type. +# If you are REALLY sure you want to use it, it would be wise set up a dedicated technical user without administrative privileges +# + +ARUBABUSINESS_API='https://api.arubabusiness.it' + +######## Public functions ######## + +# +# Usage: dns_arubabusiness_add _acme-challenge.www.domain.com aaaabbbbcccc111122223333 +# +# Add a new TXT record whose name and value match the given domain and value +# +# Variables +# _full_domain: $1 - the name of the TXT record +# _txt_value: $2 - the value of the TXT record +# _body +# dns_details +# domain_id +# dns_record_id +# response +# +dns_arubabusiness_add() { + _full_domain=$1 + _txt_value=$2 + + if ! _ab_authenticate; then + return 1 + fi + + if ! _ab_domain_id "$_full_domain"; then + return 1 + fi + + if _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then + # This is very unlikely, but allow the process to use the existing record + _info "A TXT record with name: $_full_domain and value: $_txt_value already exists (id: $dns_record_id)" + return 0 + fi + + _body="{ \"IdDomain\": $domain_id, \"Type\": \"TXT\", \"Name\": \"$_full_domain\", \"Content\": \"\\\"$_txt_value\\\"\" }" + + _debug "Adding TXT record with name: $_full_domain and value: $_txt_value" + + if ! _ab_rest POST "api/domains/dns/record" "$_body" || ! _contains "$response" "DomainId"; then + _err "Failed to add TXT record with name: $_full_domain" + return 1 + fi + + _info "Sleeping 10 seconds to let ArubaBusiness do its magic" + _sleep 10 + + # Refresh dns details and check that the record was really added + if ! _ab_dns_details "$root_domain"; then + return 1 + fi + + if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then + # This should never happen + _err "The TXT record with name: $_full_domain was not set" + _err "Please check that the dns records are clean" + return 1 + fi + + _info "Added TXT record with id: $dns_record_id" + return 0 +} + +# +# Usage: dns_arubabusiness_rm _acme-challenge.www.domain.com aaaabbbbcccc111122223333 +# +# Remove the TXT record whose name and value match the given domain and value +# +# Variables +# _full_domain: $1 - the name of the TXT record +# _txt_value: $2 - the value of the TXT record +# dns_details +# dns_record_id +# +dns_arubabusiness_rm() { + _full_domain=$1 + _txt_value=$2 + + if ! _ab_authenticate; then + return 1 + fi + + if ! _ab_domain_id "$_full_domain"; then + return 1 + fi + + if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details" || [ -z "$dns_record_id" ]; then + _err "Could not retrieve the record id for: $_full_domain" + return 1 + fi + + _debug "Deleting TXT record: $dns_record_id" + if ! _ab_rest DELETE "api/domains/dns/record/$dns_record_id" || ! _contains "$response" "DomainId"; then + _err "Failed to delete TXT record: $dns_record_id" + return 1 + fi + + _info "Deleted TXT record: $dns_record_id" + return 0 +} + +######## Private functions ######## + +# +# Usage: _ab_domain_id _acme-challenge.www.domain.com +# +# Split the input domain into subdomain + root domain and get the id of the root domain +# +# Variables +# _full_domain: $1 - the domain whose root needs to be extracted +# _domain_sections +# _current_index +# _candidate_subdomain +# _candidate_domain +# sub_domain +# root_domain +# domain_id +# dns_details: a json containing all dns records registered on the root domain +# +# Example +# _get_root _acme-challenge.www.domain.com +# +# Should return +# sub_domain=_acme-challenge.www +# root_domain=domain.com +# domain_id=123123123123 +# dns_details="{JSON_CONTENT}" +# +_ab_domain_id() { + _full_domain=$1 + + _info "Attempting to retrieve root domain details for: $_full_domain" + + _domain_sections=$(_math "$(printf "%s" "$_full_domain" | tr '.' '\n' | wc -l)" + 1) + + if [ "$_domain_sections" -lt 1 ]; then + _err "Invalid input $_full_domain" + return 1 + fi + + _current_index=1 + while true; do + _candidate_subdomain=$(if [ "$_current_index" = "1" ]; then printf ""; else printf "%s" "$_full_domain" | cut -d . -f 1-"$(_math "$_current_index" - 1)"; fi) + _candidate_domain=$(printf "%s" "$_full_domain" | cut -d . -f "$_current_index"-"$_domain_sections") + + if ! _ab_dns_details "$_candidate_domain"; then + _debug2 "Could not fetch dns details for: $_candidate_domain" + _current_index=$(_math "$_current_index" + 1) + + # Fail if there are no candidates left + if [ "$_current_index" -gt "$_domain_sections" ]; then + _err "Could not determine the root domain for: $_full_domain" + return 1 + fi + else + sub_domain="$_candidate_subdomain" + root_domain="$_candidate_domain" + # Extract the domain id, which is an integer and contains no commas + domain_id="$(printf "%s" "$dns_details" | _egrep_o '"Id":[^,]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "')" + + if [ -z "$domain_id" ]; then + _err "Could not determine the domain id for: $root_domain" + return 1 + fi + + _debug "Retrieved root domain id: $domain_id" + return 0 + fi + done +} + +# +# Usage: _ab_dns_record_id _acme-challenge.www.domain.com "aaaabbbbcccc111122223333" "{JSON_CONTENT}" +# +# Extract the record id of the first TXT record whose name and content match the input values +# +# Variables +# _record_name: $1 +# _txt_value: $2 +# _dns_details: $3 - the json returned by a previous call to '_ab_dns_details() $root_domain' +# _record_ids +# _record_names +# _record_types +# _record_contents +# _record_ids_count +# _record_names_count +# _record_types_count +# _record_contents_count +# _i +# dns_record_id +# +# Notes +# TXT correspond to record type 5 +# ArubaBusiness appends a terminating dot (.) to the record name +# The content field may contain the following character sequence: \" +# All record names are always converted to lowercase +# +_ab_dns_record_id() { + _record_name=$1 + _txt_value=$2 + _dns_details=$3 + + _record_name_lowercase=$(printf "%s" "$_record_name" | _lower_case) + + # Extract the record ids, which are integers and contain no commas, colons or spaces + # The first id is skipped because it refers to the domain id + _record_ids=$(printf "%s" "$_dns_details" | sed 's/"Id":/\n"Id":/g' | _egrep_o '"Id":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ') + + # Extract the record names, which are strings but cannot contain commas, colons, spaces and quotes + # The first name is skipped because it refers to the domain name + _record_names=$(printf "%s" "$_dns_details" | sed 's/"Name":/\n"Name":/g' | _egrep_o '"Name":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' "' | tr '\n' ' ') + + # Extract the record types, which are integers (except for the first one) and contain no commas, colons or spaces + # The first type is skipped because it refers to the domain type + _record_types=$(printf "%s" "$_dns_details" | sed 's/"Type":/\n"Type":/g' | _egrep_o '"Type":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ') + + # Extract the record contents, which are strings and may contain no quotes except for TXT records, which must be delimited by two \" literals + # Note: There is no domain related entry here + # Note: A " character is appended at the end of each content to make it easier to process the list later + _record_contents=$(printf "%s" "$_dns_details" | sed 's/"Content":/\n"Content":/g' | sed 's/\\"//g' | _egrep_o '"Content": *"[^"]*"' | cut -d : -f 2- | sed -n 's/"\(.*\)"/\1/p' | tr '\n' '#') + + _info "IDS: $_record_ids" + _info "NAMES: $_record_names" + _info "TYPEs: $_record_types" + _info "CONTENTS: $_record_contents" + + _record_ids_count=$(printf "%s" "$_record_ids" | tr ' ' '\n' | wc -l) + _record_names_count=$(printf "%s" "$_record_names" | tr ' ' '\n' | wc -l) + _record_types_count=$(printf "%s" "$_record_types" | tr ' ' '\n' | wc -l) + _record_contents_count=$(printf "%s" "$_record_contents" | tr '#' '\n' | wc -l) + + _info "Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count" + + if [ "$_record_ids_count" != "$_record_names_count" ] || [ "$_record_ids_count" != "$_record_types_count" ] || [ "$_record_ids_count" != "$_record_contents_count" ]; then + _err "Failed to parse record elements. Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count" + return 1 + fi + + _info "Looking for a TXT record matching inputs - name: $_record_name_lowercase value: $_txt_value" + + _i=1 + while [ "$_i" -le "$_record_ids_count" ]; do + _current_name=$(printf "%s" "$_record_names" | cut -d " " -f "$_i") + _current_type=$(printf "%s" "$_record_types" | cut -d " " -f "$_i") + _current_content=$(printf "%s" "$_record_contents" | cut -d "#" -f "$_i") + + if [ "$_record_name_lowercase." = "$_current_name" ] && [ "5" = "$_current_type" ] && [ "$_txt_value" = "$_current_content" ]; then + dns_record_id=$(printf "%s" "$_record_ids" | cut -d " " -f "$_i") + _info "Found matching record with id: $dns_record_id" + return 0 + else + _debug2 "Record does not match - type: '$_current_type' name: '$_current_name' value: '$_current_content'; Expected '$_record_name_lowercase.' '5' '$_txt_value'" + fi + _i=$(_math "$_i" + 1) + done + + _debug2 "No matching record was found in $_dns_details" + return 1 +} + +# +# Usage: _ab_dns_details domain.com +# +# Retrieve dns info for the given input domain +# +# Variables +# _domain: $1 +# dns_details: the json returned by the call to $ARUBABUSINESS_API/api/domains/dns/$_domain/details (if return status is 0) +# response +# +_ab_dns_details() { + _domain=$1 + + if ! _ab_rest GET "api/domains/dns/$_domain/details" || ! _contains "$response" "DomainId"; then + return 1 + fi + + dns_details="$response" + return 0 +} + +# +# Usage: _ab_authenticate +# +# Read account conf, update domain conf and perform user authentication to acquire an access token +# +# Variables +# AB_Key +# AB_User +# AB_Pass +# AB_Token +# +_ab_authenticate() { + AB_Key="${AB_Key:-$(_readaccountconf_mutable AB_Key)}" + AB_User="${AB_User:-$(_readaccountconf_mutable AB_User)}" + AB_Pass="${AB_Pass:-$(_readaccountconf_mutable AB_Pass)}" + + if [ -z "$AB_Key" ] || [ -z "$AB_User" ] || [ -z "$AB_Pass" ]; then + AB_Key="" + AB_User="" + AB_Pass="" + _err "Either the ArubaBusiness API key, the user or the password has not been defined yet." + _err "Please configure them and try again." + return 1 + fi + + _saveaccountconf_mutable AB_Key "$AB_Key" + _saveaccountconf_mutable AB_User "$AB_User" + _saveaccountconf_mutable AB_Pass "$AB_Pass" + + if ! _ab_get_token || [ -z "$AB_Token" ]; then + _err "Failed to acquire an access token" + return 1 + fi + + return 0 +} + +# +# Usage: _ab_get_token +# +# Try acquiring a temporary access token. The token should have a 24h lifespan +# +# Variables +# _ab_user_enc +# _ab_pass_enc +# _ab_authdata +# AB_User +# AB_Pass +# AB_Token +# response +# _H2 +# +_ab_get_token() { + _ab_user_enc=$(printf "%s" "$AB_User" | _url_encode) + _ab_pass_enc=$(printf "%s" "$AB_Pass" | _url_encode) + _ab_authdata="grant_type=password&username=$_ab_user_enc&password=$_ab_pass_enc" + + _H2="Content-Type: application/x-www-form-urlencoded" + + if ! _ab_rest POST "auth/token" "$_ab_authdata" || ! _contains "$response" "access_token"; then + _err "Authentication failure" + return 1 + fi + + AB_Token="$(printf "%s" "$response" | _egrep_o '"access_token":"[^\"]*"' | cut -d : -f 2 | tr -d '"')" + + if [ -z "$AB_Token" ]; then + _err "Could not extract access token" + return 1 + fi + + _debug "Acquired access token" + return 0 +} + +# +# Usage: _ab_rest POST "example/endpoint" "password=123" +# +# Perform a REST request using the given method, endpoint and data +# +# Variables +# _method: $1 - The http method +# _endpoint: $2 - The api path (relative to $ARUBABUSINESS_API) +# _data: $3 - The body of the request (optional) +# _key_trimmed +# _token_trimmed +# _ret_code +# AB_Key +# AB_Token +# ARUBABUSINESS_API +# _H1 +# _H2 +# _H3 +# _H4 +# +_ab_rest() { + _method=$1 + _endpoint="$2" + _data="$3" + + _key_trimmed=$(printf "%s" "$AB_Key" | tr -d '"') + _token_trimmed=$(printf "%s" "$AB_Token" | tr -d '"') + + _H1="Accept: application/json" + + if [ -z "$_H2" ]; then + # Default to application/json + _H2="Content-Type: application/json" + fi + + if [ "$_key_trimmed" ]; then + _H3="Authorization-Key: $_key_trimmed" + else + _err "Missing Api Key" + _ab_cleanup_headers + return 1 + fi + + if [ "$_token_trimmed" ]; then + _H4="Authorization: Bearer $_token_trimmed" + else + _debug "No access token set" + fi + + if [ "$_method" != "GET" ]; then + response="$(_post "$_data" "$ARUBABUSINESS_API/$_endpoint" "" "$_method")" + else + response="$(_get "$ARUBABUSINESS_API/$_endpoint")" + fi + + _ret_code=$? + + if [ "$_ret_code" = "0" ] && _ab_call_is_success; then + # Normalize the json response + response="$(printf "%s" "$response" | _normalizeJson)" + _ret_code=0 + else + _err "Failed to call endpoint: $_endpoint" + _ret_code=1 + fi + + _ab_cleanup_headers + + return $_ret_code +} + +# +# Usage: _ab_cleanup_headers +# +# Unset header variables to avoid interfering with other calls +# +# Variables +# _H1 +# _H2 +# _H3 +# _H4 +# +_ab_cleanup_headers() { + # Cleanup request headers + unset _H1 _H2 _H3 _H4 _H5 + + # Cleanup response headers + if [ -f "$HTTP_HEADER" ]; then + : >"$HTTP_HEADER" + fi +} + +# +# Usage: _ab_call_is_success +# +# Check whether a call's response http status is one of 200, 201, 202 or 204 (other 2xx are not handled) +# +# Variables +# _status +# _http_status +# _success_http_codes +# HTTP_HEADER +# +_ab_call_is_success() { + _success_http_codes="200 201 202 204" + if [ -f "$HTTP_HEADER" ]; then + _http_status=$(_egrep_o "^HTTP[\/0-9. ]*" <"$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2) + for _status in $_success_http_codes; do + if [ "$_status" = "$_http_status" ]; then + return 0 + fi + done + fi + + return 1 +} diff --git a/dnsapi/dns_autodns.sh b/dnsapi/dns_autodns.sh index ce566978..e26d699b 100644 --- a/dnsapi/dns_autodns.sh +++ b/dnsapi/dns_autodns.sh @@ -139,12 +139,21 @@ _get_autodns_zone() { return 1 } +# Escape the XML special characters (& < > ' ") so that credentials +# containing them do not break the request document (issue 5317). +_autodns_xml_encode() { + sed "s/&/\&/g;s//\>/g;s/'/\'/g;s/\"/\"/g" +} + _build_request_auth_xml() { + _autodns_user_xml="$(printf "%s" "$AUTODNS_USER" | _autodns_xml_encode)" + _autodns_password_xml="$(printf "%s" "$AUTODNS_PASSWORD" | _autodns_xml_encode)" + _autodns_context_xml="$(printf "%s" "$AUTODNS_CONTEXT" | _autodns_xml_encode)" printf " %s %s %s - " "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT" + " "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml" } # Arguments: diff --git a/dnsapi/dns_aws.sh b/dnsapi/dns_aws.sh index b76d69c2..1face1c8 100755 --- a/dnsapi/dns_aws.sh +++ b/dnsapi/dns_aws.sh @@ -11,7 +11,8 @@ Options: # All `_sleep` commands are included to avoid Route53 throttling, see # https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests -AWS_HOST="route53.amazonaws.com" +# Updated from "route53.amazonaws.com" +AWS_HOST="route53.global.api.aws" AWS_URL="https://$AWS_HOST" AWS_WIKI="https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Amazon-Route53-API" diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh index 8651deab..dfad8eeb 100644 --- a/dnsapi/dns_baidu.sh +++ b/dnsapi/dns_baidu.sh @@ -49,26 +49,95 @@ Options: Baidu_SK SecretAccessKey OptionsAlt: Baidu_BCD_Host API host, default: bcd.baidubce.com + Baidu_DNS_Host New DNS API host, default: dns.baidubce.com + Baidu_API_Preference Engine preference, default: auto Baidu_BCD_Version API version number, default: 1 Baidu_BCD_Expire Signature expiration seconds, default: 3600 Baidu_View Resolve view, default: DEFAULT + Baidu_Line New DNS line, default: default Baidu_TTL Resolve ttl seconds, default: 300 Baidu_RM_Max Max records to delete in one run, default: 20 ' BAIDU_BCD_DEFAULT_HOST="bcd.baidubce.com" +BAIDU_DNS_DEFAULT_HOST="dns.baidubce.com" # --- Public API --- dns_baidu_add() { fulldomain=$(_idn "$1") txtvalue=$2 - if ! _baidu_prepare_record "$fulldomain"; then - _baidu_err "baidu_prepare_record failed for add: $fulldomain" + if ! _baidu_run_with_fallback "add" "$fulldomain" "$txtvalue"; then + _baidu_err "all baidu api engines failed for add: $fulldomain" return 1 fi - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then + return 0 +} + +dns_baidu_rm() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _baidu_run_with_fallback "rm" "$fulldomain" "$txtvalue"; then + _baidu_err "all baidu api engines failed for delete: $fulldomain" + return 1 + fi + + return 0 +} + +_baidu_run_with_fallback() { + _action="$1" + _fulldomain="$2" + _txtvalue="$3" + + if ! _baidu_load_credentials; then + _baidu_err "baidu_load_credentials failed" + return 1 + fi + + for _baidu_api_engine in $(_baidu_engine_order); do + if ! _baidu_prepare_record "$_fulldomain"; then + _baidu_info "prepare failed for engine: $_baidu_api_engine" + continue + fi + + if [ "$_action" = "add" ]; then + if _baidu_add_record "$_txtvalue"; then + return 0 + fi + else + if _baidu_rm_record "$_txtvalue"; then + return 0 + fi + fi + + _baidu_info "engine failed, try next if available: $_baidu_api_engine" + done + + return 1 +} + +_baidu_engine_order() { + _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")" + case "$_pref" in + legacy) + printf "%s" "legacy new" + ;; + new) + printf "%s" "new legacy" + ;; + *) + printf "%s" "new legacy" + ;; + esac +} + +_baidu_add_record() { + _txtvalue="$1" + + if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then _baidu_err "baidu_find_record_ids failed for add: $_record_domain.$_zone_name" return 1 fi @@ -85,16 +154,28 @@ dns_baidu_add() { _ttl="300" ;; esac - _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")" - txtvalue="$(_baidu_trim_ws "$txtvalue")" + + txtvalue="$(_baidu_trim_ws "$_txtvalue")" _record_domain="$(_baidu_trim_ws "$_record_domain")" _zone_name="$(_baidu_trim_ws "$_zone_name")" - _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")" - - if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then - _baidu_err "baidu_bcd_post failed: add record" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + _line="$(_baidu_trim_ws "${Baidu_Line:-default}")" + if [ -z "$_line" ]; then + _line="default" + fi + _body="$(_baidu_payload_add_txt_dns "$_record_domain" "$txtvalue" "$_ttl" "$_line")" + if ! _baidu_dns_call "POST" "/v1/dns/zone/${_zone_name}/record" "$_body"; then + _baidu_err "baidu_dns_call failed: add record" + return 1 + fi + else + _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")" + _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")" + if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then + _baidu_err "baidu_bcd_post failed: add record" + return 1 + fi fi if _baidu_is_api_error "$response"; then @@ -105,16 +186,10 @@ dns_baidu_add() { return 0 } -dns_baidu_rm() { - fulldomain=$(_idn "$1") - txtvalue=$2 +_baidu_rm_record() { + _txtvalue="$1" - if ! _baidu_prepare_record "$fulldomain"; then - _baidu_err "baidu_prepare_record failed for delete: $fulldomain" - return 1 - fi - - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then + if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then _baidu_err "baidu_find_record_ids failed for delete: $_record_domain.$_zone_name" return 1 fi @@ -138,28 +213,37 @@ dns_baidu_rm() { fi for _rid in $_ids; do - _body="$(_baidu_payload_delete "$_zone_name" "$_rid")" - if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then - _baidu_err "baidu_bcd_post failed: delete recordId=$_rid" - return 1 - fi - if _baidu_is_api_error "$response"; then - _baidu_err "$response" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + if ! _baidu_dns_call "DELETE" "/v1/dns/zone/${_zone_name}/record/${_rid}" ""; then + _baidu_err "baidu_dns_call failed: delete recordId=$_rid" + return 1 + fi + else + _body="$(_baidu_payload_delete "$_zone_name" "$_rid")" + if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then + _baidu_err "baidu_bcd_post failed: delete recordId=$_rid" + return 1 + fi + if _baidu_is_api_error "$response"; then + _baidu_err "$response" + return 1 + fi fi done - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then - _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name" - return 1 - fi - _left_ids="$_BAIDU_FIND_RESULT" - if [ -z "$_left_ids" ]; then - return 0 - fi - if [ -n "$_left_ids" ]; then - _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records" - return 1 + if [ "$_baidu_api_engine" = "legacy" ]; then + if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then + _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name" + return 1 + fi + _left_ids="$_BAIDU_FIND_RESULT" + if [ -z "$_left_ids" ]; then + return 0 + fi + if [ -n "$_left_ids" ]; then + _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records" + return 1 + fi fi return 0 @@ -182,6 +266,7 @@ _baidu_load_credentials() { _saveaccountconf_mutable Baidu_SK "$Baidu_SK" BAIDU_BCD_HOST="${Baidu_BCD_Host:-$BAIDU_BCD_DEFAULT_HOST}" + BAIDU_DNS_HOST="${Baidu_DNS_Host:-$BAIDU_DNS_DEFAULT_HOST}" BAIDU_BCD_VERSION="${Baidu_BCD_Version:-1}" return 0 @@ -189,13 +274,16 @@ _baidu_load_credentials() { _baidu_prepare_record() { _fulldomain="$1" - if ! _baidu_load_credentials; then - _baidu_err "baidu_load_credentials failed" - return 1 - fi - if ! _baidu_get_root "$_fulldomain"; then - _baidu_err "Could not find zone for $_fulldomain" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + if ! _baidu_get_root_dns "$_fulldomain"; then + _baidu_err "Could not find zone by new dns api for $_fulldomain" + return 1 + fi + else + if ! _baidu_get_root "$_fulldomain"; then + _baidu_err "Could not find zone by legacy bcd api for $_fulldomain" + return 1 + fi fi _record_domain="$_sub_domain" _zone_name="$_domain" @@ -234,6 +322,43 @@ _baidu_get_root() { done } +_baidu_get_root_dns() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + _baidu_err "invalid domain: $domain" + return 1 + fi + + if ! _baidu_dns_call "GET" "/v1/dns/zone/${h}/record" ""; then + _baidu_info "baidu_dns_call failed: list zones" + elif ! _baidu_is_api_error "$response" && (_contains "$response" "\"records\"" || _contains "$response" "\"maxKeys\""); then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + if [ "$_sub_domain" = "$_domain" ]; then + _sub_domain="@" + fi + _baidu_info "zone matched by dns api: $_domain (host: $_sub_domain)" + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done +} + +_baidu_find_record_ids_current() { + if [ "$_baidu_api_engine" = "new" ]; then + _baidu_find_record_ids_dns "$@" + else + _baidu_find_record_ids "$@" + fi +} + _baidu_find_record_ids() { _zone_name="$1" _record_domain="$2" @@ -293,6 +418,39 @@ EOF _BAIDU_FIND_RESULT="$_ids" } +_baidu_find_record_ids_dns() { + _zone_name="$1" + _record_domain="$2" + _rdtype="$3" + _rdata="$4" + _BAIDU_FIND_RESULT="" + + if ! _baidu_dns_call "GET" "/v1/dns/zone/${_zone_name}/record" ""; then + _baidu_err "baidu_dns_call failed: list records" + return 1 + fi + + if _baidu_is_api_error "$response"; then + _baidu_err "baidu_dns error: $(_baidu_json_get_str "$response" "code") $(_baidu_json_get_str "$response" "message")" + return 1 + fi + + _normalized="$(printf "%s" "$response" | _normalizeJson)" + _records=$(printf "%s" "$_normalized" | sed 's/},{/}\n{/g') + _ids="" + + while IFS= read -r _line; do + _id="$(_baidu_match_record_id_dns "$_line" "$_record_domain" "$_rdtype" "$_rdata")" + if [ "$_id" ]; then + _ids="$_ids $_id" + fi + done < ${_now})" + return 0 + fi + + _info "ComLaude auth..." + _comlaude_body="{\"username\":\"$COMLAUDE_USERNAME\",\"password\":\"$COMLAUDE_PASSWORD\",\"api_key\":\"$COMLAUDE_API_KEY\"}" + _comlaude_response="$(_post "$_comlaude_body" "$COMLAUDE_API/api_login" "" "POST" "application/json")" + + if ! _contains "$_comlaude_response" "access_token"; then + _err "Auth failed: $_comlaude_response" + return 1 + fi + + COMLAUDE_ACCESS_TOKEN=$(echo "$_comlaude_response" | _egrep_o '"access_token":"[^"]*"' | cut -d'"' -f4) + # store expiracy from api reply l'API ("expires_in" in seconds) + _comlaude_expires_in=$(echo "$_comlaude_response" | _egrep_o '"expires_in":[0-9]*' | cut -d: -f2) + [ -z "$_comlaude_expires_in" ] && _comlaude_expires_in=3000 # fallback if no info + + COMLAUDE_TOKEN_EXPIRY=$(($(_time) + _comlaude_expires_in - 60)) # margin of 60s to secure renew + + _saveaccountconf_mutable COMLAUDE_ACCESS_TOKEN "$COMLAUDE_ACCESS_TOKEN" + _saveaccountconf_mutable COMLAUDE_TOKEN_EXPIRY "$COMLAUDE_TOKEN_EXPIRY" + + return 0 +} + +########## DOMAIN RESOLUTION ########## + +_comlaude_get_root() { + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + if [ -z "$COMLAUDE_GROUP_ID" ]; then + _err "Missing COMLAUDE_GROUP_ID" + return 1 + fi + + _comlaude_input_domain="$1" + _comlaude_input_domain="${_comlaude_input_domain#_acme-challenge.}" + case "$_comlaude_input_domain" in + \*.*) _comlaude_input_domain="${_comlaude_input_domain#*.}" ;; + esac + + _debug "Normalized domain: $_comlaude_input_domain" + + _comlaude_i=1 + while true; do + _comlaude_d=$(printf "%s" "$_comlaude_input_domain" | cut -d . -f "$_comlaude_i-") + [ -z "$_comlaude_d" ] && { + _debug "No matching domain found for $_comlaude_input_domain" + return 1 + } + + # don't test unnecessary levels + # registered domain : TLD only (no dot after cut). + case "$_comlaude_d" in + *.*) : ;; + *) + _debug "Skipping bare TLD candidate: $_comlaude_d" + _comlaude_i=$((_comlaude_i + 1)) + continue + ;; + esac + + _debug "Checking domain: $_comlaude_d" + + _comlaude_retry=0 + _comlaude_max_retry=3 # to avoid network errors + _comlaude_DOM_ID="" + _comlaude_Z_ID="" + + while [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ]; do + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _debug "Full URL: $COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone" + _comlaude_response="$(_get "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone")" + _H1="" + + _debug "RAW response for $_comlaude_d (try $((_comlaude_retry + 1))): $_comlaude_response" + + # If empty -> true network issue, we retry + if [ -z "$_comlaude_response" ]; then + _comlaude_retry=$((_comlaude_retry + 1)) + [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2 + continue + fi + + # 404 -> domain not found in that level. no retry : continue + if echo "$_comlaude_response" | grep -q '"status_code":404'; then + _debug "404 for $_comlaude_d, moving to next level (not retrying)" + break + fi + + # Domain missing (200 reply, data empty) -> continue + if echo "$_comlaude_response" | grep -q '"data":\[\]'; then + _debug "Empty data for $_comlaude_d, moving to next level" + break + fi + + # Extraction via _egrep_o + _comlaude_DOM_ID="$(echo "$_comlaude_response" | _egrep_o '"id":"[^"]*"' | head -n1 | cut -d':' -f2 | tr -d '"')" + _comlaude_Z_ID="$(echo "$_comlaude_response" | _egrep_o '"active_zone":\{"id":"[^"]*"' | _egrep_o '"id":"[^"]*"$' | cut -d':' -f2 | tr -d '"')" + + if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then + break + fi + + # 200 reply but malformed data / noid -> retry transport + _comlaude_retry=$((_comlaude_retry + 1)) + [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2 + done + + _debug "_comlaude_DOM_ID=$_comlaude_DOM_ID" + _debug "_comlaude_Z_ID=$_comlaude_Z_ID" + + if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then + _comlaude_domain="$_comlaude_d" + _comlaude_domain_id="$_comlaude_DOM_ID" + _comlaude_zone_id="$_comlaude_Z_ID" + return 0 + fi + + _comlaude_i=$((_comlaude_i + 1)) + done +} +########## ADD TXT ########## + +dns_comlaude_add() { + fulldomain="$1" + txtvalue="$2" + + COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}" + COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}" + COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}" + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + + if [ -z "$COMLAUDE_USERNAME" ] || [ -z "$COMLAUDE_PASSWORD" ] || [ -z "$COMLAUDE_API_KEY" ]; then + _err "You didn't specify ComLaude credentials (COMLAUDE_USERNAME, COMLAUDE_PASSWORD, COMLAUDE_API_KEY)." + return 1 + fi + + # Backup variable after validation + _saveaccountconf_mutable COMLAUDE_USERNAME "$COMLAUDE_USERNAME" + _saveaccountconf_mutable COMLAUDE_PASSWORD "$COMLAUDE_PASSWORD" + _saveaccountconf_mutable COMLAUDE_API_KEY "$COMLAUDE_API_KEY" + _saveaccountconf_mutable COMLAUDE_GROUP_ID "$COMLAUDE_GROUP_ID" + + _info "Adding TXT: $fulldomain" + _comlaude_auth || return 1 + _comlaude_get_root "$fulldomain" || return 1 + + _debug "Root: $_comlaude_domain" + + _comlaude_data="{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"value\":\"$txtvalue\",\"ttl\":60}" + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + export _H2="Content-Type: application/json" + + _comlaude_response="$(_post "$_comlaude_data" "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records")" + + _H1="" + _H2="" + if ! echo "$_comlaude_response" | grep -q '"id"'; then + _err "Failed to create TXT" + _debug "$_comlaude_response" + return 1 + fi + + return 0 +} + +########## REMOVE TXT ########## + +dns_comlaude_rm() { + fulldomain="$1" + txtvalue="$2" + + COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}" + COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}" + COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}" + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + + _info "Removing TXT: $fulldomain" + + _comlaude_auth || return 1 + _comlaude_get_root "$fulldomain" || return 1 + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _comlaude_encoded_name="$(printf '%s' "$fulldomain" | _url_encode)" + _comlaude_encoded_value="$(printf '%s' "$txtvalue" | _url_encode)" + _comlaude_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records?filter[type]=TXT&filter[name]=$_comlaude_encoded_name&filter[value]=$_comlaude_encoded_value" + _comlaude_response="$(_get "$_comlaude_url")" + _H1="" + + _debug "Filtered records response: $_comlaude_response" + + # first "id" top-level of reply (record itself, + # always on first position of each data[] object) + _comlaude_record_id="$(echo "$_comlaude_response" | _egrep_o '"data":\[\{"id":"[^"]*"' | _egrep_o '"[^"]*"$' | tr -d '"')" + + if [ -z "$_comlaude_record_id" ]; then + _info "No matching TXT record found to delete for $fulldomain / $txtvalue" + return 0 + fi + + _debug "Deleting record $_comlaude_record_id" + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _comlaude_del_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records/$_comlaude_record_id" + _comlaude_del_resp="$(_post "" "$_comlaude_del_url" "" "DELETE")" + _H1="" + + if echo "$_comlaude_del_resp" | grep -q '"error"'; then + _err "Delete failed for $_comlaude_record_id" + _debug "$_comlaude_del_resp" + return 1 + fi + + _info "Deleted record $_comlaude_record_id" + return 0 +} diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh index a6991403..6939c3f1 100755 --- a/dnsapi/dns_cpanel.sh +++ b/dnsapi/dns_cpanel.sh @@ -38,7 +38,7 @@ dns_cpanel_add() { fi # adding entry _info "Adding the entry" - stripped_fulldomain=$(echo "$fulldomain" | sed "s/.$_domain//") + stripped_fulldomain="${fulldomain%."$_domain"}" _debug "Adding $stripped_fulldomain to $_domain zone" _myget "json-api/cpanel?cpanel_jsonapi_apiversion=2&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=add_zone_record&domain=$_domain&name=$stripped_fulldomain&type=TXT&txtdata=$txtvalue&ttl=1" if _successful_update; then return 0; fi @@ -128,13 +128,27 @@ _get_root() { _err "Primary domain list not found!" return 1 fi - for _domain in $_domains; do - _debug "Checking if $fulldomain ends with $_domain" - if (_endswith "$fulldomain" "$_domain"); then - _debug "Root domain: $_domain" - return 0 - fi + # Pick the LONGEST matching zone, dot-anchored: with both domain.tld and + # sub.domain.tld zones on the account, cPanel stores the record in the + # most specific zone, so add and rm must both resolve to that one. + _domain="" + for d in $_domains; do + _debug "Checking if $fulldomain ends with $d" + # case with quoted patterns gives an exact literal suffix match; + # _endswith treats the needle as a regex, so its dots would let + # xdomain.tld wrongly match zone domain.tld + case "$fulldomain" in + "$d" | *".$d") + if [ "${#d}" -gt "${#_domain}" ]; then + _domain="$d" + fi + ;; + esac done + if [ -n "$_domain" ]; then + _debug "Root domain: $_domain" + return 0 + fi return 1 } diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh new file mode 100644 index 00000000..f4d76f8e --- /dev/null +++ b/dnsapi/dns_creoline.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_creoline_info='creoline +Site: https://www.creoline.com/de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_creoline +Help: https://help.creoline.com +Options: + creolineApiToken + creolineApiSecret +Issues: github.com/acmesh-official/acme.sh/issues/7103 +' + +creolineApi="https://api.creoline.com/v1" + +######## Public functions ##################### + +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPB8" +dns_creoline_add() { + fulldomain=$1 + txtvalue=$2 + + creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}" + creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}" + + if [ -z "$creolineApiToken" ] || [ -z "$creolineApiSecret" ]; then + _err "Error required creoline API Token or creoline API Secret not specified." + _err "Please set it with the Command 'export creolineApiToken=' and 'export creolineApiSecret='." + return 1 + else + _saveaccountconf_mutable creolineApiToken "$creolineApiToken" + _saveaccountconf_mutable creolineApiSecret "$creolineApiSecret" + fi + + _debug "Detecting the root dns zone." + if ! _get_root "$fulldomain"; then + _err "Error on detecting the root dns zone." + return 1 + fi + + _info "Adding record" + if _creoline_rest POST "dns/zone/$_domain/record" "{\"type\":\"TXT\",\"host\":\"$_sub_domain\",\"record\":\"$txtvalue\",\"ttl\":\"60\"}"; then + if _contains "$response" "$txtvalue"; then + _info "Added, OK" + return 0 + else + _err "Add txt record error." + return 1 + fi + fi + _err "Add txt record error." + return 1 +} + +#fulldomain txtvalue +dns_creoline_rm() { + fulldomain=$1 + txtvalue=$2 + + creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}" + creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}" + + _debug "Detecting the root dns zone." + if ! _get_root "$fulldomain"; then + _err "Error on detecting the root dns zone." + return 1 + fi + + _info "Getting earlier created txt record." + if ! _creoline_rest GET "dns/zone/$_domain/record/type/TXT/record/$txtvalue"; then + if _contains "$response" "errors" || _contains "$response" "message"; then + _err "Error on getting earlier created txt record." + return 1 + fi + _err "Error on getting earlier created txt record." + return 1 + fi + + record_id=$(echo "$response" | _egrep_o "\"id\"[ ]*:[ ]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _debug "record_id" "$record_id" + + if [ -z "$record_id" ]; then + _err "Error on deleting earlier created txt record. No record id found in response." + return 1 + fi + + _info "Deleting earlier created txt record." + if ! _creoline_rest DELETE "dns/zone/$_domain/record/$record_id"; then + if _contains "$response" "errors" || _contains "$response" "message"; then + _err "Error on deleting earlier created txt record." + return 1 + fi + _err "Error on deleting earlier created txt record." + return 1 + fi + + _info "Deleted, OK" + return 0 +} + +#################### Private functions below ################################## +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +_get_root() { + domain=$1 + if ! _creoline_rest GET "dns/zone/root/$domain"; then + return 1 + fi + + _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _debug _sub_domain "$_sub_domain" + + _domain=$(echo "$response" | _egrep_o "\"domain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") + _debug _domain "$_domain" + + if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then + return 1 + fi +} + +_creoline_rest() { + method=$1 + uri="$2" + data="$3" + timestamp=$(_time) + canonical_request="${timestamp}.${creolineApi}/${uri}" + signature_hash=$(printf "%s" "$canonical_request" | _hmac sha256 "$(printf "%s" "$creolineApiSecret" | _hex_dump | tr -d " ")" hex) + + _debug method "$method" + _debug uri "$uri" + _debug data "$data" + + _debug2 timestamp "$timestamp" + _debug2 canonical_request "$canonical_request" + _debug2 signature_hash "$signature_hash" + + token_trimmed=$(echo "$creolineApiToken" | tr -d '"') + hmac_trimmed=$(echo "$signature_hash" | tr -d '"') + + export _H1="Content-Type: application/json" + + if [ "$token_trimmed" ]; then + export _H2="X-Api-Token: $token_trimmed" + fi + + if [ "$hmac_trimmed" ]; then + export _H3="X-Creoline-Api-Signature: $hmac_trimmed" + fi + + if [ "$timestamp" ]; then + export _H4="X-Creoline-Api-Timestamp: $timestamp" + fi + + if [ "$method" != "GET" ]; then + response="$(_post "$data" "$creolineApi/$uri" "" "$method")" + else + response="$(_get "$creolineApi/$uri")" + fi + + if [ "$?" != "0" ]; then + _err "error $uri" + return 1 + fi + + _debug response "$response" + + if _contains "$response" "errors"; then + error=$(echo "$response" | _egrep_o "\"errors\":[[]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | tr -d "[") + _err "Error: $error" + _err "URI:$uri" + return 1 + elif _contains "$response" "message"; then + message=$(echo "$response" | _egrep_o "\"message\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \") + _err "Error: $message" + _err "URI:$uri" + return 1 + fi + + return 0 +} diff --git a/dnsapi/dns_cyon.sh b/dnsapi/dns_cyon.sh index d4b6b6e8..6677b32f 100644 --- a/dnsapi/dns_cyon.sh +++ b/dnsapi/dns_cyon.sh @@ -285,15 +285,15 @@ _cyon_delete_txt() { list_txt_url="https://my.cyon.ch/domain/dnseditor/list-async" - list_txt_response="$(_get "${list_txt_url}" | sed -e 's/data-hash/\\ndata-hash/g')" + list_txt_response="$(_get "${list_txt_url}")" _debug list_txt_response "${list_txt_response}" if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi # Find and delete all acme challenge entries for the $fulldomain. - _dns_entries="$(printf "%b\n" "${list_txt_response}" | sed -n 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\".*/\1 \2/p')" + _dns_entries="$(printf "%s\n" "${list_txt_response}" | _egrep_o 'data-hash=\\"[^"]*\\" data-identifier=\\"[^"]*\\"' | sed 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\"/\1 \2/')" - printf "%s" "${_dns_entries}" | while read -r _hash _identifier; do + printf "%s\n" "${_dns_entries}" | while read -r _hash _identifier; do dns_type="$(printf "%s" "$_identifier" | cut -d'|' -f1)" dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)" diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh index f0f4c32e..6ad60442 100644 --- a/dnsapi/dns_czechia.sh +++ b/dnsapi/dns_czechia.sh @@ -30,8 +30,9 @@ dns_czechia_add() { return 1 fi - _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//') - _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + _czechia_tab="$(printf '\t')" + _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//") + _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//") if [ -z "$_cz" ] || [ -z "$_tk" ]; then _err "Missing zone or CZ_AuthorizationToken." @@ -76,7 +77,7 @@ dns_czechia_add() { return 0 fi - _nres="$(_normalizeJson "$_res")" + _nres="$(printf '%s' "$_res" | _normalizeJson)" if [ "$?" -ne 0 ] || [ -z "$_nres" ]; then _nres="$_res" fi @@ -108,8 +109,9 @@ dns_czechia_rm() { return 1 fi - _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//') - _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + _czechia_tab="$(printf '\t')" + _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//") + _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//") if [ -z "$_cz" ] || [ -z "$_tk" ]; then _err "Missing zone or CZ_AuthorizationToken." @@ -180,12 +182,13 @@ _czechia_load_conf() { } _czechia_pick_zone() { + _czechia_pz_tab="$(printf '\t')" _fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//') _best_zone="" _zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g') for _z in $_zones_space; do - _clean_z=$(printf "%s" "$_z" | _lower_case | sed 's/[[:space:]]//g; s/\.$//') + _clean_z=$(printf "%s" "$_z" | _lower_case | sed "s/[ $_czechia_pz_tab]//g; s/\.\$//") [ -z "$_clean_z" ] && continue case "$_fd" in diff --git a/dnsapi/dns_da.sh b/dnsapi/dns_da.sh index 36251b05..d9cf6247 100755 --- a/dnsapi/dns_da.sh +++ b/dnsapi/dns_da.sh @@ -4,7 +4,7 @@ dns_da_info='DirectAdmin Server API Site: DirectAdmin.com/api.php Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_da Options: - DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443" + DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443". Special characters in the user/password must be percent-encoded, e.g. "@" -> "%40". DA_Api_Insecure Insecure TLS. 0: check for cert validity, 1: always accept Issues: github.com/TigerP/acme.sh/issues ' diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index d6b9c355..e5e4809a 100644 --- a/dnsapi/dns_desec.sh +++ b/dnsapi/dns_desec.sh @@ -4,7 +4,7 @@ dns_desec_info='deSEC.io Site: desec.readthedocs.io/en/latest/ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_desec Options: - DDNSS_Token API Token + DEDYN_TOKEN API Token Issues: github.com/acmesh-official/acme.sh/issues/2180 Author: Zheng Qian ' @@ -39,6 +39,7 @@ dns_desec_add() { _err "invalid domain" return 1 fi + _sub_domain=$(echo "$_sub_domain" | _lower_case) _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" @@ -48,7 +49,7 @@ dns_desec_add() { _desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/" if [ "$_code" = "200" ]; then - oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" + oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" _debug "existing TXT found" _debug oldtxtvalues "$oldtxtvalues" if [ -n "$oldtxtvalues" ]; then @@ -100,7 +101,7 @@ dns_desec_rm() { _err "invalid domain" return 1 fi - + _sub_domain=$(echo "$_sub_domain" | _lower_case) _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" @@ -110,7 +111,7 @@ dns_desec_rm() { _desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/" if [ "$_code" = "200" ]; then - oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" + oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" _debug "existing TXT found" _debug oldtxtvalues "$oldtxtvalues" if [ -n "$oldtxtvalues" ]; then @@ -150,6 +151,8 @@ _desec_rest() { if [ "$m" != "GET" ]; then _secure_debug2 data "$data" response="$(_post "$data" "$ep" "" "$m")" + _info "Sleeping 1s to respect deSEC write rate limit" + _sleep 1 else response="$(_get "$ep")" fi diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index ec3b07a4..b92482cf 100644 --- a/dnsapi/dns_dnsexit.sh +++ b/dnsapi/dns_dnsexit.sh @@ -5,14 +5,11 @@ Site: DNSExit.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsexit Options: DNSEXIT_API_KEY API Key - DNSEXIT_AUTH_USER Username - DNSEXIT_AUTH_PASS Password Issues: github.com/acmesh-official/acme.sh/issues/4719 Author: Samuel Jimenez ' DNSEXIT_API_URL="https://api.dnsexit.com/dns/" -DNSEXIT_HOSTS_URL="https://update.dnsexit.com/ipupdate/hosts.jsp" ######## Public functions ##################### #Usage: dns_dnsexit_add _acme-challenge.*.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" @@ -28,20 +25,7 @@ dns_dnsexit_add() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"add\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":0,\"overwrite\":false}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op add ',"ttl":1,"overwrite":false' } #Usage: fulldomain txtvalue @@ -58,54 +42,43 @@ dns_dnsexit_rm() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - _err "$response" - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"delete\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op delete '' } #################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -_get_root() { - domain=$1 +# The legacy zone-detection endpoint (update.dnsexit.com/ipupdate/hosts.jsp) +# was shut down by DNSExit and now returns 503, and the JSON API offers no +# zone-list call. So find the root zone by attempting the actual operation at +# each domain level: the API answers "code":0 only when the domain matches a +# zone of the account. https://github.com/acmesh-official/acme.sh/issues/6914 +#Usage: _dnsexit_zone_op +_dnsexit_zone_op() { + _op="$1" + _extra="$2" i=1 while true; do - _domain=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$_domain" + _domain=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100) + _debug _domain "$_domain" if [ -z "$_domain" ]; then + _err "Could not find the root zone of $fulldomain in your DNSExit account" return 1 fi - _debug login "$DNSEXIT_AUTH_USER" - _debug password "$DNSEXIT_AUTH_PASS" - _debug domain "$_domain" + _sub_domain="$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")" + if [ "$_sub_domain" = "$fulldomain" ]; then + _sub_domain="" + fi + _debug _sub_domain "$_sub_domain" - _dnsexit_http "login=$DNSEXIT_AUTH_USER&password=$DNSEXIT_AUTH_PASS&domain=$_domain" - - if _contains "$response" "0=$_domain"; then - _sub_domain="$(echo "$fulldomain" | sed "s/\\.$_domain\$//")" - return 0 - else - _debug "Go to next level of $_domain" + if _dnsexit_rest "{\"domain\":\"$_domain\",\"$_op\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"$_extra}}"; then + if _contains "$response" "\"code\":0" || _contains "$response" "\"code\": 0"; then + _debug2 _response "$response" + return 0 + fi + _debug "Zone $_domain was not accepted, trying the next level" "$response" fi i=$(_math "$i" + 1) done - - return 1 } _dnsexit_rest() { @@ -136,27 +109,7 @@ _dnsexit_rest() { return 0 } -_dnsexit_http() { - m=GET - param="$1" - _debug param "$param" - _debug get "$DNSEXIT_HOSTS_URL?$param" - - response="$(_get "$DNSEXIT_HOSTS_URL?$param")" - - _debug response "$response" - - if [ "$?" != "0" ]; then - _err "Error $param" - return 1 - fi - - _debug2 response "$response" - return 0 -} - get_account_info() { - DNSEXIT_API_KEY="${DNSEXIT_API_KEY:-$(_readaccountconf_mutable DNSEXIT_API_KEY)}" if test -z "$DNSEXIT_API_KEY"; then DNSEXIT_API_KEY='' @@ -166,23 +119,5 @@ get_account_info() { _saveaccountconf_mutable DNSEXIT_API_KEY "$DNSEXIT_API_KEY" - DNSEXIT_AUTH_USER="${DNSEXIT_AUTH_USER:-$(_readaccountconf_mutable DNSEXIT_AUTH_USER)}" - if test -z "$DNSEXIT_AUTH_USER"; then - DNSEXIT_AUTH_USER="" - _err 'DNSEXIT_AUTH_USER was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_USER "$DNSEXIT_AUTH_USER" - - DNSEXIT_AUTH_PASS="${DNSEXIT_AUTH_PASS:-$(_readaccountconf_mutable DNSEXIT_AUTH_PASS)}" - if test -z "$DNSEXIT_AUTH_PASS"; then - DNSEXIT_AUTH_PASS="" - _err 'DNSEXIT_AUTH_PASS was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_PASS "$DNSEXIT_AUTH_PASS" - return 0 } diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index 10a3821d..257549b4 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -5,6 +5,7 @@ Site: DNSimple.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple Options: DNSimple_OAUTH_TOKEN OAuth Token + DNSimple_ACCOUNT_ID Account ID. Optional, only needed when the token can access multiple accounts. Issues: github.com/pho3nixf1re/acme.sh/issues ' @@ -17,6 +18,7 @@ dns_dnsimple_add() { fulldomain=$1 txtvalue=$2 + DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" if [ -z "$DNSimple_OAUTH_TOKEN" ]; then DNSimple_OAUTH_TOKEN="" _err "You have not set the dnsimple oauth token yet." @@ -25,10 +27,10 @@ dns_dnsimple_add() { fi # save the oauth token for later - _saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" + _saveaccountconf_mutable DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -56,8 +58,14 @@ dns_dnsimple_add() { dns_dnsimple_rm() { fulldomain=$1 + DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" + if [ -z "$DNSimple_OAUTH_TOKEN" ]; then + _err "You have not set the dnsimple oauth token yet." + return 1 + fi + if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -122,13 +130,16 @@ _get_root() { # returns _account_id _get_account_id() { - _debug "retrive account id" - if ! _dnsimple_rest GET "whoami"; then - return 1 + DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf_mutable DNSimple_ACCOUNT_ID)}" + if [ "$DNSimple_ACCOUNT_ID" ]; then + _saveaccountconf_mutable DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" + _account_id="$DNSimple_ACCOUNT_ID" + _debug _account_id "$_account_id" + return 0 fi - if _contains "$response" "\"account\":null"; then - _err "no account associated with this token" + _debug "retrieve account id" + if ! _dnsimple_rest GET "whoami"; then return 1 fi @@ -137,7 +148,25 @@ _get_account_id() { return 1 fi + if _contains "$response" "\"account\":null"; then + # the whoami of a user token (dnsimple_u_*) carries no account, + # so list the accounts the token can access instead + # https://github.com/acmesh-official/acme.sh/issues/6491 + if ! _dnsimple_rest GET "accounts"; then + return 1 + fi + fi + _account_id=$(printf "%s" "$response" | _egrep_o "\"id\":[^,]*,\"email\":" | cut -d: -f2 | cut -d, -f1) + if [ -z "$_account_id" ]; then + _err "no account associated with this token" + return 1 + fi + if [ "$(echo "$_account_id" | wc -l)" -gt 1 ]; then + _err "The token has access to multiple accounts, please pick one and set it explicitly:" + _err "export DNSimple_ACCOUNT_ID=" + return 1 + fi _debug _account_id "$_account_id" return 0 diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh index 1d1fc311..3ac5c2f1 100644 --- a/dnsapi/dns_dynu.sh +++ b/dnsapi/dns_dynu.sh @@ -23,6 +23,8 @@ dns_dynu_add() { fulldomain=$1 txtvalue=$2 + Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}" + Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}" if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then Dynu_ClientId="" Dynu_Secret="" @@ -32,8 +34,8 @@ dns_dynu_add() { fi #save the client id and secret to the account conf file. - _saveaccountconf Dynu_ClientId "$Dynu_ClientId" - _saveaccountconf Dynu_Secret "$Dynu_Secret" + _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId" + _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret" if [ -z "$Dynu_Token" ]; then _info "Getting Dynu token." @@ -69,6 +71,8 @@ dns_dynu_rm() { fulldomain=$1 txtvalue=$2 + Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}" + Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}" if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then Dynu_ClientId="" Dynu_Secret="" @@ -78,8 +82,8 @@ dns_dynu_rm() { fi #save the client id and secret to the account conf file. - _saveaccountconf Dynu_ClientId "$Dynu_ClientId" - _saveaccountconf Dynu_Secret "$Dynu_Secret" + _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId" + _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret" if [ -z "$Dynu_Token" ]; then _info "Getting Dynu token." @@ -214,11 +218,11 @@ _dynu_authentication() { response="$(_get "$Dynu_EndPoint/oauth2/token")" if [ "$?" != "0" ]; then - _err "Authentication failed." + _err "Authentication failed: no response from $Dynu_EndPoint/oauth2/token" return 1 fi if _contains "$response" "Authentication Exception"; then - _err "Authentication failed." + _err "Authentication failed. Server response: $response" return 1 fi if _contains "$response" "access_token"; then diff --git a/dnsapi/dns_edgedns.sh b/dnsapi/dns_edgedns.sh index e88a1483..9ff1cc06 100755 --- a/dnsapi/dns_edgedns.sh +++ b/dnsapi/dns_edgedns.sh @@ -363,17 +363,12 @@ _edgedns_rest() { _edgedns_eg_timestamp() { _debug "Generating signature Timestamp" - _debug3 "Retriving ntp time" - _timeheaders="$(_get "https://www.ntp.org" "onlyheader")" - _debug3 "_timeheaders" "$_timeheaders" - _ntpdate="$(echo "$_timeheaders" | grep -i "Date:" | _head_n 1 | cut -d ':' -f 2- | tr -d "\r\n")" - _debug3 "_ntpdate" "$_ntpdate" - _ntpdate="$(echo "${_ntpdate}" | sed -e 's/^[[:space:]]*//')" - _debug3 "_NTPDATE" "$_ntpdate" - _ntptime="$(echo "${_ntpdate}" | _head_n 1 | cut -d " " -f 5 | tr -d "\r\n")" - _debug3 "_ntptime" "$_ntptime" - _eg_timestamp=$(date -u "+%Y%m%dT") - _eg_timestamp="$(printf "%s%s+0000" "$_eg_timestamp" "$_ntptime")" + #Akamai accepts a clock skew of +/-30s, so use the system clock directly. + #The previous code fetched the Date header from www.ntp.org, which is not + #a reliable time source (it served a wrong time for hours, issue 3973), + #cost an extra https round-trip for every API request, and combined the + #remote time of day with the LOCAL date, breaking around UTC midnight. + _eg_timestamp="$(date -u "+%Y%m%dT%H:%M:%S+0000")" _debug "_eg_timestamp" "$_eg_timestamp" } diff --git a/dnsapi/dns_eurodns.sh b/dnsapi/dns_eurodns.sh new file mode 100644 index 00000000..0fac4cb5 --- /dev/null +++ b/dnsapi/dns_eurodns.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_eurodns_info='EuroDNS +Site: eurodns.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_eurodns +Options: + EURODNS_APP_ID Application ID + EURODNS_API_KEY API Key + EURODNS_TTL TTL. Default: "600". +Issues: github.com/acmesh-official/acme.sh/issues +Author: Nicolas Santorelli +' + +# +# EuroDNS DNS API +# +# EuroDNS API documentation: +# https://docapi.eurodns.com +# +# Usage: +# export EURODNS_APP_ID="your-app-id" +# export EURODNS_API_KEY="your-api-key" +# acme.sh --issue --dns dns_eurodns -d example.com -d *.example.com +# +# The credentials will be saved in ~/.acme.sh/account.conf +# +# Optional: +# export EURODNS_API_URL="https://rest-api.eurodns.com" # Default API URL +# export EURODNS_TTL=600 # Default TTL (minimum 600 for EuroDNS) +# + +EURODNS_API_DEFAULT="https://rest-api.eurodns.com" +EURODNS_TTL_DEFAULT=600 + +######## Public functions ##################### + +#Usage: dns_eurodns_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_eurodns_add() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue=$2 + + _info "Using EuroDNS DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + EURODNS_APP_ID="${EURODNS_APP_ID:-$(_readaccountconf_mutable EURODNS_APP_ID)}" + EURODNS_API_KEY="${EURODNS_API_KEY:-$(_readaccountconf_mutable EURODNS_API_KEY)}" + EURODNS_API_URL="${EURODNS_API_URL:-$(_readaccountconf_mutable EURODNS_API_URL)}" + EURODNS_API_URL="${EURODNS_API_URL:-$EURODNS_API_DEFAULT}" + EURODNS_TTL="${EURODNS_TTL:-$(_readaccountconf_mutable EURODNS_TTL)}" + EURODNS_TTL="${EURODNS_TTL:-$EURODNS_TTL_DEFAULT}" + + if [ -z "$EURODNS_APP_ID" ] || [ -z "$EURODNS_API_KEY" ]; then + EURODNS_APP_ID="" + EURODNS_API_KEY="" + _err "You didn't specify EuroDNS App ID and API Key." + _err "Please export EURODNS_APP_ID and EURODNS_API_KEY and try again." + return 1 + fi + + _saveaccountconf_mutable EURODNS_APP_ID "$EURODNS_APP_ID" + _saveaccountconf_mutable EURODNS_API_KEY "$EURODNS_API_KEY" + if [ "$EURODNS_API_URL" != "$EURODNS_API_DEFAULT" ]; then + _saveaccountconf_mutable EURODNS_API_URL "$EURODNS_API_URL" + fi + if [ "$EURODNS_TTL" != "$EURODNS_TTL_DEFAULT" ]; then + _saveaccountconf_mutable EURODNS_TTL "$EURODNS_TTL" + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + _info "Adding TXT record" + if _eurodns_add_txt_record "$_domain" "$_sub_domain" "$txtvalue"; then + _info "Added TXT record successfully." + return 0 + else + _err "Failed to add TXT record." + return 1 + fi +} + +#Usage: fulldomain txtvalue +dns_eurodns_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue=$2 + + _info "Using EuroDNS DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + EURODNS_APP_ID="${EURODNS_APP_ID:-$(_readaccountconf_mutable EURODNS_APP_ID)}" + EURODNS_API_KEY="${EURODNS_API_KEY:-$(_readaccountconf_mutable EURODNS_API_KEY)}" + EURODNS_API_URL="${EURODNS_API_URL:-$(_readaccountconf_mutable EURODNS_API_URL)}" + EURODNS_API_URL="${EURODNS_API_URL:-$EURODNS_API_DEFAULT}" + + if [ -z "$EURODNS_APP_ID" ] || [ -z "$EURODNS_API_KEY" ]; then + EURODNS_APP_ID="" + EURODNS_API_KEY="" + _err "You didn't specify EuroDNS App ID and API Key." + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + _info "Removing TXT record" + if _eurodns_rm_txt_record "$_domain" "$_sub_domain" "$txtvalue"; then + _info "Removed TXT record successfully." + return 0 + else + _err "Failed to remove TXT record." + return 1 + fi +} + +#################### Private functions below ################################## + +# _sub_domain=_acme-challenge.www +# _domain=domain.com +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + return 1 + fi + + _eurodns_rest GET "dns-zones/$h" + if [ "$?" != "0" ]; then + if [ "$_code" = "404" ]; then + _debug "Zone $h not found, continuing..." + else + _err "API error looking up zone $h" + return 1 + fi + p=$i + i=$(_math "$i" + 1) + continue + fi + + if _contains "$response" '"name"'; then + if [ "$i" = "1" ]; then + _sub_domain="@" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + fi + _domain=$h + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done + + return 1 +} + +_eurodns_add_txt_record() { + domain=$1 + subdomain=$2 + txtvalue=$3 + + data='[{"type":"TXT","host":"'"$subdomain"'","rdata":"'"$txtvalue"'","ttl":'"$EURODNS_TTL"'}]' + + _debug "Adding TXT record via API" + if _eurodns_rest POST "dns-zones/$domain/dns-records" "$data"; then + if _contains "$response" "$txtvalue"; then + return 0 + fi + fi + _err "Failed to add TXT record" + return 1 +} + +_eurodns_rm_txt_record() { + domain=$1 + subdomain=$2 + txtvalue=$3 + + _debug "Getting current zone data for $domain" + + if ! _eurodns_rest GET "dns-zones/$domain"; then + _err "Failed to get zone data" + return 1 + fi + + zone_data=$(echo "$response" | _normalizeJson) + _debug2 zone_data "$zone_data" + + # Find the record ID matching our TXT record + record_id=$(echo "$zone_data" | tr '{' '\n' | grep -F '"TXT"' | grep -F "\"$subdomain\"" | grep -F "\"$txtvalue\"" | _egrep_o '"id" *: *[0-9]+' | cut -d : -f 2 | _head_n 1) + _debug record_id "$record_id" + + if [ -z "$record_id" ]; then + _info "TXT record not found or already removed" + return 0 + fi + + _debug "Deleting TXT record $record_id" + if ! _eurodns_rest DELETE "dns-zones/$domain/dns-records/$record_id"; then + _err "Failed to delete TXT record" + return 1 + fi + + return 0 +} + +# Usage: _eurodns_rest METHOD ENDPOINT [DATA] +_eurodns_rest() { + method=$1 + endpoint=$2 + data="$3" + + export _H1="X-APP-ID: $EURODNS_APP_ID" + export _H2="X-API-KEY: $EURODNS_API_KEY" + export _H3="Content-Type: application/json" + + url="$EURODNS_API_URL/$endpoint" + + _debug2 url "$url" + _debug2 method "$method" + _debug2 data "$data" + + : >"$HTTP_HEADER" + + if [ "$method" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "$method")" + fi + + _ret="$?" + unset _H1 _H2 _H3 + _debug2 response "$response" + + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug2 _code "$_code" + + if [ "$_ret" != "0" ]; then + _err "Error calling API: $endpoint" + return 1 + fi + + if [ "$_code" != "200" ] && [ "$_code" != "201" ] && [ "$_code" != "204" ]; then + if [ "$_code" != "404" ]; then + _err "API error (HTTP $_code): $response" + fi + return 1 + fi + + return 0 +} diff --git a/dnsapi/dns_firestorm.sh b/dnsapi/dns_firestorm.sh new file mode 100644 index 00000000..808c2b89 --- /dev/null +++ b/dnsapi/dns_firestorm.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_firestorm_info='Firestorm.ch +Site: firestorm.ch +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_firestorm +Options: + FST_Key Customer ID + FST_Secret API Secret + FST_Url API URL. Optional. Default "https://api.firestorm.ch/acme-dns". +Issues: github.com/acmesh-official/acme.sh/issues/6839 +Author: FireStorm GmbH +' + +FST_Url_DEFAULT="https://api.firestorm.ch/acme-dns" + +######## Public functions ##################### + +# Usage: dns_firestorm_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_firestorm_add() { + fulldomain=$1 + txtvalue=$2 + + FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}" + FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}" + FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}" + + if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then + _err "FST_Key and FST_Secret must be set" + _err "Get your API credentials at https://admin.firestorm.ch" + return 1 + fi + + FST_Url="${FST_Url:-$FST_Url_DEFAULT}" + + _saveaccountconf_mutable FST_Key "$FST_Key" + _saveaccountconf_mutable FST_Secret "$FST_Secret" + if [ "$FST_Url" != "$FST_Url_DEFAULT" ]; then + _saveaccountconf_mutable FST_Url "$FST_Url" + else + _clearaccountconf_mutable FST_Url + fi + + subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//') + + _info "Adding TXT record for $fulldomain" + _debug "Subdomain" "$subdomain" + _debug "TXT value" "$txtvalue" + + body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}" + + response="$(_firestorm_api "update" "$body")" + + if _contains "$response" "$txtvalue"; then + _info "TXT record added successfully" + return 0 + fi + + _err "Failed to add TXT record: $response" + return 1 +} + +# Usage: dns_firestorm_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_firestorm_rm() { + fulldomain=$1 + txtvalue=$2 + + FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}" + FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}" + FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}" + FST_Url="${FST_Url:-$FST_Url_DEFAULT}" + + if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then + _err "FST_Key and FST_Secret must be set" + return 1 + fi + + subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//') + + _info "Removing TXT record for $fulldomain" + + body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}" + + response="$(_firestorm_api "remove" "$body")" + + if _contains "$response" "removed"; then + _info "TXT record removed" + return 0 + fi + + _err "Failed to remove TXT record: $response" + return 1 +} + +#################### Private functions below ################################## + +# Escape special characters for safe JSON string interpolation +_json_safe() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +_firestorm_api() { + action=$1 + data=$2 + + export _H1="X-Api-User: $FST_Key" + export _H2="X-Api-Key: $FST_Secret" + export _H3="Content-Type: application/json" + + _post "$data" "$FST_Url/$action" "" "POST" +} diff --git a/dnsapi/dns_freedns.sh b/dnsapi/dns_freedns.sh index 13d9f68b..8ea86c24 100755 --- a/dnsapi/dns_freedns.sh +++ b/dnsapi/dns_freedns.sh @@ -305,7 +305,7 @@ _freedns_domain_id() { fi domain_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's//@/g' | tr '@' '\n' | - grep "$search_domain\|$search_domain(.*)" | + grep -E "$search_domain|$search_domain\(.*\)" | sed -n 's/.*\(edit\.php?edit_domain_id=[0-9a-zA-Z]*\).*/\1/p' | cut -d = -f 2)" # The above beauty extracts domain ID from the html page... diff --git a/dnsapi/dns_freemyip.sh b/dnsapi/dns_freemyip.sh index d598a657..18d8e7f9 100644 --- a/dnsapi/dns_freemyip.sh +++ b/dnsapi/dns_freemyip.sh @@ -6,7 +6,7 @@ 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 +Author: Recolic Keghart , @Giova96, ExtremeFiretop ' FREEMYIP_DNS_API="https://freemyip.com/update?" @@ -68,22 +68,30 @@ dns_freemyip_rm() { return $? } -################ Private functions below ################ +################ Private functions below ################ _get_root() { _fmi_d="$1" - echo "$_fmi_d" | rev | cut -d '.' -f 1-3 | rev + echo "$_fmi_d" | sed 's/.*\.\([^.]*\.[^.]*\.[^.]*\)$/\1/' } # 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 + _fmi_i=1 + while [ "$_fmi_i" -le 8 ]; do + _debug "HTTP GET freemyip.com API '$_fmi_url', retry $_fmi_i/8..." + _fmi_response="$(_get "$_fmi_url")" + printf '%s\n' "$_fmi_response" >&2 + + if _contains "$_fmi_response" "OK"; then + return 0 + fi + _sleep 1 # DO NOT send the request too fast + _fmi_i=$((_fmi_i + 1)) done - _err "Failed to request freemyip API: $_fmi_url . Server does not say 'OK'" + _err "Failed to request freemyip API. Server does not say 'OK'" return 1 } @@ -93,13 +101,16 @@ _is_root_domain_published() { _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..." + _fmi_i=1 + while [ "$_fmi_i" -le 3 ]; do + _debug "'$_webroot' ns lookup, retry $_fmi_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 + _fmi_i=$((_fmi_i + 1)) done return 1 } diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index ee66ee19..c92bdfa2 100755 --- a/dnsapi/dns_gd.sh +++ b/dnsapi/dns_gd.sh @@ -69,7 +69,12 @@ dns_gd_add() { return 1 fi - if ! _contains "$response" "$txtvalue"; then + if _contains "$response" "UNKNOWN_DOMAIN"; then + # GoDaddy sometimes returns UNKNOWN_DOMAIN when reading a record back even + # though the PUT above succeeded; skip the local readback check and let + # acme.sh's own DNS propagation check verify the record was published. + _info "GoDaddy API won't allow reading the record back; skipping local verification." + elif ! _contains "$response" "$txtvalue"; then _err "TXT record '${txtvalue}' for '${fulldomain}', value wasn't set!" return 1 fi @@ -145,8 +150,8 @@ dns_gd_rm() { # _domain=domain.com _get_root() { domain=$1 - i=2 - p=1 + i=1 + p=0 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) if [ -z "$h" ]; then @@ -154,17 +159,41 @@ _get_root() { return 1 fi - if ! _gd_rest GET "domains/$h"; then - return 1 + # The record name is whatever precedes the candidate zone. Do not assume + # _acme-challenge here: with DNS alias mode it can be any name, and the + # record may even sit at the zone apex (name "@"). + if [ "$p" = "0" ]; then + _probe_sub="@" + else + _probe_sub=$(printf "%s" "$domain" | cut -d . -f 1-"$p") fi - if _contains "$response" '"code":"NOT_FOUND"'; then - _debug "$h not found" - else - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + # Probe with the records endpoint instead of "GET domains/$h": since + # 2024-05 GoDaddy rejects the domain details call for accounts with + # fewer than 10 domains, while record-level calls keep working. + # https://github.com/acmesh-official/acme.sh/issues/4487 + if ! _gd_rest GET "domains/$h/records/TXT/$_probe_sub"; then + return 1 + fi + if _startswith "$response" '\['; then + _sub_domain="$_probe_sub" _domain="$h" return 0 fi + + # Some accounts get UNKNOWN_DOMAIN when reading records of a valid zone + # even though writes succeed (see issue #6517); fall back to the domain + # details call for them. + if ! _gd_rest GET "domains/$h"; then + return 1 + fi + if _contains "$response" '"domainId"'; then + _sub_domain="$_probe_sub" + _domain="$h" + return 0 + fi + + _debug "$h not found" p="$i" i=$(_math "$i" + 1) done diff --git a/dnsapi/dns_glesys.sh b/dnsapi/dns_glesys.sh new file mode 100644 index 00000000..008abd12 --- /dev/null +++ b/dnsapi/dns_glesys.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_glesys_info='Glesys +Site: Glesys.se +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_glesys +Options: + GLESYS_API_KEY Generated API key. + GLESYS_PROJECT_ID Project ID for the API key (e.g. cl12345). + GLESYS_API API endpoint. Default "https://api.glesys.com/domain". + GLESYS_TTL TXT record TTL. Default 120. +Issues: https://github.com/acmesh-official/acme.sh/issues/7057 +Author: Toni Karppi +' + +GLESYS_API_DEFAULT="https://api.glesys.com/domain" +GLESYS_TTL_DEFAULT="120" + +######## Public functions ##################################################### + +# Usage: +# dns_glesys_add _acme-challenge.www.example.com "txt-value" +dns_glesys_add() { + fulldomain="$1" + txtvalue="$2" + + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + _glesys_init || return 1 + + if ! _glesys_get_root "$fulldomain"; then + _err "Could not find root zone for $fulldomain" + return 1 + fi + + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + host_value="${_sub_domain:-@}" + _debug _host_value "$host_value" + + data="{\"domainname\":\"$_domain\",\"host\":\"$host_value\",\"type\":\"TXT\",\"data\":\"$txtvalue\",\"ttl\":\"$GLESYS_TTL\"}" + + _debug2 data "$data" + + if ! _glesys_rest POST "/addrecord" "$data"; then + _err "Failed to send HTTP request to add TXT record" + return 1 + fi + + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" != "200" ]; then + _err "GleSYS API responded with an unexpected status when attempting to add TXT record" + _debug2 "API response" "$response" + return 1 + fi + + _info "TXT record added" + + return 0 +} + +# Usage: +# dns_glesys_rm _acme-challenge.www.example.com "txt-value" +dns_glesys_rm() { + fulldomain="$1" + txtvalue="$2" + + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + _glesys_init || return 1 + + if ! _glesys_get_root "$fulldomain"; then + _err "Could not find root zone for $fulldomain" + return 1 + fi + + if ! _glesys_find_record_id "$txtvalue"; then + _info "TXT record not present, skip removal" + return 0 + fi + + _debug _record_id "$_record_id" + + if ! _glesys_rest POST "/deleterecord" "{\"recordid\":$_record_id}"; then + _err "Failed to send HTTP request to remove TXT record" + return 1 + fi + + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" != "200" ]; then + _err "GleSYS API responded with unexpected status when attempting to remove TXT record" + _debug2 "API response" "$response" + return 1 + fi + + _info "TXT record removed" + + return 0 +} + +######## Private functions #################################################### + +_glesys_find_record_id() { + txtvalue="$1" + + _debug txtvalue "$txtvalue" + + if [ -z "$txtvalue" ]; then + return 1 + fi + + _record_id="" + + _debug "Looking for TXT record with value" "$txtvalue" + + if ! _glesys_rest GET "/listrecords?domainname=$_domain"; then + _err "Failed to list DNS records" + return 1 + fi + + records="$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + sed 's/},{/}\ +{/g' + )" + + _debug2 records "$records" + + expected_data="\"data\":\"$txtvalue\"" + + _record_id="$( + printf "%s\n" "$records" | + while IFS= read -r record; do + printf "%s" "$record" | grep -q '"type":"TXT"' || continue + printf "%s" "$record" | grep -Fq "$expected_data" || continue + + printf "%s" "$record" | + grep -E -o '"recordid":"?[0-9]+' | + grep -E -o '[0-9]+$' + + break + done + )" + + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + return 1 + fi + + return 0 +} + +# Finds: +# _domain example.com +# _sub_domain _acme-challenge.www +_glesys_get_root() { + domain="$1" + i=1 + + while true; do + h="$(printf "%s" "$domain" | cut -d . -f "$i"-100)" + + if [ -z "$h" ]; then + return 1 + fi + + if _glesys_rest GET "/listrecords?domainname=$h"; then + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" = "200" ]; then + cut_len="$((${#domain} - ${#h} - 1))" + _domain="$h" + _sub_domain="$(printf "%s" "$domain" | cut -c "1-$cut_len")" + return 0 + fi + fi + + i="$((i + 1))" + done +} + +_glesys_init() { + [ -z "$GLESYS_API" ] && GLESYS_API="$GLESYS_API_DEFAULT" + [ -z "$GLESYS_TTL" ] && GLESYS_TTL="$GLESYS_TTL_DEFAULT" + + _debug GLESYS_API "$GLESYS_API" + _debug GLESYS_TTL "$GLESYS_TTL" + + GLESYS_API_KEY="${GLESYS_API_KEY:-$(_readaccountconf_mutable GLESYS_API_KEY)}" + GLESYS_PROJECT_ID="${GLESYS_PROJECT_ID:-$(_readaccountconf_mutable GLESYS_PROJECT_ID)}" + + if [ -z "$GLESYS_API_KEY" ] || [ -z "$GLESYS_PROJECT_ID" ]; then + _err "GLESYS_API_KEY and GLESYS_PROJECT_ID must be set for this provider" + return 1 + fi + + _secure_debug GLESYS_API_KEY "$GLESYS_API_KEY" + _secure_debug GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID" + + _glesys_basic_auth="$(printf "%s:%s" "$GLESYS_PROJECT_ID" "$GLESYS_API_KEY" | _base64)" + _secure_debug2 _glesys_basic_auth "$_glesys_basic_auth" + + _saveaccountconf_mutable GLESYS_API_KEY "$GLESYS_API_KEY" + _saveaccountconf_mutable GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID" + + return 0 +} + +_glesys_rest() { + method="$1" + path="$2" + data="$3" + + export _H1="Authorization: Basic $_glesys_basic_auth" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + url="$GLESYS_API$path" + _debug "$method $url" + + if [ "$method" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "$method")" + fi + + ret="$?" + _debug2 response "$response" + _debug ret "$ret" + + if [ "$ret" != "0" ]; then + return 1 + fi + + return 0 +} diff --git a/dnsapi/dns_hestiacp.sh b/dnsapi/dns_hestiacp.sh new file mode 100644 index 00000000..13ee6caf --- /dev/null +++ b/dnsapi/dns_hestiacp.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_hestiacp_info='HestiaCP Server API +Site: hestiacp.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hestiacp +Options: + HESTIA_HOST Panel URL. E.g. "https://panel.example.com:8083" + HESTIA_ACCESS API access key + HESTIA_SECRET API secret key + HESTIA_USER Username owning the DNS zones. Default "admin". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/6251 +Author: Radu Malica +' + +######## Public functions ##################### + +# Usage: dns_hestiacp_add fulldomain txtvalue +dns_hestiacp_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _hestia_init; then + return 1 + fi + + _debug "Detecting the root zone for $fulldomain" + if ! _hestia_get_root "$fulldomain"; then + _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER" + return 1 + fi + _debug _hestia_domain "$_hestia_domain" + _debug _hestia_sub "$_hestia_sub" + + # _hestia_get_root left the zone record listing in _hestia_response + if _hestia_find_records "$_hestia_sub" "TXT" | grep -F -- "$txtvalue" >/dev/null; then + _info "The TXT record already exists, skipping" + return 0 + fi + + _info "Adding TXT record for $fulldomain" + if ! _hestia_rest "v-add-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_sub" "TXT" "$txtvalue" "" "" "yes" "600"; then + _err "Error adding TXT record: $_hestia_response" + return 1 + fi + _info "TXT record added successfully" + return 0 +} + +# Usage: dns_hestiacp_rm fulldomain txtvalue +dns_hestiacp_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _hestia_init; then + return 1 + fi + + _debug "Detecting the root zone for $fulldomain" + if ! _hestia_get_root "$fulldomain"; then + _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER" + return 1 + fi + _debug _hestia_domain "$_hestia_domain" + _debug _hestia_sub "$_hestia_sub" + + _hestia_removed=0 + _hestia_failed=0 + while IFS='|' read -r _hestia_id _hestia_value || [ -n "$_hestia_id" ]; do + if [ -z "$_hestia_id" ]; then + continue + fi + if ! _contains "$_hestia_value" "$txtvalue"; then + continue + fi + _info "Deleting TXT record $_hestia_id" + if ! _hestia_rest "v-delete-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_id" "yes"; then + _err "Error deleting TXT record $_hestia_id: $_hestia_response" + _hestia_failed=$(_math "$_hestia_failed" + 1) + continue + fi + _hestia_removed=$(_math "$_hestia_removed" + 1) + done </dev/null + else + _info "Don't need to remove." + fi + +} + +#################### Private functions below ################################## +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + #not valid + return 1 + fi + + _hostinger_rest GET "$h" + if _contains "$response" "records"; then + if [ "$response" = "[]" ]; then + _debug "Valid subdomains are not the root" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + fi + + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +_hostinger_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + token_trimmed=$(echo "$HOSTINGER_Token" | tr -d '"') + + export _H1="Content-Type: application/json" + export _H2="Authorization: Bearer $token_trimmed" + + if [ "$m" != "GET" ]; then + _debug data "$data" + response="$(_post "$data" "$HOSTINGER_Api/$ep" "" "$m")" + else + response="$(_get "$HOSTINGER_Api/$ep")" + fi + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh index b3211069..a3d9174a 100644 --- a/dnsapi/dns_hostup.sh +++ b/dnsapi/dns_hostup.sh @@ -6,13 +6,13 @@ 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_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api/v2). HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds). - HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection). + HOSTUP_ZONE_ID Optional. Force a specific v2 zone ID (zone_...) and skip auto-detection. Author: HostUp (https://cloud.hostup.se/contact/en) ' -HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api" +HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api/v2" HOSTUP_DEFAULT_TTL=60 # Public: add TXT record @@ -20,6 +20,7 @@ HOSTUP_DEFAULT_TTL=60 dns_hostup_add() { fulldomain="$1" txtvalue="$2" + hostup_add_txtvalue="$2" _info "Using HostUp DNS API" @@ -34,31 +35,34 @@ dns_hostup_add() { record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")" record_name="$(_hostup_sanitize_name "$record_name")" - record_value="$(_hostup_json_escape "$txtvalue")" + hostup_add_record_value="$(_hostup_json_escape "$hostup_add_txtvalue")" - ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}" + raw_ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}" + ttl="$(_hostup_normalize_ttl "$raw_ttl")" + if [ -z "$ttl" ]; then + _err "HOSTUP_TTL must be a whole number between 60 and 86400 seconds." + return 1 + fi + if [ -n "$HOSTUP_TTL" ]; then + HOSTUP_TTL="$ttl" + _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL" + fi _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 + record_name_fqdn="$(_hostup_fqdn "$fulldomain")" + if _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$hostup_add_txtvalue"; then + _info "TXT record already exists for $fulldomain" + return 0 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 + request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$hostup_add_record_value\",\"ttl\":$ttl}" - 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" + if ! _hostup_rest "POST" "/dns-zones/$HOSTUP_ZONE_ID/records" "$request_body"; then + return 1 fi _info "Added TXT record for $fulldomain" @@ -85,20 +89,9 @@ dns_hostup_rm() { 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" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value" return 0 fi @@ -109,7 +102,7 @@ dns_hostup_rm() { fi _info "Deleted TXT record $HOSTUP_RECORD_ID" - _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value" HOSTUP_ZONE_ID="" return 0 } @@ -127,21 +120,18 @@ _hostup_init() { if [ -z "$HOSTUP_API_BASE" ]; then HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT" fi + HOSTUP_API_BASE="$(_hostup_normalize_api_base "$HOSTUP_API_BASE")" 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." + _err "Please export your HostUp API key with read:dns, write:dns, and read:domains 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 @@ -149,11 +139,80 @@ _hostup_init() { return 0 } +_hostup_normalize_api_base() { + api_base="${1%/}" + + case "$api_base" in + */api/v2) + printf "%s" "$api_base" + ;; + */api) + printf "%s/v2" "$api_base" + ;; + *) + printf "%s" "$api_base" + ;; + esac +} + +_hostup_normalize_ttl() { + ttl_value="$1" + + case "$ttl_value" in + "" | *[!0-9]*) + return 1 + ;; + esac + + while [ "${ttl_value#0}" != "$ttl_value" ]; do + ttl_value="${ttl_value#0}" + done + [ -z "$ttl_value" ] && ttl_value=0 + + case "$ttl_value" in + ??????*) + return 1 + ;; + esac + + if [ "$ttl_value" -lt 60 ] || [ "$ttl_value" -gt 86400 ]; then + return 1 + fi + + printf "%s" "$ttl_value" +} + +_hostup_domain_in_zone() { + host="$(printf "%s" "${1%.}" | _lower_case)" + zone="$(printf "%s" "${2%.}" | _lower_case)" + + if [ -z "$host" ] || [ -z "$zone" ]; then + return 1 + fi + + if [ "$host" = "$zone" ]; then + return 0 + fi + + case "$host" in + *."$zone") + return 0 + ;; + esac + + return 1 +} + _hostup_detect_zone() { fulldomain="$1" if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then - return 0 + if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then + return 0 + fi + _debug "hostup_cached_zone_mismatch" "$HOSTUP_ZONE_DOMAIN" + HOSTUP_ZONE_ID="" + HOSTUP_ZONE_DOMAIN="" fi HOSTUP_ZONE_DOMAIN="" @@ -162,16 +221,16 @@ _hostup_detect_zone() { 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 + if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then + return 0 + fi + _debug "hostup_forced_zone_mismatch" "$HOSTUP_ZONE_DOMAIN" fi HOSTUP_ZONE_ID="" + HOSTUP_ZONE_DOMAIN="" fi - if ! _hostup_load_zones; then - return 1 - fi - - _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)" + _domain_candidate="$(printf "%s" "${fulldomain%.}" | _lower_case)" _debug "hostup_initial_candidate" "$_domain_candidate" while [ -n "$_domain_candidate" ]; do @@ -240,11 +299,11 @@ _hostup_fqdn() { _hostup_fetch_zone_details() { zone_id="$1" - if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then + 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 '"')" + zonedomain="$(_hostup_json_extract "name" "$_hostup_response")" if [ -n "$zonedomain" ]; then HOSTUP_ZONE_DOMAIN="$zonedomain" return 0 @@ -254,7 +313,7 @@ _hostup_fetch_zone_details() { } _hostup_load_zones() { - if ! _hostup_rest "GET" "/dns/zones" ""; then + if ! _hostup_rest "GET" "/dns-zones?limit=1000" ""; then return 1 fi @@ -263,9 +322,9 @@ _hostup_load_zones() { 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")" + *'"id"'*'"name"'*) + zone_id="$(_hostup_json_extract "id" "$line")" + zone_domain="$(_hostup_json_extract "name" "$line")" if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id} " @@ -290,9 +349,30 @@ _hostup_lookup_zone() { _lookup_zone_id="" _lookup_zone_domain="" + encoded_domain="$(printf "%s" "$lookup_domain" | _url_encode)" + if _hostup_rest "GET" "/dns-zones?name=$encoded_domain&limit=1" ""; then + zone_id="$(_hostup_json_extract "id" "$_hostup_response")" + zone_domain="$(_hostup_json_extract "name" "$_hostup_response")" + if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then + zone_domain_lower="$(printf "%s" "$zone_domain" | _lower_case)" + if [ "$zone_domain_lower" = "$lookup_domain" ]; then + _lookup_zone_domain="$zone_domain" + _lookup_zone_id="$zone_id" + HOSTUP_ZONE_DOMAIN="$zone_domain" + HOSTUP_ZONE_ID="$zone_id" + return 0 + fi + fi + fi + + if [ -z "$HOSTUP_ZONES_CACHE" ] && ! _hostup_load_zones; then + return 1 + fi + while IFS='|' read -r domain zone_id; do [ -z "$domain" ] && continue - if [ "$domain" = "$lookup_domain" ]; then + domain_lower="$(printf "%s" "$domain" | _lower_case)" + if [ "$domain_lower" = "$lookup_domain" ]; then _lookup_zone_domain="$domain" _lookup_zone_id="$zone_id" HOSTUP_ZONE_DOMAIN="$domain" @@ -307,50 +387,50 @@ EOF } _hostup_find_record() { - zone_id="$1" - fqdn="$2" - txtvalue="$3" + _hostup_find_zone_id="$1" + _hostup_find_fqdn="$2" + _hostup_find_txtvalue="$3" - if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then + _hostup_find_encoded_name="$(printf "%s" "$_hostup_find_fqdn" | _url_encode)" + if ! _hostup_rest "GET" "/dns-zones/$_hostup_find_zone_id/records?type=TXT&name=$_hostup_find_encoded_name" ""; then return 1 fi HOSTUP_RECORD_ID="" - records="$(printf "%s" "$_hostup_response" | tr '{' '\n')" + _hostup_find_records="$(printf "%s" "$_hostup_response" | tr '{' '\n')" - while IFS= read -r line; do + while IFS= read -r _hostup_find_line; do # Normalize line to make TXT value matching reliable - line_clean="$(printf "%s" "$line" | tr -d '\r\n')" - line_value_clean="$(printf "%s" "$line_clean" | sed 's/\\"//g')" + _hostup_find_line_clean="$(printf "%s" "$_hostup_find_line" | tr -d '\r\n')" + _hostup_find_line_value_clean="$(printf "%s" "$_hostup_find_line_clean" | sed 's/\\"//g')" - case "$line_clean" in - *'"type":"TXT"'*'"name"'*'"value"'*) - name_value="$(_hostup_json_extract "name" "$line_clean")" - record_value="$(_hostup_json_extract "value" "$line_value_clean")" + _hostup_find_record_type="$(_hostup_json_extract "type" "$_hostup_find_line_clean")" + [ "$_hostup_find_record_type" != "TXT" ] && continue - _debug "hostup_record_raw" "$record_value" - if [ "${record_value#\"}" != "$record_value" ] && [ "${record_value%\"}" != "$record_value" ]; then - record_value="${record_value#\"}" - record_value="${record_value%\"}" - fi - if [ "${record_value#\'}" != "$record_value" ] && [ "${record_value%\'}" != "$record_value" ]; then - record_value="${record_value#\'}" - record_value="${record_value%\'}" - fi - record_value="$(printf "%s" "$record_value" | tr -d '\r\n')" - _debug "hostup_record_value" "$record_value" + _hostup_find_name_value="$(_hostup_json_extract "name" "$_hostup_find_line_clean")" + _hostup_find_record_value="$(_hostup_json_extract "value" "$_hostup_find_line_value_clean")" - if [ "$name_value" = "$fqdn" ] && [ "$record_value" = "$txtvalue" ]; then - record_id="$(_hostup_json_extract "id" "$line_clean")" - if [ -n "$record_id" ]; then - HOSTUP_RECORD_ID="$record_id" - return 0 - fi + _debug "hostup_record_raw" "$_hostup_find_record_value" + if [ "${_hostup_find_record_value#\"}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\"}" != "$_hostup_find_record_value" ]; then + _hostup_find_record_value="${_hostup_find_record_value#\"}" + _hostup_find_record_value="${_hostup_find_record_value%\"}" + fi + if [ "${_hostup_find_record_value#\'}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\'}" != "$_hostup_find_record_value" ]; then + _hostup_find_record_value="${_hostup_find_record_value#\'}" + _hostup_find_record_value="${_hostup_find_record_value%\'}" + fi + _hostup_find_record_value="$(printf "%s" "$_hostup_find_record_value" | tr -d '\r\n')" + _debug "hostup_record_value" "$_hostup_find_record_value" + + if [ "$_hostup_find_name_value" = "$_hostup_find_fqdn" ] && [ "$_hostup_find_record_value" = "$_hostup_find_txtvalue" ]; then + _hostup_find_record_id="$(_hostup_json_extract "id" "$_hostup_find_line_clean")" + if [ -n "$_hostup_find_record_id" ]; then + HOSTUP_RECORD_ID="$_hostup_find_record_id" + return 0 fi - ;; - esac + fi done <berlight.de" (the 'g' from + # "" plus '.' matching '>'). See issue #5129. + _hregex=$(printf "%s" "$h" | sed 's/\./\\./g') + if _contains "$response" "$_hregex"; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$h" return 0 fi + # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode, + # encode each zone name via _idn() and compare -- no python dependency. + if _contains "$h" "xn--"; then + _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' | + sed 's/<[^>]*>//g' | while IFS= read -r _z; do + if [ "$(_idn "$_z")" = "$h" ]; then + printf "%s" "$_z" + break + fi + done) + if [ -n "$_zone_unicode" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain="$_zone_unicode" + return 0 + fi + fi p=$i i=$(_math "$i" + 1) done diff --git a/dnsapi/dns_ionos.sh b/dnsapi/dns_ionos.sh index 9a464253..00662e82 100755 --- a/dnsapi/dns_ionos.sh +++ b/dnsapi/dns_ionos.sh @@ -16,7 +16,7 @@ IONOS_TXT_TTL=60 # minimum accepted by API IONOS_TXT_PRIO=10 dns_ionos_add() { - fulldomain=$1 + fulldomain="$(echo "$1" | _lower_case)" txtvalue=$2 if ! _ionos_init; then @@ -34,7 +34,7 @@ dns_ionos_add() { } dns_ionos_rm() { - fulldomain=$1 + fulldomain="$(echo "$1" | _lower_case)" txtvalue=$2 if ! _ionos_init; then @@ -146,7 +146,7 @@ _ionos_rest() { if [ "$method" != "GET" ]; then export _H2="Accept: application/json" - export _H3="Content-Type: application/json" + export _H3= _response="$(_post "$data" "$IONOS_API$route" "" "$method" "application/json")" else diff --git a/dnsapi/dns_ipprojects.sh b/dnsapi/dns_ipprojects.sh new file mode 100644 index 00000000..dadd05f0 --- /dev/null +++ b/dnsapi/dns_ipprojects.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_ipprojects_info='IP-Projects DNS +Site: ip-projects.de/ +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_ipprojects +Options: + IPP_Apikey API Key +Issues: github.com/acmesh-official/acme.sh/issues/6958 +Author: Markus Ebner +' + +IPP_Apikey="${IPP_Apikey:-$(_readaccountconf_mutable IPP_Apikey)}" +IPP_API="https://api.ip-projects.de/v1/dns/acme" + +######## Public functions ######## + +dns_ipprojects_add() { + fulldomain="$1" + txtvalue="$2" + + _info "Using IP-Projects DNS API to add record" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _IPP_load_credentials; then + return 1 + fi + + _IPP_api_request "add" "$fulldomain" "$txtvalue" +} + +dns_ipprojects_rm() { + fulldomain="$1" + txtvalue="$2" + + _info "Using IP-Projects DNS API to remove record" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _IPP_load_credentials; then + return 1 + fi + + _IPP_api_request "remove" "$fulldomain" "$txtvalue" +} + +######## Private helpers ######## + +_IPP_load_credentials() { + IPP_Apikey="${IPP_Apikey:-$(_readaccountconf_mutable IPP_Apikey)}" + + if [ -z "$IPP_Apikey" ]; then + _err "You must export IPP_Apikey" + _err "e.g.: export IPP_Apikey=\"your_api_key\"" + return 1 + fi + + _saveaccountconf_mutable IPP_Apikey "$IPP_Apikey" + return 0 +} + +_IPP_api_request() { + action="$1" + domain="$2" + value="$3" + + url="$IPP_API/$action" + + data="{\"domain\":\"$domain\",\"key\":\"$domain\",\"value\":\"$value\"}" + _debug url "$url" + _debug data "$data" + export _H1="X-API-Key: $IPP_Apikey" + + response="$(_post "$data" "$url" "" "POST" "application/json")" + ret="$?" + _ipprojects_last_http_code=$(grep "^HTTP" "${HTTP_HEADER}" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n') + + _debug response "$response" + + if [ "$ret" != "0" ]; then + _err "HTTP request failed" + return 1 + fi + + if [ "$_ipprojects_last_http_code" != "200" ]; then + _err "API returned an error [code: ${_ipprojects_last_http_code}]" + return 1 + fi + + return 0 +} diff --git a/dnsapi/dns_ispconfig.sh b/dnsapi/dns_ispconfig.sh index edc789e1..bd6bfb28 100755 --- a/dnsapi/dns_ispconfig.sh +++ b/dnsapi/dns_ispconfig.sh @@ -136,7 +136,7 @@ _ISPC_getZoneInfo() { curResult="$(_post "${curData}" "${ISPC_Api}?client_get_id")" _debug "Calling _ISPC_ClientGetID: '${curData}' '${ISPC_Api}?client_get_id'" _debug "Result of _ISPC_ClientGetID: '$curResult'" - client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | tr -d '{}') + client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | cut -d '[' -f 1 | tr -d '{}') _debug "Client ID: '${client_id}'" case "${client_id}" in '' | *[!0-9]*) diff --git a/dnsapi/dns_joker.sh b/dnsapi/dns_joker.sh index 401471be..0ad80327 100644 --- a/dnsapi/dns_joker.sh +++ b/dnsapi/dns_joker.sh @@ -35,9 +35,28 @@ dns_joker_add() { return 1 fi + # Joker's /nic/replace overwrites all TXT records at the label on every call, + # and the API is not readable, so accumulate the values locally (keyed by the + # full record name) and re-send the whole set each time. This is required so a + # wildcard cert (base + *.domain both validating under the same + # _acme-challenge label) does not overwrite its own first challenge value. + _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_') + _joker_values=$(_readdomainconf "$_joker_conf_key") + if [ -z "$_joker_values" ]; then + _joker_values="$txtvalue" + elif ! _contains " $_joker_values " " $txtvalue "; then + _joker_values="$_joker_values $txtvalue" + fi + + _joker_value_params="" + for _joker_v in $_joker_values; do + _joker_value_params="$_joker_value_params&value=$_joker_v" + done + _info "Adding TXT record" - if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value=$txtvalue"; then + if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then if _startswith "$response" "OK"; then + _savedomainconf "$_joker_conf_key" "$_joker_values" _info "Added, OK" return 0 fi @@ -59,10 +78,36 @@ dns_joker_rm() { return 1 fi + # Remove only this value from the accumulated set and replace the label with + # whatever remains (an empty value clears the label's TXT records entirely). + _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_') + _joker_values=$(_readdomainconf "$_joker_conf_key") + _joker_remaining="" + for _joker_v in $_joker_values; do + if [ "$_joker_v" != "$txtvalue" ]; then + _joker_remaining="$_joker_remaining $_joker_v" + fi + done + _joker_remaining=$(printf "%s" "$_joker_remaining" | sed 's/^ *//') + + _joker_value_params="" + for _joker_v in $_joker_remaining; do + _joker_value_params="$_joker_value_params&value=$_joker_v" + done + if [ -z "$_joker_value_params" ]; then + _joker_value_params="&value=" + fi + _info "Removing TXT record" - # TXT record is removed by setting its value to empty. - if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value="; then + # TXT record is removed by replacing the label with the remaining values + # (or an empty value, which clears all TXT records at the label). + if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then if _startswith "$response" "OK"; then + if [ -z "$_joker_remaining" ]; then + _cleardomainconf "$_joker_conf_key" + else + _savedomainconf "$_joker_conf_key" "$_joker_remaining" + fi _info "Removed, OK" return 0 fi diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh index 5636804a..2b6d8ef4 100644 --- a/dnsapi/dns_knot.sh +++ b/dnsapi/dns_knot.sh @@ -5,7 +5,8 @@ Site: www.knot-dns.cz/docs/2.5/html/man_knsupdate.html Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_knot Options: KNOT_SERVER Server hostname. Default: "localhost". - KNOT_KEY File path to TSIG key + KNOT_KEY TSIG key data, not a file path. knsupdate "key" statement format: "[alg:]name secret". E.g. "hmac-sha256:acme_key BASE64SECRET=" + KNOT_ZONE Zone name. Optional, set it when the challenge record lives in a delegated subdomain zone. Default: the parent domain of the challenge record. ' # See also dns_nsupdate.sh @@ -21,6 +22,9 @@ dns_knot_add() { # save the dns server and key to the account.conf file. _saveaccountconf KNOT_SERVER "${KNOT_SERVER}" _saveaccountconf KNOT_KEY "${KNOT_KEY}" + if [ -n "${KNOT_ZONE}" ]; then + _saveaccountconf KNOT_ZONE "${KNOT_ZONE}" + fi if ! _get_root "$fulldomain"; then _err "Domain does not exist." @@ -84,6 +88,13 @@ EOF # _domain=domain.com _get_root() { domain=$1 + # a delegated subdomain zone cannot be derived from the record name; + # let the user name the zone explicitly (issue 2881) + if [ -n "${KNOT_ZONE}" ]; then + _domain="${KNOT_ZONE%.}" + _debug "Using KNOT_ZONE zone" "${_domain}" + return 0 + fi i="$(echo "$fulldomain" | tr '.' ' ' | wc -w)" i=$(_math "$i" - 1) diff --git a/dnsapi/dns_laodc.sh b/dnsapi/dns_laodc.sh new file mode 100644 index 00000000..9f2103b3 --- /dev/null +++ b/dnsapi/dns_laodc.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_laodc_info='LaoDC DNS API Server +Site: laodc.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_laodc +Options: + LaoDC_Key API Key +Issues: github.com/acmesh-official/acme.sh/issues/6973 +Author: @laodc +' + +# Usage: +# export LaoDC_Key="your-api-key" +# acme.sh --issue --dns dns_laodc -d example.la -d *.example.la --dnssleep 120 +# +# The credentials will be saved in ~/.acme.sh/account.conf + +LAODC_VER="0.1.2" +LAODC_API_ENDPOINT="https://dns.laodc.com/v1" + +######## Public functions ##################### + +# Usage: dns_laodc_add _acme-challenge.example.la ZPXvna6tBhq7XQMH7_t2WC2sg0F-BdmtmmpUJiK6Ho +dns_laodc_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using LaoDC DNS API" + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Adding acme record" + if _laodc_api "POST" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "201" ]; then + _info "Added, OK" + return 0 + else + _err "Add TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Add TXT record error." + return 1 +} + +dns_laodc_rm() { + fulldomain=$1 + txtvalue=$2 + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _root_domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Deleting acme record" + if _laodc_api "DELETE" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "204" ]; then + _info "Deleted, OK" + return 0 + else + _err "Delete TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Delete TXT record error." + return 1 +} + +#################### Private functions below ################################## +# _acme-challenge.www.domain.com +# returns +# _domain=domain.com +# _sub_domain=www +_get_root() { + fqdn=$1 + p=1 + i=1 + + while true; do + h=$(printf "%s" "$fqdn" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 # not valid domain + fi + + # Check API if domain exists + if _laodc_api "GET" "$h"; then + if [ "$_code" = "200" ]; then + _domain="$h" + + # DNS alias mode - @ is alias for fqdn + _sub_domain=$(printf "%s" "$fqdn" | cut -d . -f 1-"$p") + if [ "$i" = "1" ]; then + _sub_domain="@" + fi + + return 0 + fi + fi + + p="$i" + i=$(_math "$i" + 1) + done + + return 1 +} + +_laodc_validate_key() { + LaoDC_Key="${LaoDC_Key:-$(_readaccountconf_mutable LaoDC_Key)}" + + if [ -z "$LaoDC_Key" ]; then + LaoDC_Key="" + _err "You didn't specify a LaoDC API Key yet." + _err "Please export LaoDC_Key and try again." + return 1 + fi + + # Save the api key to the account conf file. + _saveaccountconf_mutable LaoDC_Key "$LaoDC_Key" +} + +_laodc_api() { + method=$1 + domain=$2 + subdomain=$3 + value=$4 + + export _H1="Content-Type: application/json" + export _H2="User-Agent: acme.sh/$VER laodc-dns-acme-sh/$LAODC_VER" + export _H3="Authorization: Bearer $LaoDC_Key" + + case $method in + GET) + if [ -n "$subdomain" ]; then + response="$(_get "$LAODC_API_ENDPOINT/$domain/$subdomain?type=TXT")" + else + response="$(_get "$LAODC_API_ENDPOINT/$domain")" + fi + ;; + POST) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\", \"ttl\": \"60\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "POST" "application/json")" + ;; + DELETE) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "DELETE" "application/json")" + ;; + esac + + _ret=$? + + # Unset immediately after request to prevent leaks + export _H1= + export _H2= + export _H3= + + if [ "$_ret" != "0" ]; then + _err "Error $domain" + return 1 + fi + + responseHeaders="$(cat "$HTTP_HEADER")" + + if echo "$responseHeaders" | grep -i "Content-Type: *application/json" >/dev/null 2>&1; then + response="$(echo "$response" | _json_decode | _normalizeJson)" + fi + + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + + _debug "http response code $_code" + _debug response "$response" + return 0 +} diff --git a/dnsapi/dns_level27.sh b/dnsapi/dns_level27.sh new file mode 100644 index 00000000..3fbaf810 --- /dev/null +++ b/dnsapi/dns_level27.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_level27_info='Level27 +Site: Level27.be +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_level27 +Options: + LEVEL27_API_KEY API key. Get one from the Level27 control panel (https://app.level27.eu/account/profile/security). +OptionsAlt: + LEVEL27_API API base URL. Optional. Default "https://api.level27.eu/v1". +Issues: github.com/acmesh-official/acme.sh/issues +Author: Jeroen Moors +' + +LEVEL27_API_DEFAULT="https://api.level27.eu/v1" + +######## Public functions ##################### + +# Usage: dns_level27_add _acme-challenge.www.example.com "TXT-value" +dns_level27_add() { + fulldomain="$(_idn "$1")" + txtvalue="$2" + + _info "Using Level27 to add a TXT record for $fulldomain" + + if ! _level27_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Could not determine the root zone for $fulldomain at Level27." + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _level27_data="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\"}" + if ! _level27_rest POST "domains/$_domain_id/records" "$_level27_data"; then + _err "Could not add the TXT record." + return 1 + fi + + if _contains "$response" "\"id\":"; then + _info "TXT record added." + return 0 + fi + + _err "Unexpected response while adding the TXT record." + return 1 +} + +# Usage: dns_level27_rm _acme-challenge.www.example.com "TXT-value" +dns_level27_rm() { + fulldomain="$(_idn "$1")" + txtvalue="$2" + + _info "Using Level27 to remove the TXT record for $fulldomain" + + if ! _level27_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Could not determine the root zone for $fulldomain at Level27." + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + if ! _level27_rest GET "domains/$_domain_id/records?type=TXT"; then + _err "Could not list the existing TXT records." + return 1 + fi + + _record_id="$(_level27_find_record_id "$response" "$txtvalue")" + if [ -z "$_record_id" ]; then + _info "No matching TXT record found; nothing to remove." + return 0 + fi + _debug _record_id "$_record_id" + + if ! _level27_rest DELETE "domains/$_domain_id/records/$_record_id"; then + _err "Could not remove the TXT record." + return 1 + fi + + _info "TXT record removed." + return 0 +} + +#################### Private functions below ################################## + +# Reads and validates the API credentials and endpoint, and stores them for renewals. +_level27_init() { + LEVEL27_API_KEY="${LEVEL27_API_KEY:-$(_readaccountconf_mutable LEVEL27_API_KEY)}" + if [ -z "$LEVEL27_API_KEY" ]; then + LEVEL27_API_KEY="" + _err "You must export the variable LEVEL27_API_KEY before using the Level27 DNS API." + _err "Get an API key from the Level27 control panel (https://app.level27.eu/account/profile/security)." + return 1 + fi + LEVEL27_API_KEY="$(echo "$LEVEL27_API_KEY" | tr -d '"')" + _saveaccountconf_mutable LEVEL27_API_KEY "$LEVEL27_API_KEY" + + LEVEL27_API="${LEVEL27_API:-$(_readaccountconf_mutable LEVEL27_API)}" + if [ -z "$LEVEL27_API" ]; then + LEVEL27_API="$LEVEL27_API_DEFAULT" + fi + _saveaccountconf_mutable LEVEL27_API "$LEVEL27_API" + + # Remove a trailing slash so endpoints can be appended consistently. + LEVEL27_API="$(echo "$LEVEL27_API" | sed 's#/$##')" + return 0 +} + +# Usage: _get_root _acme-challenge.www.example.com +# Splits the full domain into the registered zone and the subdomain part. +# Sets: _domain, _domain_id, _sub_domain +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + # not valid + return 1 + fi + + if ! _level27_rest GET "domains?filter=$h"; then + return 1 + fi + + _level27_zones="$(echo "$response" | _normalizeJson)" + if _contains "$_level27_zones" "\"fullname\":\"$h\""; then + _domain_line="$(echo "$_level27_zones" | sed 's/},{/}\n{/g' | grep "\"fullname\":\"$h\"" | _head_n 1)" + _domain_id="$(echo "$_domain_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2)" + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +# Usage: _level27_find_record_id "" "" +# Prints the id of the TXT record whose content matches the value, or nothing. +_level27_find_record_id() { + _records="$(echo "$1" | _normalizeJson | sed 's/},{/}\n{/g')" + _wanted="$2" + _record_line="$(echo "$_records" | grep "\"content\":\"$_wanted\"" | _head_n 1)" + if [ -z "$_record_line" ]; then + # Some APIs store TXT content wrapped in quotes. + _record_line="$(echo "$_records" | grep "\"content\":\"\\\\\"$_wanted\\\\\"\"" | _head_n 1)" + fi + if [ -z "$_record_line" ]; then + return 0 + fi + echo "$_record_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2 +} + +# Usage: _level27_rest [data] +# Performs an authenticated API call and stores the body in $response. +_level27_rest() { + m="$1" + ep="$2" + data="$3" + _debug "$ep" + + export _H1="Authorization: $LEVEL27_API_KEY" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + if [ "$m" != "GET" ]; then + _debug2 data "$data" + response="$(_post "$data" "$LEVEL27_API/$ep" "" "$m")" + else + response="$(_get "$LEVEL27_API/$ep")" + fi + + if [ "$?" != "0" ]; then + _err "Error querying the Level27 API endpoint: $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_linode.sh b/dnsapi/dns_linode.sh deleted file mode 100755 index d74d1fc8..00000000 --- a/dnsapi/dns_linode.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env sh -# shellcheck disable=SC2034 -dns_linode_info='Linode.com (Old) - Deprecated. Use dns_linode_v4 -Site: Linode.com -Options: - LINODE_API_KEY API Key -Author: Philipp Grosswiler -' - -LINODE_API_URL="https://api.linode.com/?api_key=$LINODE_API_KEY&api_action=" - -######## Public functions ##################### - -#Usage: dns_linode_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" -dns_linode_add() { - fulldomain="${1}" - txtvalue="${2}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_add() '${fulldomain}' '${txtvalue}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id&Type=TXT&Name=$_sub_domain&Target=$txtvalue" - - if _rest GET "domain.resource.create" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error adding the domain resource." - return 1 - fi - - _info "Domain resource successfully added." - return 0 - fi - - return 1 -} - -#Usage: dns_linode_rm _acme-challenge.www.domain.com -dns_linode_rm() { - fulldomain="${1}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_rm() '${fulldomain}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id" - - if _rest GET "domain.resource.list" "$_parameters" && [ -n "$response" ]; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - - resource="$(echo "$response" | _egrep_o "{.*\"NAME\":\s*\"$_sub_domain\".*}")" - if [ "$resource" ]; then - _resource_id=$(printf "%s\n" "$resource" | _egrep_o "\"RESOURCEID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_resource_id" ]; then - _debug _resource_id "$_resource_id" - - _parameters="&DomainID=$_domain_id&ResourceID=$_resource_id" - - if _rest GET "domain.resource.delete" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error deleting the domain resource." - return 1 - fi - - _info "Domain resource successfully deleted." - return 0 - fi - fi - - return 1 - fi - - return 0 - fi - - return 1 -} - -#################### Private functions below ################################## - -_Linode_API() { - if [ -z "$LINODE_API_KEY" ]; then - LINODE_API_KEY="" - - _err "You didn't specify the Linode API key yet." - _err "Please create your key and try again." - - return 1 - fi - - _saveaccountconf LINODE_API_KEY "$LINODE_API_KEY" -} - -#################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=12345 -_get_root() { - domain=$1 - i=2 - p=1 - - if _rest GET "domain.list"; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - #not valid - return 1 - fi - - hostedzone="$(echo "$response" | _egrep_o "{.*\"DOMAIN\":\s*\"$h\".*}")" - if [ "$hostedzone" ]; then - _domain_id=$(printf "%s\n" "$hostedzone" | _egrep_o "\"DOMAINID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_domain_id" ]; then - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain=$h - return 0 - fi - return 1 - fi - p=$i - i=$(_math "$i" + 1) - done - fi - return 1 -} - -#method method action data -_rest() { - mtd="$1" - ep="$2" - data="$3" - - _debug mtd "$mtd" - _debug ep "$ep" - - export _H1="Accept: application/json" - export _H2="Content-Type: application/json" - - if [ "$mtd" != "GET" ]; then - # both POST and DELETE. - _debug data "$data" - response="$(_post "$data" "$LINODE_API_URL$ep" "" "$mtd")" - else - response="$(_get "$LINODE_API_URL$ep$data")" - fi - - if [ "$?" != "0" ]; then - _err "error $ep" - return 1 - fi - _debug2 response "$response" - return 0 -} diff --git a/dnsapi/dns_me.sh b/dnsapi/dns_me.sh index 163fe8db..0966c5f1 100644 --- a/dnsapi/dns_me.sh +++ b/dnsapi/dns_me.sh @@ -140,7 +140,7 @@ _me_rest() { data="$3" _debug "$ep" - cdate=$(LANG=C date -u +"%a, %d %b %Y %T %Z") + cdate=$(LC_ALL=C date -u +"%a, %d %b %Y %T %Z") hmac=$(printf "%s" "$cdate" | _hmac sha1 "$(printf "%s" "$ME_Secret" | _hex_dump | tr -d " ")" hex) export _H1="x-dnsme-apiKey: $ME_Key" diff --git a/dnsapi/dns_muumuu.sh b/dnsapi/dns_muumuu.sh new file mode 100755 index 00000000..8ef0b8c8 --- /dev/null +++ b/dnsapi/dns_muumuu.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_muumuu_info='muumuu-domain.com +Site: muumuu-domain.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_muumuu +Options: + MUUMUU_PAT Personal Access Token (scopes: domains:read, dns:read, dns:write) +Issues: github.com/acmesh-official/acme.sh/issues/7011 +' + +MUUMUU_API="https://muumuu-domain.com/api/v2" + +######## Public functions ##################### + +dns_muumuu_add() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + _err "Please create a Personal Access Token at https://muumuu-domain.com" + _err "with scopes: domains:read, dns:read, dns:write" + return 1 + fi + _saveaccountconf_mutable MUUMUU_PAT "$MUUMUU_PAT" + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Adding TXT record for ${fulldomain}" + body="{\"fqdn\":\"${fulldomain}.\",\"type\":\"TXT\",\"value\":\"${txtvalue}\",\"ttl\":3600}" + if _muumuu_rest POST "/me/domains/${_domain_id}/dns-records" "$body"; then + if [ "$_muumuu_code" = "201" ]; then + _info "TXT record added successfully" + return 0 + fi + fi + + _err "Failed to add TXT record (HTTP ${_muumuu_code})" + return 1 +} + +dns_muumuu_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + return 1 + fi + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + + _info "Looking up TXT record for ${fulldomain}" + if ! _muumuu_rest GET "/me/domains/${_domain_id}/dns-records?type=TXT&fqdn=${fulldomain}."; then + _err "Failed to list TXT records" + return 1 + fi + + record_id=$(echo "$response" | _egrep_o "\"id\":[0-9]+[^}]*\"value\":\"${txtvalue}\"" | _egrep_o "\"id\":[0-9]+" | _head_n 1 | cut -d: -f2) + if [ -z "$record_id" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + _debug record_id "$record_id" + + if _muumuu_rest DELETE "/me/domains/${_domain_id}/dns-records/${record_id}"; then + if [ "$_muumuu_code" = "204" ]; then + _info "TXT record deleted successfully" + return 0 + fi + fi + + _err "Failed to delete TXT record (HTTP ${_muumuu_code})" + return 1 +} + +#################### Private functions below ################################## + +# _acme-challenge.www.example.com +# sets: +# _domain_id MU00000001 +# _sub_domain _acme-challenge.www +# _domain example.com +_muumuu_get_root() { + domain="$1" + i=1 + p=0 + h="" + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 + fi + if ! _muumuu_rest GET "/me/domains?fqdn=${h}&page-size=1"; then + return 1 + fi + if [ "$_muumuu_code" = "401" ] || [ "$_muumuu_code" = "403" ]; then + _err "Authentication failed (HTTP ${_muumuu_code}). Check MUUMUU_PAT." + return 1 + fi + if _contains "$response" "\"fqdn\":\"${h}\""; then + _domain_id=$(echo "$response" | _egrep_o "\"id\":\"MU[0-9]+\"" | _head_n 1 | cut -d: -f2 | tr -d '"') + _domain="$h" + if [ "$p" = "0" ]; then + _sub_domain="" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + fi + return 0 + fi + p="$i" + i=$(_math "$i" + 1) + done +} + +_muumuu_rest() { + _muumuu_method="$1" + _muumuu_path="$2" + _muumuu_data="$3" + _muumuu_url="${MUUMUU_API}${_muumuu_path}" + + export _H1="Authorization: Bearer ${MUUMUU_PAT}" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + export _H4="" + export _H5="" + + _secure_debug2 data "$_muumuu_data" + + if [ "$_muumuu_method" = "GET" ]; then + response="$(_get "$_muumuu_url")" + else + response="$(_post "$_muumuu_data" "$_muumuu_url" "" "$_muumuu_method")" + fi + _muumuu_ret="$?" + _muumuu_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug "HTTP code: ${_muumuu_code}" + _secure_debug2 response "$response" + + if [ "$_muumuu_ret" != "0" ]; then + _err "Error accessing ${_muumuu_url}" + return 1 + fi + + response="$(printf "%s" "$response" | _normalizeJson)" + return 0 +} diff --git a/dnsapi/dns_mythic_beasts.sh b/dnsapi/dns_mythic_beasts.sh index 1529e1e7..a49ab8ab 100755 --- a/dnsapi/dns_mythic_beasts.sh +++ b/dnsapi/dns_mythic_beasts.sh @@ -186,7 +186,7 @@ _oauth2() { _oauth2_std() { # HTTP Basic Authentication _H1="Authorization: Basic $(echo "$MB_AK:$MB_AS" | _base64)" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 body="grant_type=client_credentials" @@ -210,7 +210,7 @@ _oauth2_std() { } _oauth2_github() { - _H1="Accepts: application/json" + _H1="Accept: application/json" export _H1 body="{\"login\":{\"handle\":\"$MB_AK\",\"pass\":\"$MB_AS\",\"floating\":1}}" @@ -241,7 +241,7 @@ _mb_rest() { fi _H1="Authorization: Bearer $MB_TK" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 if [ "$data" ] || [ "$m" = "POST" ] || [ "$m" = "PUT" ] || [ "$m" = "DELETE" ]; then # body url [needbase64] [POST|PUT|DELETE] [ContentType] diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh index 5527b357..7035640b 100755 --- a/dnsapi/dns_namecheap.sh +++ b/dnsapi/dns_namecheap.sh @@ -104,6 +104,9 @@ _get_root_by_getList() { return 1 fi + _namecheap_domain_list=$(echo "$response" | _egrep_o ']*') + _debug2 domain_list "$_namecheap_domain_list" + i=2 p=1 @@ -120,7 +123,7 @@ _get_root_by_getList() { return 1 fi - if ! _contains "$response" "$h"; then + if ! _namecheap_is_our_dns "$h"; then _debug "$h not found" else _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") @@ -133,6 +136,29 @@ _get_root_by_getList() { return 1 } +#Usage: _namecheap_is_our_dns +#Succeeds only when domains.getList listed exactly AND that entry is +#served by Namecheap's own DNS. A domain parked on Namecheap's webhosting DNS +#is listed with IsOurDNS="false", and every dns.getHosts/setHosts call against +#it is refused with error 2030288 "not using proper DNS servers". Accepting +#such a domain as the root zone hides a subdomain that IS delegated to +#Namecheap DNS and that the getHosts probe below would have found. +#https://github.com/acmesh-official/acme.sh/issues/7178 +_namecheap_is_our_dns() { + _namecheap_entry=$(echo "$_namecheap_domain_list" | grep -F " Name=\"$1\"" | _head_n 1) + if [ -z "$_namecheap_entry" ]; then + return 1 + fi + + _namecheap_ourdns=$(echo "$_namecheap_entry" | _egrep_o ' IsOurDNS="[^"]*' | cut -d '"' -f 2) + _debug2 "$1 IsOurDNS" "$_namecheap_ourdns" + + if [ "$_namecheap_ourdns" = "true" ]; then + return 0 + fi + return 1 +} + _get_root_by_getHosts() { i=100 p=99 @@ -264,8 +290,16 @@ _set_namecheap_TXT() { _debug hosts "$hosts" if [ -z "$hosts" ]; then - _err "Hosts not found" - return 1 + # An empty host list is only acceptable when the API positively confirms + # a successful getHosts reply: setHosts below REPLACES all records, so + # proceeding on a malformed/unparsed response would wipe the whole zone. + # https://github.com/acmesh-official/acme.sh/issues/6963 + if _contains "$response" "Status=\"OK\"" && _contains "$response" "DomainDNSGetHostsResult"; then + _debug "No existing host records, adding the TXT record as the first one" + else + _err "Hosts not found" + return 1 + fi fi _namecheap_reset_hostList diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 1062c849..bd7da0c2 100755 --- a/dnsapi/dns_namecom.sh +++ b/dnsapi/dns_namecom.sh @@ -15,7 +15,7 @@ Namecom_API="https://api.name.com/v4" #Usage: dns_namecom_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_namecom_add() { - fulldomain=$1 + fulldomain=$(_idn "$1") txtvalue=$2 Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}" @@ -68,7 +68,7 @@ dns_namecom_add() { #Usage: fulldomain txtvalue #Remove the txt record after validation. dns_namecom_rm() { - fulldomain=$1 + fulldomain=$(_idn "$1") txtvalue=$2 Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}" @@ -153,10 +153,9 @@ _namecom_get_root() { i=2 p=1 - if ! _namecom_rest GET "domains"; then - return 1 - fi - + # Probe each candidate with GetDomain (GET /v4/domains/{domainName}) instead + # of listing all domains: the list is paginated at 1000 domains per page, so + # larger accounts never found their domain on the first page. # Need to exclude the last field (tld) numfields=$(echo "$domain" | _egrep_o "\." | wc -l) while [ "$i" -le "$numfields" ]; do @@ -166,7 +165,7 @@ _namecom_get_root() { return 1 fi - if _contains "$response" "$host"; then + if _namecom_rest GET "domains/$host" && _contains "$response" "\"domainName\":\"$host\""; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$host" return 0 diff --git a/dnsapi/dns_namesilo.sh b/dnsapi/dns_namesilo.sh index 5d47a59a..df5871cf 100755 --- a/dnsapi/dns_namesilo.sh +++ b/dnsapi/dns_namesilo.sh @@ -65,7 +65,7 @@ dns_namesilo_rm() { if _namesilo_rest GET "dnsListRecords?version=1&type=xml&key=$Namesilo_Key&domain=$_domain"; then retcode=$(printf "%s\n" "$response" | _egrep_o "300") if [ "$retcode" ]; then - _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$fulldomain" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1) + _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$_sub_domain$txtvalue" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1) _debug _record_id "$_record_id" if [ "$_record_id" ]; then _info "Successfully retrieved the record id for ACME challenge." diff --git a/dnsapi/dns_netcup.sh b/dnsapi/dns_netcup.sh index 8609adf6..3b291854 100644 --- a/dnsapi/dns_netcup.sh +++ b/dnsapi/dns_netcup.sh @@ -33,9 +33,11 @@ dns_netcup_add() { exit=$(echo "$fulldomain" | tr -dc '.' | wc -c) exit=$(_math "$exit" + 1) i=$exit + _nc_last=$(_nc_lastlevel "$i") + _nc_found="" while - [ "$exit" -gt 0 ] + [ "$exit" -ge "$_nc_last" ] do tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") if [ "$(_math "$i" - "$exit")" -eq 0 ]; then @@ -51,12 +53,18 @@ dns_netcup_add() { _err "$msg" return 1 else + _nc_found=1 break fi fi fi exit=$(_math "$exit" - 1) done + if [ -z "$_nc_found" ]; then + _err "$msg" + _nc_nozone "$fulldomain" + return 1 + fi logout } @@ -70,9 +78,11 @@ dns_netcup_rm() { exit=$(_math "$exit" + 1) i=$exit rec="" + _nc_last=$(_nc_lastlevel "$i") + _nc_found="" while - [ "$exit" -gt 0 ] + [ "$exit" -ge "$_nc_last" ] do tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") if [ "$(_math "$i" - "$exit")" -eq 0 ]; then @@ -89,12 +99,18 @@ dns_netcup_rm() { _err "$msg" return 1 else + _nc_found=1 break fi fi fi exit=$(_math "$exit" - 1) done + if [ -z "$_nc_found" ]; then + _err "$msg" + _nc_nozone "$fulldomain" + return 1 + fi ida=0000 idv=0001 @@ -125,6 +141,27 @@ dns_netcup_rm() { logout } +# The zone is looked up by walking the challenge name from the right, one +# label at a time. The leftmost label is the challenge prefix, so the full +# name itself can never be a zone: asking netcup for it only returns 4013 +# "Validation Error", which would then mask the real 5028 "zone could not be +# found". Stop one label short, unless the name is too short to have a +# challenge prefix at all (manual invocation). +# levels +_nc_lastlevel() { + if [ "$1" -ge 3 ]; then + echo 2 + else + echo 1 + fi +} + +# fulldomain +_nc_nozone() { + _err "No DNS zone for $1 was found at netcup." + _err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup." +} + _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) diff --git a/dnsapi/dns_nexdns.sh b/dnsapi/dns_nexdns.sh new file mode 100755 index 00000000..e4447c0e --- /dev/null +++ b/dnsapi/dns_nexdns.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_nexdns_info='NexDNS +Site: nexdns.tech +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_nexdns +Options: + NEXDNS_Token API token. Can be created at https://nexdns.tech/settings/api-keys + NEXDNS_Api API base url. Default "https://api.nexdns.tech/v1". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/7179 +Author: NexDNS +' + +NEXDNS_Api_Default="https://api.nexdns.tech/v1" + +######## Public functions ##################### + +#Usage: dns_nexdns_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_nexdns_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _nexdns_init; then + return 1 + fi + + _saveaccountconf_mutable NEXDNS_Token "$NEXDNS_Token" + if [ "$NEXDNS_Api" != "$NEXDNS_Api_Default" ]; then + _saveaccountconf_mutable NEXDNS_Api "$NEXDNS_Api" + else + _clearaccountconf_mutable NEXDNS_Api + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Cannot find the zone of $fulldomain in this NexDNS account." + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _info "Adding the TXT record for $fulldomain" + if ! _nexdns_rest POST "zones/$_domain_id/records" "{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}"; then + return 1 + fi + + _info "The TXT record has been added." + return 0 +} + +#Usage: dns_nexdns_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_nexdns_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _nexdns_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Cannot find the zone of $fulldomain in this NexDNS account." + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _info "Removing the TXT record for $fulldomain" + if ! _nexdns_rest GET "zones/$_domain_id/records?type=TXT&name=$_sub_domain"; then + return 1 + fi + + #All the challenge records share one name and one type, so the value is the + #only thing that tells them apart. A certificate covering example.com and + #*.example.com puts two of them at the same name at the same time. + _record_id="$(echo "$response" | tr '{' "\n" | grep -- "$txtvalue" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + _info "The TXT record is already gone, nothing to remove." + return 0 + fi + + if ! _nexdns_rest DELETE "zones/$_domain_id/records/$_record_id"; then + return 1 + fi + + _info "The TXT record has been removed." + return 0 +} + +#################### Private functions below ################################## + +#Reads the token and the api url, and applies the default url. +_nexdns_init() { + NEXDNS_Token="${NEXDNS_Token:-$(_readaccountconf_mutable NEXDNS_Token)}" + NEXDNS_Api="${NEXDNS_Api:-$(_readaccountconf_mutable NEXDNS_Api)}" + + if [ -z "$NEXDNS_Token" ]; then + _err "You have not set NEXDNS_Token yet." + _err "Create one at https://nexdns.tech/settings/api-keys, on a plan that includes API access, then:" + _err "export NEXDNS_Token=\"your-api-token\"" + return 1 + fi + + if [ -z "$NEXDNS_Api" ]; then + NEXDNS_Api="$NEXDNS_Api_Default" + fi + #A trailing slash would make every request path begin with a double slash. + NEXDNS_Api="$(echo "$NEXDNS_Api" | sed 's|/*$||')" + _debug NEXDNS_Api "$NEXDNS_Api" + + return 0 +} + +#_acme-challenge.www.example.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=example.com +# _domain_id=Zm9vYmFy +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + #not valid + return 1 + fi + + if ! _nexdns_rest GET "zones?search=$h&per_page=100"; then + return 1 + fi + + #search matches on a substring, so the page can also hold zones that merely + #contain h. Take the id of the one whose name is exactly h. + _domain_id="$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done +} + +#Usage: _nexdns_rest GET|POST|DELETE path [body] [attempt] +_nexdns_rest() { + m=$1 + ep=$2 + data=$3 + attempt=${4:-1} + _debug "$ep" + + export _H1="Authorization: Bearer $NEXDNS_Token" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "$NEXDNS_Api/$ep")" + else + _debug2 data "$data" + response="$(_post "$data" "$NEXDNS_Api/$ep" "" "$m" "application/json")" + fi + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + + #A single certificate costs a handful of requests, but a renewal sweep over + #many of them meets the account's per-minute budget, and that run is + #unattended. Retry-After is treated as a floor: an api may report the time one + #token needs at an average rate and name a second when nothing frees for a + #minute, so the wait grows on its own across attempts. + if [ "$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" = "429" ]; then + if [ "$attempt" -ge 4 ]; then + _err "$m $ep failed: rate limited, and the wait budget is spent" + return 1 + fi + + _retry_after="$(grep -i "^Retry-After" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d : -f 2 | tr -d " \r\n")" + _backoff="$(_math "$attempt" \* 15)" + #The header may also carry an http date. Anything but a plain count of + #seconds falls through to the backoff rather than being parsed: guessing + #wrong about a date is worse than waiting a known interval, and comparing a + #date numerically would abort the hook outright. + case "$_retry_after" in + "" | *[!0-9]*) _retry_after="$_backoff" ;; + *) + if [ "$_retry_after" -lt "$_backoff" ]; then + _retry_after="$_backoff" + fi + ;; + esac + + #A wait longer than this is a refusal rather than a schedule, and sleeping + #it out would hold the hook for the length of the window. Hand the run back + #instead, so the next cron pass picks it up. + if [ "$_retry_after" -gt 120 ]; then + _err "$m $ep failed: rate limited for ${_retry_after}s, longer than this hook will wait" + return 1 + fi + + _info "Rate limited by the NexDNS API; retrying in $_retry_after seconds." + _sleep "$_retry_after" + + _nexdns_rest "$m" "$ep" "$data" "$(_math "$attempt" + 1)" + return $? + fi + + #Whitespace between a key and its value would defeat every match made on the + #body, here and in the callers. + response="$(echo "$response" | _normalizeJson)" + _debug2 response "$response" + + #The status line decides success, not the body: a delete answers 204 with no + #body at all, and a record whose own content contains "error": would otherwise + #turn a stored value into a reported failure. The body is read only for the + #message once the status says the request was rejected. + _code="$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 _code "$_code" + case "$_code" in + "" | 2*) + return 0 + ;; + esac + + #A rejected request carries {"error":{"code":..,"message":..}}, so say what the + #api says went wrong. + _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_message" ]; then + _message="status $_code" + fi + _err "$m $ep failed: $_message" + + return 1 +} diff --git a/dnsapi/dns_njalla.sh b/dnsapi/dns_njalla.sh index c410447d..6ce51380 100644 --- a/dnsapi/dns_njalla.sh +++ b/dnsapi/dns_njalla.sh @@ -98,7 +98,7 @@ dns_njalla_rm() { echo "$records" | while read -r record; do record_name=$(echo "$record" | _egrep_o "\"name\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \") record_content=$(echo "$record" | _egrep_o "\"content\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \") - record_id=$(echo "$record" | _egrep_o "\"id\":\s?[0-9]+" | cut -d : -f 2 | tr -d " " | tr -d \") + record_id=$(echo "$record" | _egrep_o "\"id\":\s?\"?[^\",}]*" | cut -d : -f 2 | tr -d " " | tr -d \") if [ "$_sub_domain" = "$record_name" ]; then if [ "$txtvalue" = "$record_content" ]; then _debug "record_id" "$record_id" diff --git a/dnsapi/dns_oci.sh b/dnsapi/dns_oci.sh index c76a4565..e1aa3dd9 100644 --- a/dnsapi/dns_oci.sh +++ b/dnsapi/dns_oci.sh @@ -115,12 +115,15 @@ _oci_config() { _clearaccountconf_mutable OCI_CLI_PROFILE fi - OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readaccountconf_mutable OCI_CLI_TENANCY)}" + if [ -z "$OCI_CLI_TENANCY" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_TENANCY=$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_TENANCY" ]; then + OCI_CLI_TENANCY=$(_readaccountconf_mutable OCI_CLI_TENANCY) + fi if [ "$OCI_CLI_TENANCY" ]; then _saveaccountconf_mutable OCI_CLI_TENANCY "$OCI_CLI_TENANCY" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_TENANCY" ]; then @@ -128,41 +131,47 @@ _oci_config() { return 1 fi - OCI_CLI_USER="${OCI_CLI_USER:-$(_readaccountconf_mutable OCI_CLI_USER)}" + if [ -z "$OCI_CLI_USER" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_USER=$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_USER" ]; then + OCI_CLI_USER=$(_readaccountconf_mutable OCI_CLI_USER) + fi if [ "$OCI_CLI_USER" ]; then _saveaccountconf_mutable OCI_CLI_USER "$OCI_CLI_USER" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_USER="${OCI_CLI_USER:-$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_USER" ]; then _err "Error: unable to read OCI_CLI_USER from config file or environment variable." return 1 fi - OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readaccountconf_mutable OCI_CLI_REGION)}" + if [ -z "$OCI_CLI_REGION" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_REGION=$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_REGION" ]; then + OCI_CLI_REGION=$(_readaccountconf_mutable OCI_CLI_REGION) + fi if [ "$OCI_CLI_REGION" ]; then _saveaccountconf_mutable OCI_CLI_REGION "$OCI_CLI_REGION" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_REGION" ]; then _err "Error: unable to read OCI_CLI_REGION from config file or environment variable." return 1 fi - OCI_CLI_KEY="${OCI_CLI_KEY:-$(_readaccountconf_mutable OCI_CLI_KEY)}" - if [ -z "$OCI_CLI_KEY" ]; then - _clearaccountconf_mutable OCI_CLI_KEY - OCI_CLI_KEY_FILE="${OCI_CLI_KEY_FILE:-$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE")}" - if [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then - _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE" - OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE") - _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" - fi - else + if [ -z "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + OCI_CLI_KEY_FILE=$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE") + fi + if [ "$OCI_CLI_KEY" ]; then _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" + elif [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then + _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE" + OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE") + _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" + else + OCI_CLI_KEY=$(_readaccountconf_mutable OCI_CLI_KEY) fi if [ -z "$OCI_CLI_KEY_FILE" ] && [ -z "$OCI_CLI_KEY" ]; then diff --git a/dnsapi/dns_ovh.sh b/dnsapi/dns_ovh.sh index 9f2cd23f..df2b184d 100755 --- a/dnsapi/dns_ovh.sh +++ b/dnsapi/dns_ovh.sh @@ -224,7 +224,7 @@ _ovh_authentication() { _H3="" _H4="" - _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}' + _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "GET","path": "/domain/zone/*/record/*"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}' response="$(_post "$_ovhdata" "$OVH_API/auth/credential")" _debug3 response "$response" diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh index ec19ad25..72a58af0 100755 --- a/dnsapi/dns_pdns.sh +++ b/dnsapi/dns_pdns.sh @@ -50,6 +50,9 @@ dns_pdns_add() { PDNS_Ttl="$DEFAULT_PDNS_TTL" fi + # Ensure PDNS_Url has no trailing slash ('/') + PDNS_Url="${PDNS_Url%/}" + #save the api addr and key to the account conf file. _saveaccountconf_mutable PDNS_Url "$PDNS_Url" _saveaccountconf_mutable PDNS_ServerId "$PDNS_ServerId" @@ -186,19 +189,23 @@ _get_root() { domain=$1 i=1 - if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones"; then - _zones_response=$(echo "$response" | _normalizeJson) - fi - while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - if _contains "$_zones_response" "\"name\":\"$h.\""; then - _domain="$h." - if [ -z "$h" ]; then - _domain="=2E" + # Probe each candidate zone with the server-side name filter instead of + # listing every zone: with large installations (100k zones) the + # unfiltered list takes minutes. Servers that ignore the parameter + # return the full list, which the check below still handles. + # https://doc.powerdns.com/authoritative/http-api/zone.html + if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones?zone=$h."; then + _zones_response=$(echo "$response" | _normalizeJson) + if _contains "$_zones_response" "\"name\":\"$h.\""; then + _domain="$h." + if [ -z "$h" ]; then + _domain="=2E" + fi + return 0 fi - return 0 fi if [ -z "$h" ]; then diff --git a/dnsapi/dns_pleskxml.sh b/dnsapi/dns_pleskxml.sh index 465bcc60..176f329d 100644 --- a/dnsapi/dns_pleskxml.sh +++ b/dnsapi/dns_pleskxml.sh @@ -151,8 +151,8 @@ dns_pleskxml_rm() { # Extracting the id of the TXT record for the full domain (NOT case-sensitive) and corresponding value recid="$( _value "$reclist" | - grep -i "${fulldomain}." | - grep "${txtvalue}" | + grep -Fi "${fulldomain}." | + grep -F "${txtvalue}" | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/' )" @@ -419,7 +419,7 @@ _pleskxml_get_root_domain() { _debug "Checking if '$root_domain_name' is managed by the Plesk server..." - root_domain_id="$(_value "$output" | grep "$root_domain_name" | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')" + root_domain_id="$(_value "$output" | grep -F "$root_domain_name" | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')" if [ -n "$root_domain_id" ]; then # Found a match diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh new file mode 100644 index 00000000..a4c81835 --- /dev/null +++ b/dnsapi/dns_poweradmin.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 + +# Credits to the authors of dnsapi/dns_pdns.sh as this reuses much of that code. + +dns_poweradmin_info='Poweradmin API +Site: https://www.poweradmin.org/ +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_poweradmin +Options: +POWERADMIN_URL API URL (with scheme). E.g. "https://poweradmin.example.com" or "http://192.168.0.10:8080" +POWERADMIN_API_KEY API Token "pwa_xxxx" +POWERADMIN_API_VERSION Optionally override Poweradmin API version. +Issues: https://github.com/acmesh-official/acme.sh/issues/6912 +Author: Jakob Næss +' + +######## Public functions #################### + +# Usage: dns_poweradmin_add _acme-challenge.www.domain.com "123456789ABCDEF" +# fulldomain +# txtvalue +dns_poweradmin_add() { + fulldomain=$1 + txtvalue=$2 + + POWERADMIN_URL="${POWERADMIN_URL:-$(_readaccountconf_mutable POWERADMIN_URL)}" + POWERADMIN_API_KEY="${POWERADMIN_API_KEY:-$(_readaccountconf_mutable POWERADMIN_API_KEY)}" + POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-$(_readaccountconf_mutable POWERADMIN_API_VERSION)}" + POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-2}" + + if [ -z "$POWERADMIN_URL" ]; then + POWERADMIN_URL="" + _err "You didn't specify Poweradmin URL." + _err "Please set POWERADMIN_URL and try again." + return 1 + fi + + if [ -z "$POWERADMIN_API_KEY" ]; then + POWERADMIN_API_KEY="" + _err "You didn't specify Poweradmin token." + _err "Please set POWERADMIN_API_KEY and try again." + return 1 + fi + + # Save the api addr, key, and version to the account conf file. + _saveaccountconf_mutable POWERADMIN_URL "$POWERADMIN_URL" + _saveaccountconf_mutable POWERADMIN_API_KEY "$POWERADMIN_API_KEY" + _saveaccountconf_mutable POWERADMIN_API_VERSION "$POWERADMIN_API_VERSION" + + _debug "Detect root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _zone_id "$_zone_id" + + if ! _set_record "$fulldomain" "$txtvalue"; then + return 1 + fi + + return 0 +} + +# Usage: dns_poweradmin_rm _acme-challenge.www.domain.com "123456789ABCDEF" +# fulldomain +# txtvalue +dns_poweradmin_rm() { + fulldomain=$1 + txtvalue=$2 + + POWERADMIN_URL="${POWERADMIN_URL:-$(_readaccountconf_mutable POWERADMIN_URL)}" + POWERADMIN_API_KEY="${POWERADMIN_API_KEY:-$(_readaccountconf_mutable POWERADMIN_API_KEY)}" + POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-$(_readaccountconf_mutable POWERADMIN_API_VERSION)}" + POWERADMIN_API_VERSION="${POWERADMIN_API_VERSION:-2}" + + _debug "Detect root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _zone_id "$_zone_id" + + if ! _rm_record "$fulldomain" "$txtvalue"; then + return 1 + fi + + return 0 +} + +######## Private functions below ##################### + +_set_record() { + _info "Adding TXT record" + full=$1 + new_challenge=$2 + + data='{"name":"'$full'","type":"TXT","content":"'$new_challenge'","ttl":60}' + + if ! _poweradmin_rest "POST" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records" "$data" "application/json"; then + _err "Failed to add TXT record" + return 1 + fi + + return 0 +} + +_rm_record() { + _info "Remove TXT record" + full=$1 + txtvalue=$2 + + if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records"; then + _err "Failed to retrieve records" + return 1 + fi + + # The API returns: {"success":true,"data":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]} + _txt_record_obj=$( + printf '%s\n' "$response" | + sed 's/^.*"data":\[//; s/\],"message":.*$//' | + awk '{ gsub(/},{/, "}\n{"); print }' | + grep -F "\"name\":\"$full\"" | + grep -F "\"type\":\"TXT\"" | + grep -F "\"content\":\"$txtvalue\"" | + _head_n 1 + ) + + if [ -z "$_txt_record_obj" ]; then + _info "TXT record not found for $full with content $txtvalue" + return 0 + fi + + record_id=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | _head_n 1) + record_type=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"type":"\([^"]*\)".*/\1/p' | _head_n 1) + record_name=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"name":"\([^"]*\)".*/\1/p' | _head_n 1) + record_content=$(printf '%s\n' "$_txt_record_obj" | sed -n 's/.*"content":"\([^"]*\)".*/\1/p' | _head_n 1) + + _debug2 "_txt_record_obj=$_txt_record_obj" + _debug2 "record id: $record_id" + _debug2 "record type: $record_type" + _debug2 "record name: $record_name" + _debug2 "record content: $record_content" + + if [ "$record_type" != "TXT" ]; then + _err "Refusing to delete non-TXT record id=$record_id type=$record_type name=$full" + return 1 + fi + + if ! _poweradmin_rest "DELETE" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records/$record_id"; then + _err "Failed to delete TXT record" + return 1 + fi + + _info "Record deleted successfully" + return 0 +} + +# _acme-challenge.www.domain.com +# returns +# _domain=domain.com +# _zone_id=220 +_get_root() { + domain=$1 + i=1 + + if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones"; then + _err "Failed to retrieve zones" + return 1 + fi + + _zones_response="$response" + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + + if [ -z "$h" ]; then + _debug "Root domain not found for $domain" + return 1 + fi + + zone_obj=$( + printf '%s' "$_zones_response" | + sed 's/},{/}\n{/g' | + grep -F "\"name\":\"$h\"" | + _head_n 1 + ) + + if [ -n "$zone_obj" ]; then + _zone_id=$(printf '%s' "$zone_obj" | _egrep_o '"id":[0-9][0-9]*' | _head_n 1 | cut -d: -f2) + _domain="$h" + _debug "Found zone: $_domain with id: $_zone_id" + return 0 + fi + + i=$(_math "$i" + 1) + done +} + +_poweradmin_rest() { + method=$1 + ep=$2 + data=$3 + ct=$4 + + export _H1="X-API-Key: $POWERADMIN_API_KEY" + + if [ "$method" = "GET" ]; then + response="$(_get "$POWERADMIN_URL$ep")" + else + _debug "API call: $method $ep" + _debug "Content-Type: $ct" + _debug "Payload: $data" + response="$(_post "$data" "$POWERADMIN_URL$ep" "" "$method" "$ct")" + fi + + # Clear _H1 variable + unset -v _H1 + + if [ "$?" != "0" ]; then + _err "API error on $method $ep" + _debug "Response: $response" + return 1 + fi + + if printf '%s' "$response" | grep -q '"success"[ ]*:[ ]*false'; then + _err "API reported failure on $method $ep" + _debug "Response: $response" + return 1 + fi + + _debug2 "API Response: $response" + return 0 +} diff --git a/dnsapi/dns_rage4.sh b/dnsapi/dns_rage4.sh index c27fbc5f..b9abff17 100755 --- a/dnsapi/dns_rage4.sh +++ b/dnsapi/dns_rage4.sh @@ -71,7 +71,7 @@ dns_rage4_rm() { _debug "Getting txt records" _rage4_rest "getrecords/?id=${_domain_id}" - _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p') + _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -n 's/.*"id":\([0-9][0-9]*\),.*/\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 diff --git a/dnsapi/dns_regru.sh b/dnsapi/dns_regru.sh index be5ae117..edf8b464 100644 --- a/dnsapi/dns_regru.sh +++ b/dnsapi/dns_regru.sh @@ -96,8 +96,8 @@ _get_root() { for ITEM in ${domains_list}; do IDN_ITEM=${ITEM} - case "${domain}" in - *${IDN_ITEM}*) + case ".${domain}" in + *.${IDN_ITEM}*) _domain="$(_idn "${ITEM}")" _debug _domain "${_domain}" return 0 diff --git a/dnsapi/dns_rltx.sh b/dnsapi/dns_rltx.sh new file mode 100644 index 00000000..065ac177 --- /dev/null +++ b/dnsapi/dns_rltx.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_rltx_info='Realtox Media Cloudpanel DNS API +Site: realtoxmedia.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_rltx +Options: + RLTX_Key API Key + RLTX_OrganizationID Organization ID +' + +######## Public functions ##################### + +#Usage: dns_rltx_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_rltx_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using Realtox Media Cloudpanel DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _rltx_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + _err "Could not find matching DNS zone for $fulldomain" + return 1 + fi + + _debug _domain_id "$_domain_id" + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}" + if ! _rltx_rest POST "domains/$_domain_id/dns/acme-txt" "$data"; then + _err "Add TXT record request failed" + return 1 + fi + if _contains "$response" '"status":"added"'; then + _info "Added TXT record, OK" + return 0 + fi + _err "Add TXT record failed: $response" + return 1 +} + +#Usage: fulldomain txtvalue +#Remove the txt record after validation. +dns_rltx_rm() { + fulldomain=$1 + txtvalue=$2 + + _info "Using Realtox Media Cloudpanel DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _rltx_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + _err "Could not find matching DNS zone for $fulldomain" + return 1 + fi + + _debug _domain_id "$_domain_id" + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}" + if ! _rltx_rest DELETE "domains/$_domain_id/dns/acme-txt" "$data"; then + _err "Remove TXT record request failed" + return 1 + fi + if _contains "$response" '"status":"removed"'; then + _info "Removed TXT record, OK" + return 0 + fi + _err "Remove TXT record failed: $response" + return 1 +} + +#################### Private functions below ################################## + +_rltx_init() { + RLTX_Key="${RLTX_Key:-$(_readaccountconf_mutable RLTX_Key)}" + RLTX_OrganizationID="${RLTX_OrganizationID:-$(_readaccountconf_mutable RLTX_OrganizationID)}" + + if [ -z "$RLTX_Key" ] || [ -z "$RLTX_OrganizationID" ]; then + RLTX_Key="" + RLTX_OrganizationID="" + _err "Please specify RLTX_Key and RLTX_OrganizationID." + _err "You can export them and retry: export RLTX_Key=... RLTX_OrganizationID=..." + return 1 + fi + + _saveaccountconf_mutable RLTX_Key "$RLTX_Key" + _saveaccountconf_mutable RLTX_OrganizationID "$RLTX_OrganizationID" +} + +_get_root() { + domain=$1 + fqdn_encoded="$(printf "%s" "$domain" | _url_encode)" + if ! _rltx_rest GET "domains/dns/acme-zone?fqdn=$fqdn_encoded"; then + return 1 + fi + if ! _contains "$response" '"domain_id":"'; then + return 1 + fi + + _domain_id="$(printf "%s" "$response" | _egrep_o '"domain_id":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + _domain="$(printf "%s" "$response" | _egrep_o '"zone":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + _sub_domain="$(printf "%s" "$response" | _egrep_o '"record_name":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + + if [ -z "$_domain_id" ] || [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then + return 1 + fi + return 0 +} + +_rltx_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + export _H1="X-API-Key: $RLTX_Key" + export _H2="X-Organization-ID: $RLTX_OrganizationID" + export _H3="Content-Type: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "https://api.ccp.realtoxmedia.de/api/$ep")" + else + _debug2 data "$data" + response="$(_post "$data" "https://api.ccp.realtoxmedia.de/api/$ep" "" "$m")" + fi + + if [ "$?" != "0" ]; then + _err "Realtox Media Cloudpanel API request failed: $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_selectel.sh b/dnsapi/dns_selectel.sh index 565f541b..8ba9a4fb 100644 --- a/dnsapi/dns_selectel.sh +++ b/dnsapi/dns_selectel.sh @@ -368,7 +368,7 @@ _get_auth_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") + _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | cut -d ':' -f 2- | tr -d ' \t\r') _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" diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh index 4912dfdf..25130146 100644 --- a/dnsapi/dns_selfhost.sh +++ b/dnsapi/dns_selfhost.sh @@ -7,6 +7,7 @@ Options: SELFHOSTDNS_USERNAME Username SELFHOSTDNS_PASSWORD Password SELFHOSTDNS_MAP Subdomain name + SELFHOSTDNS_UPDATE_URL API url. Optional. Default "https://account.selfhost.de/cgi-bin/api.pl" Issues: github.com/acmesh-official/acme.sh/issues/4291 Author: Marvin Edeler ' @@ -18,9 +19,11 @@ dns_selfhost_add() { _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - SELFHOSTDNS_UPDATE_URL="https://selfhost.de/cgi-bin/api.pl" + DEFAULT_SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl" # Get values, but don't save until we successfully validated + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}" + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}" SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}" # These values are domain dependent, so read them from there @@ -39,7 +42,10 @@ dns_selfhost_add() { # only match full domains (at the beginning of the string or with a leading whitespace), # e.g. don't match mytest.example.com or sub.test.example.com for test.example.com # if the domain is defined multiple times only the last occurance will be matched - mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain)(:[[:digit:]]+)([:]?[[:digit:]]*)(.*)/\2\3\4/p") + # prepend a space to each line so "start of line" and "after whitespace" + # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..)) + _selfhost_tab="$(printf '\t')" + mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p") _debug2 mapEntry "$mapEntry" if test -z "$mapEntry"; then _err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID" @@ -51,7 +57,7 @@ dns_selfhost_add() { rid2=$(echo "$mapEntry" | cut -d: -f3) # read last used rid domain - lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain:[[:digit:]]+)(.*)/\2/p") + lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p") _debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry" lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2) @@ -84,6 +90,11 @@ dns_selfhost_add() { fi fi + # Save api url if different from default + if [ "$DEFAULT_SELFHOSTDNS_UPDATE_URL" != "$SELFHOSTDNS_UPDATE_URL" ]; then + _saveaccountconf_mutable SELFHOSTDNS_UPDATE_URL "$SELFHOSTDNS_UPDATE_URL" + fi + # Now that we know the values are good, save them _saveaccountconf_mutable SELFHOSTDNS_USERNAME "$SELFHOSTDNS_USERNAME" _saveaccountconf_mutable SELFHOSTDNS_PASSWORD "$SELFHOSTDNS_PASSWORD" diff --git a/dnsapi/dns_simply.sh b/dnsapi/dns_simply.sh index e0ad16e2..74e891ad 100644 --- a/dnsapi/dns_simply.sh +++ b/dnsapi/dns_simply.sh @@ -8,11 +8,7 @@ Options: SIMPLY_ApiKey API Key ' -#SIMPLY_Api="https://api.simply.com/2/" -SIMPLY_Api_Default="https://api.simply.com/2" - -#This is used for determining success of REST call -SIMPLY_SUCCESS_CODE='"status":200' +SIMPLY_Api="https://api.simply.com/2" ######## Public functions ##################### #Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" @@ -72,7 +68,16 @@ dns_simply_rm() { return 1 fi - records=$(echo "$response" | tr '{' "\n" | grep 'record_id\|type\|data\|\name' | sed 's/\"record_id/;\"record_id/' | tr "\n" ' ' | tr -d ' ' | tr ';' ' ') + case "$_simply_http_code" in + 2*) ;; + *) + _err "Failed to fetch DNS records (HTTP $_simply_http_code)" + _err "$response" + return 1 + ;; + esac + + records=$(echo "$response" | tr '{' "\n" | grep -E 'record_id|type|data|name' | sed 's/\"record_id/;\"record_id/' | tr "\n" ' ' | tr -d ' ' | tr ';' ' ') nr_of_deleted_records=0 _info "Fetching txt record" @@ -95,7 +100,7 @@ dns_simply_rm() { if [ "$record_id" -gt 0 ]; then - if ! _simply_delete_record "$_domain" "$_sub_domain" "$record_id"; then + if ! _simply_delete_record "$_domain" "$record_id"; then _err "Record with id $record_id could not be deleted" return 1 fi @@ -122,14 +127,9 @@ dns_simply_rm() { #################### Private functions below ################################## _simply_load_config() { - SIMPLY_Api="${SIMPLY_Api:-$(_readaccountconf_mutable SIMPLY_Api)}" SIMPLY_AccountName="${SIMPLY_AccountName:-$(_readaccountconf_mutable SIMPLY_AccountName)}" SIMPLY_ApiKey="${SIMPLY_ApiKey:-$(_readaccountconf_mutable SIMPLY_ApiKey)}" - if [ -z "$SIMPLY_Api" ]; then - SIMPLY_Api="$SIMPLY_Api_Default" - fi - if [ -z "$SIMPLY_AccountName" ] || [ -z "$SIMPLY_ApiKey" ]; then SIMPLY_AccountName="" SIMPLY_ApiKey="" @@ -144,9 +144,6 @@ _simply_load_config() { } _simply_save_config() { - if [ "$SIMPLY_Api" != "$SIMPLY_Api_Default" ]; then - _saveaccountconf_mutable SIMPLY_Api "$SIMPLY_Api" - fi _saveaccountconf_mutable SIMPLY_AccountName "$SIMPLY_AccountName" _saveaccountconf_mutable SIMPLY_ApiKey "$SIMPLY_ApiKey" } @@ -163,26 +160,39 @@ _simply_get_all_records() { _get_root() { domain=$1 + + if ! _simply_rest GET "my/products/"; then + return 1 + fi + + case "$_simply_http_code" in + 2*) ;; + *) + _err "Failed to fetch product list (HTTP $_simply_http_code)" + _err "$response" + return 1 + ;; + esac + i=2 p=1 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) if [ -z "$h" ]; then - #not valid return 1 fi - if ! _simply_rest GET "my/products/$h/dns/"; then - return 1 - fi + _domain=$(printf "%s" "$response" | tr '}' '\n' | + grep -F -e "\"object\":\"$h\"" -e "\"name\":\"$h\"" -e "\"name_idn\":\"$h\"" | + sed -n 's/.*"object":"\([^"]*\)".*/\1/p' | + _head_n 1) - if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then - _debug "$h not found" - else + if [ -n "$_domain" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain="$h" return 0 fi + + _debug "No Simply.com product found for $h" p="$i" i=$(_math "$i" + 1) done @@ -194,39 +204,44 @@ _simply_add_record() { sub_domain=$2 txtval=$3 - data="{\"name\": \"$sub_domain\", \"type\":\"TXT\", \"data\": \"$txtval\", \"priority\":0, \"ttl\": 3600}" + data="{\"name\": \"$sub_domain\", \"type\":\"TXT\", \"data\": \"$txtval\", \"priority\":0, \"ttl\": 120}" if ! _simply_rest POST "my/products/$domain/dns/records/" "$data"; then - _err "Adding record not successfull!" + _err "Adding record not successful!" return 1 fi - if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then - _err "Call to API not sucessfull, see below message for more details" + case "$_simply_http_code" in + 2*) ;; + *) + _err "Call to API not successful (HTTP $_simply_http_code), see below message for more details" _err "$response" return 1 - fi + ;; + esac return 0 } _simply_delete_record() { domain=$1 - sub_domain=$2 - record_id=$3 + record_id=$2 _debug record_id "Delete record with id $record_id" if ! _simply_rest DELETE "my/products/$domain/dns/records/$record_id/"; then - _err "Deleting record not successfull!" + _err "Deleting record not successful!" return 1 fi - if ! _contains "$response" "$SIMPLY_SUCCESS_CODE"; then - _err "Call to API not sucessfull, see below message for more details" + case "$_simply_http_code" in + 2*) ;; + *) + _err "Call to API not successful (HTTP $_simply_http_code), see below message for more details" _err "$response" return 1 - fi + ;; + esac return 0 } @@ -248,17 +263,24 @@ _simply_rest() { export _H2="Content-Type: application/json" + : >"$HTTP_HEADER" + if [ "$m" != "GET" ]; then response="$(_post "$data" "$SIMPLY_Api/$ep" "" "$m")" else response="$(_get "$SIMPLY_Api/$ep")" fi - if [ "$?" != "0" ]; then + _ret="$?" + unset _H1 _H2 + + if [ "$_ret" != "0" ]; then _err "error $ep" return 1 fi + _simply_http_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d' ' -f2 | tr -d '\r\n')" + response="$(echo "$response" | _normalizeJson)" _debug2 response "$response" diff --git a/dnsapi/dns_udr.sh b/dnsapi/dns_udr.sh index 656a0557..dbc959d6 100644 --- a/dnsapi/dns_udr.sh +++ b/dnsapi/dns_udr.sh @@ -145,8 +145,8 @@ _udr_rest() { _debug data "${data}" response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")" - _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | tr -d ' \t\r') + _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | tr -d '\r' | sed -e 's/^[ ]*//' -e 's/[ ]*$//') _debug response_code "$_code" _debug response_description "$_description" diff --git a/dnsapi/dns_volcengine.sh b/dnsapi/dns_volcengine.sh new file mode 100755 index 00000000..2cc805d5 --- /dev/null +++ b/dnsapi/dns_volcengine.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_volcengine_info='Volcano Engine DNS API +Site: https://www.volcengine.com/docs/6758/155086 +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_volcengine +Options: + Volcengine_ACCESS_KEY_ID API Key ID + Volcengine_SECRET_ACCESS_KEY API Secret + Volcengine_SESSION_TOKEN Session Token. Optional, only needed when using temporary STS credentials. +Issues: github.com/acmesh-official/acme.sh/issues/7064 +' + +Volcengine_HOST="dns.volcengineapi.com" +Volcengine_URL="https://$Volcengine_HOST" + +######## Public functions ##################### + +#fulldomain txtvalue +dns_volcengine_add() { + fulldomain=$1 + txtvalue=$2 + _record_id="" + + Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}" + Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}" + + if [ -z "$Volcengine_ACCESS_KEY_ID" ] || [ -z "$Volcengine_SECRET_ACCESS_KEY" ]; then + Volcengine_ACCESS_KEY_ID="" + Volcengine_SECRET_ACCESS_KEY="" + _err "You haven't specified the volcengine dns api key id and api key secret yet." + return 1 + fi + + #save the api key and email to the account conf file. + _saveaccountconf_mutable Volcengine_ACCESS_KEY_ID "$Volcengine_ACCESS_KEY_ID" + _saveaccountconf_mutable Volcengine_SECRET_ACCESS_KEY "$Volcengine_SECRET_ACCESS_KEY" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + _sleep 1 + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + # _info "Getting existing records for $fulldomain" + if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then + _sleep 1 + return 1 + fi + + # ListRecords already filtered by ZID + Host + Value + SearchMode:exact, + # so any returned record is our target. Don't match on FQDN: Volcengine + # lowercases the Host/FQDN in the response, which would break a + # case-sensitive string compare against $fulldomain. + _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')" + _debug "_record_id" "$_record_id" + + if [ "$_record_id" ] && _contains "$response" "$txtvalue"; then + _info "The TXT record already exists. Skipping." + _sleep 1 + return 0 + fi + + _debug "Adding records" + + if volcengine_rest POST "" "Action=CreateRecord&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"Remark\":\"acme.sh\"}"; then + _info "TXT record updated successfully." + _sleep 1 + return 0 + fi + + _sleep 1 + return 1 +} + +#fulldomain txtvalue +dns_volcengine_rm() { + fulldomain=$1 + txtvalue=$2 + _record_id="" + + Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}" + Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + _sleep 1 + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Getting existing records for $fulldomain" + + if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then + _sleep 1 + return 1 + fi + + # ListRecords already filtered by ZID + Host + Value + SearchMode:exact, + # so any returned record is our target. Don't match on FQDN: Volcengine + # lowercases the Host/FQDN in the response, which would break a + # case-sensitive string compare against $fulldomain. + _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')" + _debug "_record_id" "$_record_id" + + if [ -z "$_record_id" ]; then + _debug "no records exist, skip" + _sleep 1 + return 0 + fi + + if volcengine_rest POST "" "Action=DeleteRecord&Version=2018-08-01" "{\"RecordID\":\"$_record_id\"}"; then + _info "TXT record deleted successfully." + _sleep 1 + return 0 + fi + _sleep 1 + return 1 +} + +#################### Private functions below ################################## + +_get_root() { + domain=$1 + i=1 + p=1 + + # iterate over names (a.b.c.d -> b.c.d -> c.d -> d) + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug "Checking domain: $h" + if [ -z "$h" ]; then + _err "invalid domain" + return 1 + fi + + # iterate over paginated result for list_hosted_zones + if ! volcengine_rest POST "" "Action=ListZones&Version=2018-08-01" "{\"Key\":\"$h\",\"SearchMode\":\"exact\"}"; then + return 1 + fi + if _contains "$response" "\"ZoneName\":\"$h\""; then + _domain_id=$(printf "%s" "$response" | _egrep_o "\"ZID\":[0-9]+," | cut -d: -f2 | cut -d, -f1) + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + _err "Can't find domain with id: $h" + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +#method uri qstr data +volcengine_rest() { + mtd="$1" + ep="$2" + qsr="$3" + data="$4" + + _debug mtd "$mtd" + _debug ep "$ep" + _debug qsr "$qsr" + _debug data "$data" + + # clear any header state left over from a previous request so that + # conditionally-set headers (e.g. x-content-sha256, x-security-token) + # can't leak into the next request + _H1="" + _H2="" + _H3="" + _H4="" + _H5="" + + CanonicalURI="/$ep" + _debug2 CanonicalURI "$CanonicalURI" + + CanonicalQueryString="$qsr" + _debug2 CanonicalQueryString "$CanonicalQueryString" + + RequestDate="$(date -u +"%Y%m%dT%H%M%SZ")" + _debug2 RequestDate "$RequestDate" + + Hash="sha256" + + _H1="X-Date: $RequestDate" + _debug2 _H1 "$_H1" + + volcengine_host="$Volcengine_HOST" + CanonicalHeaders="host:$volcengine_host\n" + SignedHeaders="host" + + if [ -n "$data" ]; then + XContentSha256="$(printf "%s" "$data" | _digest "$Hash" hex)" + _H4="x-content-sha256: $XContentSha256" + _debug2 _H4 "$_H4" + + CanonicalHeaders="${CanonicalHeaders}x-content-sha256:$XContentSha256\n" + SignedHeaders="${SignedHeaders};x-content-sha256" + fi + + CanonicalHeaders="${CanonicalHeaders}x-date:$RequestDate\n" + SignedHeaders="${SignedHeaders};x-date" + + if [ -n "$Volcengine_SESSION_TOKEN" ]; then + _H3="x-security-token: $Volcengine_SESSION_TOKEN" + CanonicalHeaders="${CanonicalHeaders}x-security-token:$Volcengine_SESSION_TOKEN\n" + SignedHeaders="${SignedHeaders};x-security-token" + fi + + _debug2 CanonicalHeaders "$CanonicalHeaders" + _debug2 SignedHeaders "$SignedHeaders" + + RequestPayload="$data" + _debug2 RequestPayload "$RequestPayload" + + CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$(printf "%s" "$RequestPayload" | _digest "$Hash" hex)" + _debug2 CanonicalRequest "$CanonicalRequest" + + HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)" + _debug2 HashedCanonicalRequest "$HashedCanonicalRequest" + + Algorithm="HMAC-SHA256" + _debug2 Algorithm "$Algorithm" + + RequestDateOnly="$(echo "$RequestDate" | cut -c 1-8)" + _debug2 RequestDateOnly "$RequestDateOnly" + + Region="cn-beijing" + Service="dns" + + CredentialScope="$RequestDateOnly/$Region/$Service/request" + _debug2 CredentialScope "$CredentialScope" + + StringToSign="$Algorithm\n$RequestDate\n$CredentialScope\n$HashedCanonicalRequest" + + _debug2 StringToSign "$StringToSign" + + kSecret="$Volcengine_SECRET_ACCESS_KEY" + + _secure_debug2 kSecret "$kSecret" + + kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")" + _secure_debug2 kSecretH "$kSecretH" + + kDateH="$(printf "%s" "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)" + _debug2 kDateH "$kDateH" + + kRegionH="$(printf "%s" "$Region" | _hmac "$Hash" "$kDateH" hex)" + _debug2 kRegionH "$kRegionH" + + kServiceH="$(printf "%s" "$Service" | _hmac "$Hash" "$kRegionH" hex)" + _debug2 kServiceH "$kServiceH" + + kSigningH="$(printf "%s" "request" | _hmac "$Hash" "$kServiceH" hex)" + _debug2 kSigningH "$kSigningH" + + signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)" + _debug2 signature "$signature" + + Authorization="$Algorithm Credential=$Volcengine_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature" + _debug2 Authorization "$Authorization" + + _H2="Authorization: $Authorization" + _debug2 _H2 "$_H2" + + url="$Volcengine_URL/$ep" + if [ "$qsr" ]; then + url="$Volcengine_URL/$ep?$qsr" + fi + + if [ "$mtd" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "POST" "application/json")" + fi + + _ret="$?" + _debug response "$response" + if [ "$_ret" = "0" ]; then + if _contains "$response" "\"Error\":{"; then + _err "Response error:$response" + return 1 + fi + fi + + return "$_ret" +} diff --git a/dnsapi/dns_wedos.sh b/dnsapi/dns_wedos.sh new file mode 100644 index 00000000..d1f353e2 --- /dev/null +++ b/dnsapi/dns_wedos.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_wedos_info='WEDOS.com +Site: wedos.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_wedos +Options: + WEDOS_Username WAPI login (account email) + WEDOS_Wapipass WAPI password +Issues: github.com/acmesh-official/acme.sh/issues/7071 +Author: Jan Forman +' + +WEDOS_Api="https://api.wedos.com/wapi/json" + +######## Public functions ##################### + +#Usage: dns_wedos_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_add() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + _info "Adding TXT record: $_sub_domain.$_domain" + if ! _wedos_request "dns-row-add" "{\"domain\":\"$_domain\",\"name\":\"$_sub_domain\",\"ttl\":\"300\",\"type\":\"TXT\",\"rdata\":\"$txtvalue\"}"; then + _err "Failed to add TXT record" + return 1 + fi + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#Usage: dns_wedos_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_rm() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + # _get_root leaves the dns-rows-list response for $_domain in $response + _debug "Looking up row IDs for TXT value: $txtvalue" + _row_ids=$(echo "$response" | tr '{' '\n' | grep -F -- "\"rdata\":\"$txtvalue\"" | grep -F -- "\"name\":\"$_sub_domain\"" | _egrep_o '"ID": *"[0-9]*"' | tr -dc '0-9\n') + _debug _row_ids "$_row_ids" + + if [ -z "$_row_ids" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + + for _row_id in $_row_ids; do + _info "Removing TXT record ID $_row_id from $_domain" + if ! _wedos_request "dns-row-delete" "{\"domain\":\"$_domain\",\"row_id\":\"$_row_id\"}"; then + _err "Failed to delete TXT record" + return 1 + fi + done + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#################### Private functions below ################################## + +_wedos_init() { + WEDOS_Username="${WEDOS_Username:-$(_readaccountconf_mutable WEDOS_Username)}" + WEDOS_Wapipass="${WEDOS_Wapipass:-$(_readaccountconf_mutable WEDOS_Wapipass)}" + + if [ -z "$WEDOS_Username" ] || [ -z "$WEDOS_Wapipass" ]; then + WEDOS_Username="" + WEDOS_Wapipass="" + _err "You didn't specify the WEDOS WAPI credentials yet." + _err "Please export WEDOS_Username and WEDOS_Wapipass and try again." + return 1 + fi + + _saveaccountconf_mutable WEDOS_Username "$WEDOS_Username" + _saveaccountconf_mutable WEDOS_Wapipass "$WEDOS_Wapipass" + return 0 +} + +# WAPI auth token: sha1(login + sha1(password) + hour), where the hour is +# the current hour on the WEDOS servers (Europe/Prague timezone). +# The POSIX TZ string is used so no tzdata is required on the client. +_wedos_auth() { + if [ "$_wedos_utc" ]; then + # fallback: WAPI accepts 1 hour of skew, UTC+1 fits both CET and CEST + _wedos_hour=$(date -u +%H) + _wedos_hour=$(printf '%02d' "$(((${_wedos_hour#0} + 1) % 24))") + else + _wedos_hour=$(TZ='CET-1CEST,M3.5.0,M10.5.0/3' date +%H) + fi + _wedos_phash=$(printf '%s' "$WEDOS_Wapipass" | _digest sha1 hex) + printf '%s' "${WEDOS_Username}${_wedos_phash}${_wedos_hour}" | _digest sha1 hex +} + +#Usage: _wedos_request +#Returns 0 and sets $response on WAPI code 1000, returns 1 otherwise. +_wedos_request() { + _wedos_cmd="$1" + _wedos_data="$2" + + _wedos_token=$(_wedos_auth) + _secure_debug _wedos_token "$_wedos_token" + + _wedos_json="{\"request\":{\"user\":\"$WEDOS_Username\",\"auth\":\"$_wedos_token\",\"command\":\"$_wedos_cmd\",\"data\":$_wedos_data}}" + _debug2 "WAPI command: $_wedos_cmd" + _debug2 "WAPI data: $_wedos_data" + + # _post sends the global _H1.._H5 headers with every request; clear them so + # headers from earlier API calls are not leaked to the WAPI endpoint. + export _H1="" + export _H2="" + export _H3="" + export _H4="" + export _H5="" + + _wedos_body="request=$(printf '%s' "$_wedos_json" | _url_encode)" + response=$(_post "$_wedos_body" "$WEDOS_Api" "" "POST" "application/x-www-form-urlencoded") + if [ "$?" != "0" ]; then + _err "WAPI request failed for command '$_wedos_cmd'" + return 1 + fi + _debug2 "WAPI response: $response" + + _wedos_code=$(echo "$response" | _egrep_o '"code": *[0-9]*' | _head_n 1 | tr -dc '0-9') + _debug2 "WAPI result code: $_wedos_code" + if [ "$_wedos_code" = "1000" ]; then + return 0 + fi + + # some systems ignore the TZ variable (Haiku), sending a wrong auth hour; + # retry once with the UTC fallback in _wedos_auth + if [ "$_wedos_code" = "2050" ] && [ -z "$_wedos_utc" ]; then + _wedos_utc=1 + _wedos_request "$_wedos_cmd" "$_wedos_data" + return $? + fi + + # 2050 = bad credentials, 2051 = IP not whitelisted, 2052 = IP blocked + if [ "$_wedos_code" = "2050" ] || [ "$_wedos_code" = "2051" ] || [ "$_wedos_code" = "2052" ]; then + _wedos_result=$(echo "$response" | _egrep_o '"result": *"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _err "WAPI authentication error $_wedos_code: $_wedos_result" + _err "Check WEDOS_Username, WEDOS_Wapipass and the WAPI IP whitelist." + _wedos_autherr=1 + return 1 + fi + + _debug "WAPI error for command '$_wedos_cmd': $response" + return 1 +} + +# Determine the registered domain (_domain) and subdomain prefix (_sub_domain) +# by walking up the labels and calling dns-rows-list until WAPI accepts one. +# _acme-challenge.www.example.co.uk +# -> _sub_domain=_acme-challenge.www _domain=example.co.uk +# The full domain itself is tried first, so a zone apex (e.g. DNS alias mode +# pointing at the registered domain) resolves to an empty _sub_domain. +_get_root() { + _gr_full="$1" + _gr_i=1 + _wedos_autherr="" + while true; do + _gr_candidate=$(printf '%s' "$_gr_full" | cut -d . -f "${_gr_i}"-100) + _debug2 "Checking zone candidate: $_gr_candidate" + if [ -z "$_gr_candidate" ]; then + return 1 + fi + + if _wedos_request "dns-rows-list" "{\"domain\":\"$_gr_candidate\"}"; then + _domain="$_gr_candidate" + if [ "$_gr_i" = "1" ]; then + _sub_domain="" + else + _sub_domain=$(printf '%s' "$_gr_full" | cut -d . -f 1-"$((_gr_i - 1))") + fi + return 0 + fi + + # auth error hits every candidate, stop the walk + if [ "$_wedos_autherr" ]; then + return 1 + fi + + _gr_i=$((_gr_i + 1)) + done +} diff --git a/dnsapi/dns_world4you.sh b/dnsapi/dns_world4you.sh index f59715ac..0a1cda6b 100644 --- a/dnsapi/dns_world4you.sh +++ b/dnsapi/dns_world4you.sh @@ -61,7 +61,7 @@ dns_world4you_add() { if _contains "$res" "successfully"; then return 0 else - msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') + msg=$(_w4y_alert_msg "$res") if [ "$msg" = '' ]; then _err "Unable to add record: Unknown error" echo "$ret" >'error-01.html' @@ -125,7 +125,7 @@ dns_world4you_rm() { if _contains "$res" "successfully"; then return 0 else - msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') + msg=$(_w4y_alert_msg "$res") if [ "$msg" = '' ]; then _err "Unable to remove record: Unknown error" echo "$ret" >'error-01.html' @@ -145,6 +145,17 @@ dns_world4you_rm() { ################ Private functions ################ +# Usage: _w4y_alert_msg +# Extracts the error text out of the alert box of a DNS page. +# "grep -A" is not portable (Solaris /usr/bin/grep: "illegal option -- A"), +# so select from the alert to EOF and keep the same number of lines. +# "\s" is a GNU sed extension, use an explicit space/tab bracket instead. +_w4y_alert_msg() { + _w4y_tab=$(printf '\t') + echo "$1" | sed -n '/alert-notification/,$p' | _head_n 21 | + grep 'class="weak-title">[^<]' | sed "s/<[^>]*>//g;s/^[ $_w4y_tab]*//" +} + # Usage: _login _login() { WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}" diff --git a/dnsapi/dns_yandex360.sh b/dnsapi/dns_yandex360.sh index 18d01361..98841d6b 100644 --- a/dnsapi/dns_yandex360.sh +++ b/dnsapi/dns_yandex360.sh @@ -149,7 +149,7 @@ _check_variables() { org_response="$(echo "$org_response" | _normalizeJson)" YANDEX360_ORG_ID=$( echo "$org_response" | - _egrep_o '"id":[[:space:]]*[0-9]+' | + _egrep_o '"id":[ ]*[0-9]+' | cut -d':' -f2 ) _debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID" @@ -216,7 +216,7 @@ _get_token() { interval=$( echo "$response" | - _egrep_o '"interval":[[:space:]]*[0-9]+' | + _egrep_o '"interval":[ ]*[0-9]+' | cut -d':' -f2 ) _debug 'Polling interval' "$interval" diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 36c49ce4..cacb5977 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -22,21 +22,32 @@ dns_yc_add() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 + # YC_SA_Key_File_PEM_b64/Path are always persisted to the domain conf below, + # so they must be recovered from there first (account conf is only a + # fallback for the YC_Folder_ID case, see the SA_ID/SA_Key_ID save below). + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}" YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}" YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" if [ "$YC_SA_Key_File_PEM_b64" ]; then echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key YC_SA_Key_File="private.key" + _yc_key_is_temp=1 _savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" else YC_SA_Key_File="$YC_SA_Key_File_Path" + _yc_key_is_temp="" _savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path" fi + YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then @@ -65,11 +76,21 @@ dns_yc_add() { return 1 fi else + # Clear both possible stores -- YC_Zone_ID/YC_Folder_ID/key material are + # persisted to the domain conf, while YC_SA_ID/YC_SA_Key_ID may have been + # saved account-wide (Folder_ID mode), so a plain _clearaccountconf alone + # would leave stale values behind in whichever store wasn't touched. + _cleardomainconf YC_Zone_ID _clearaccountconf YC_Zone_ID + _cleardomainconf YC_Folder_ID _clearaccountconf YC_Folder_ID - _clearaccountconf YC_SA_ID - _clearaccountconf YC_SA_Key_ID + _cleardomainconf YC_SA_ID + _clearaccountconf_mutable YC_SA_ID + _cleardomainconf YC_SA_Key_ID + _clearaccountconf_mutable YC_SA_Key_ID + _cleardomainconf YC_SA_Key_File_PEM_b64 _clearaccountconf YC_SA_Key_File_PEM_b64 + _cleardomainconf YC_SA_Key_File_Path _clearaccountconf YC_SA_Key_File_Path _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." return 1 @@ -110,11 +131,30 @@ dns_yc_rm() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 + YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + # See dns_yc_add() for why domain conf is checked before account conf. + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + + if [ "$YC_SA_Key_File_PEM_b64" ]; then + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key + YC_SA_Key_File="private.key" + _yc_key_is_temp=1 + else + YC_SA_Key_File="$YC_SA_Key_File_Path" + _yc_key_is_temp="" + fi + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -124,16 +164,10 @@ dns_yc_rm() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - _debug "Getting txt records" - if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:]*$") - _debug exists_txtvalue "$exists_txtvalue" - else - _err "Error: $response" - return 1 - fi - - if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then + # upsertRecordSets.deletions removes only the given value from the rrset, + # leaving any other values at the same name (e.g. base + wildcard domain) + # intact -- no need to read the current data set and recompute it. + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then if _contains "$response" "\"done\": true"; then _info "Delete, OK" return 0 @@ -194,7 +228,7 @@ _get_root() { return 1 fi if _contains "$response" "\"zone\": \"$h\""; then - _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"') + _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') _debug _domain_id "$_domain_id" if [ "$_domain_id" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") @@ -255,7 +289,9 @@ _yc_login() { _signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace) _debug2 _signature "$_signature" - rm -rf "$YC_SA_Key_File" + if [ "$_yc_key_is_temp" ]; then + rm -f "$YC_SA_Key_File" + fi _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") _debug2 _jwt "$_jwt" @@ -264,7 +300,7 @@ _yc_login() { _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" _debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)" - YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')" _debug3 YC_Token return 0 diff --git a/dnsapi/dns_zonomi.sh b/dnsapi/dns_zonomi.sh index ee817381..c783a221 100644 --- a/dnsapi/dns_zonomi.sh +++ b/dnsapi/dns_zonomi.sh @@ -5,9 +5,11 @@ Site: zonomi.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_zonomi Options: ZM_Key API Key +OptionsAlt: + ZM_Api API endpoint. Default: "https://zonomi.com/app/dns/dyndns.jsp". For RimuHosting use "https://rimuhosting.com/dns/dyndns.jsp". ' -ZM_Api="https://zonomi.com/app/dns/dyndns.jsp" +ZM_Api_Default="https://zonomi.com/app/dns/dyndns.jsp" ######## Public functions ##################### @@ -28,6 +30,8 @@ dns_zonomi_add() { #save the api key to the account conf file. _saveaccountconf_mutable ZM_Key "$ZM_Key" + _zm_init_api + _info "Get existing txt records for $fulldomain" if ! _zm_request "action=QUERY&name=$fulldomain"; then _err "error" @@ -64,11 +68,27 @@ dns_zonomi_rm() { return 1 fi + _zm_init_api + _zm_request "action=DELETE&type=TXT&name=$fulldomain" } #################### Private functions below ################################## + +# resolve the API endpoint: zonomi by default, overridable for providers +# sharing the same API on another host (e.g. RimuHosting) +_zm_init_api() { + ZM_Api="${ZM_Api:-$(_readaccountconf_mutable ZM_Api)}" + if [ -z "$ZM_Api" ]; then + ZM_Api="$ZM_Api_Default" + fi + _debug2 ZM_Api "$ZM_Api" + if [ "$ZM_Api" != "$ZM_Api_Default" ]; then + _saveaccountconf_mutable ZM_Api "$ZM_Api" + fi +} + #qstr _zm_request() { qstr="$1" diff --git a/notify/aws_ses.sh b/notify/aws_ses.sh index 07e0c48c..735e6204 100644 --- a/notify/aws_ses.sh +++ b/notify/aws_ses.sh @@ -83,7 +83,43 @@ aws_ses_send() { response="$(aws_rest POST "" "" "$_data")" } +_use_container_role() { + # automatically set if running inside ECS + if [ -z "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" ]; then + _debug "No ECS environment variable detected" + return 1 + fi + _use_metadata "169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" +} + +_use_instance_role() { + _instance_role_name_url="http://169.254.169.254/latest/meta-data/iam/security-credentials/" + + if _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 401; then + _debug "Using IMDSv2" + _token_url="http://169.254.169.254/latest/api/token" + export _H1="X-aws-ec2-metadata-token-ttl-seconds: 21600" + _token="$(_post "" "$_token_url" "" "PUT")" + _secure_debug3 "_token" "$_token" + if [ -z "$_token" ]; then + _debug "Unable to fetch IMDSv2 token from instance metadata" + return 1 + fi + export _H1="X-aws-ec2-metadata-token: $_token" + fi + + if ! _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 200; then + _debug "Unable to fetch IAM role from instance metadata" + return 1 + fi + + _instance_role_name=$(_get "$_instance_role_name_url" "" 1) + _debug "_instance_role_name" "$_instance_role_name" + _use_metadata "$_instance_role_name_url$_instance_role_name" "$_token" +} + _use_metadata() { + export _H1="X-aws-ec2-metadata-token: $2" _aws_creds="$( _get "$1" "" 1 | _normalizeJson | diff --git a/notify/customscript.sh b/notify/customscript.sh new file mode 100644 index 00000000..ba8b07cb --- /dev/null +++ b/notify/customscript.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh + +# Support calling a custom script for notifications +# +# export CUSTOMSCRIPT_PATH="/usr/local/bin/acme-notification.sh" +# +# The script is called with three arguments: +# $1 subject +# $2 content +# $3 status code (0: success, 1: error, 2: skipped) + +customscript_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + CUSTOMSCRIPT_PATH="${CUSTOMSCRIPT_PATH:-$(_readaccountconf_mutable CUSTOMSCRIPT_PATH)}" + if [ -z "$CUSTOMSCRIPT_PATH" ]; then + _err "You didn't specify the custom script path CUSTOMSCRIPT_PATH yet." + return 1 + fi + if ! _exists "$CUSTOMSCRIPT_PATH"; then + _err "The custom script $CUSTOMSCRIPT_PATH does not exist or is not executable." + return 1 + fi + _saveaccountconf_mutable CUSTOMSCRIPT_PATH "$CUSTOMSCRIPT_PATH" + + # Invoke directly, never through eval: the subject and content contain + # domain names and CA messages, eval would allow command injection. + _customscript_result="$("$CUSTOMSCRIPT_PATH" "$_subject" "$_content" "$_statusCode" 2>&1)" + _customscript_rc="$?" + _debug2 "_customscript_result" "$_customscript_result" + + if [ "$_customscript_rc" != "0" ]; then + _err "custom script execution error ($_customscript_rc): $_customscript_result" + return 1 + fi + + _info "custom script executed successfully." + return 0 +} diff --git a/notify/smtp.sh b/notify/smtp.sh index f5ebebca..a7318692 100644 --- a/notify/smtp.sh +++ b/notify/smtp.sh @@ -200,6 +200,7 @@ _smtp_send_curl() { set -- "$@" \ --upload-file - \ + --crlf \ --mail-from "$SMTP_FROM" \ --max-time "$SMTP_TIMEOUT" diff --git a/notify/waha.sh b/notify/waha.sh new file mode 100755 index 00000000..573295e9 --- /dev/null +++ b/notify/waha.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env sh + +#Support WAHA (WhatsApp HTTP API) - free, self-hosted WhatsApp API +#https://waha.devlike.pro/ + +#Required: +#WAHA_URL="http://localhost:3000" +#WAHA_CHAT_ID="1234567890@c.us" + +#Optional: +#WAHA_API_KEY="" +#WAHA_SESSION="default" + +waha_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + WAHA_URL="${WAHA_URL:-$(_readaccountconf_mutable WAHA_URL)}" + if [ -z "$WAHA_URL" ]; then + WAHA_URL="" + _err "You didn't specify the WAHA server url WAHA_URL yet." + _err "Example: export WAHA_URL=\"http://localhost:3000\"" + return 1 + fi + _saveaccountconf_mutable WAHA_URL "$WAHA_URL" + + WAHA_CHAT_ID="${WAHA_CHAT_ID:-$(_readaccountconf_mutable WAHA_CHAT_ID)}" + if [ -z "$WAHA_CHAT_ID" ]; then + WAHA_CHAT_ID="" + _err "You didn't specify the WhatsApp chat id WAHA_CHAT_ID yet." + _err "Example: export WAHA_CHAT_ID=\"1234567890@c.us\"" + return 1 + fi + _saveaccountconf_mutable WAHA_CHAT_ID "$WAHA_CHAT_ID" + + WAHA_API_KEY="${WAHA_API_KEY:-$(_readaccountconf_mutable WAHA_API_KEY)}" + if [ "$WAHA_API_KEY" ]; then + _saveaccountconf_mutable WAHA_API_KEY "$WAHA_API_KEY" + fi + + WAHA_SESSION="${WAHA_SESSION:-$(_readaccountconf_mutable WAHA_SESSION)}" + if [ -z "$WAHA_SESSION" ]; then + WAHA_SESSION="default" + else + _saveaccountconf_mutable WAHA_SESSION "$WAHA_SESSION" + fi + + _content=$(printf "*%s*\n%s" "$_subject" "$_content" | _json_encode) + + _data="{\"chatId\": \"$WAHA_CHAT_ID\", " + _data="$_data\"text\": \"$_content\", " + _data="$_data\"session\": \"$WAHA_SESSION\"}" + + _debug "_data" "$_data" + + if [ "$WAHA_API_KEY" ]; then + export _H1="X-Api-Key: $WAHA_API_KEY" + fi + + _waha_url="${WAHA_URL}/api/sendText" + response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")" + + if [ "$?" = "0" ] && _contains "$response" "\"id\""; then + _info "waha send success." + return 0 + fi + _err "waha send error." + _err "$response" + return 1 +}