Compare commits

...
301 Commits
148 changed files with 14013 additions and 1506 deletions
+50
View File
@@ -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
+392 -27
View File
@@ -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,23 @@ 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}}"
@@ -721,3 +792,297 @@ jobs:
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}}"
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"
HardenedBSD:
runs-on: ubuntu-latest
needs: OpenEuler
env:
TEST_DNS : ${{ secrets.TEST_DNS }}
TestingDomain: ${{ secrets.TestingDomain }}
TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }}
TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }}
TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }}
CASE: le_test_dnsapi
TEST_LOCAL: 1
DEBUG: ${{ secrets.DEBUG }}
http_proxy: ${{ secrets.http_proxy }}
https_proxy: ${{ secrets.https_proxy }}
TokenName1: ${{ secrets.TokenName1}}
TokenName2: ${{ secrets.TokenName2}}
TokenName3: ${{ secrets.TokenName3}}
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/hardenedbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg install -y socat curl
usesh: true
sync: nfs
run: |
if [ "${{ secrets.TokenName1}}" ] ; then
export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}"
fi
if [ "${{ secrets.TokenName2}}" ] ; then
export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}"
fi
if [ "${{ secrets.TokenName3}}" ] ; then
export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}"
fi
if [ "${{ secrets.TokenName4}}" ] ; then
export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}"
fi
if [ "${{ secrets.TokenName5}}" ] ; then
export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}"
fi
cd ../acmetest
./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
OPNsense:
runs-on: ubuntu-latest
needs: HardenedBSD
env:
TEST_DNS : ${{ secrets.TEST_DNS }}
TestingDomain: ${{ secrets.TestingDomain }}
TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }}
TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }}
TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }}
CASE: le_test_dnsapi
TEST_LOCAL: 1
DEBUG: ${{ secrets.DEBUG }}
http_proxy: ${{ secrets.http_proxy }}
https_proxy: ${{ secrets.https_proxy }}
TokenName1: ${{ secrets.TokenName1}}
TokenName2: ${{ secrets.TokenName2}}
TokenName3: ${{ secrets.TokenName3}}
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/opnsense-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
#The dns-01 cases need no inbound port, so the appliance's web GUI can
#keep the 80 port here, unlike the standalone workflow.
prepare: pkg install -y socat curl
usesh: true
sync: nfs
run: |
if [ "${{ secrets.TokenName1}}" ] ; then
export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}"
fi
if [ "${{ secrets.TokenName2}}" ] ; then
export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}"
fi
if [ "${{ secrets.TokenName3}}" ] ; then
export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}"
fi
if [ "${{ secrets.TokenName4}}" ] ; then
export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}"
fi
if [ "${{ secrets.TokenName5}}" ] ; then
export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}"
fi
cd ../acmetest
./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+1
View File
@@ -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"
+1
View File
@@ -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"
+83
View File
@@ -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"
+1
View File
@@ -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"
+76
View File
@@ -0,0 +1,76 @@
name: HardenedBSD
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/HardenedBSD.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/HardenedBSD.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
HardenedBSD:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
ACME_USE_WGET: 1
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/hardenedbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
prepare: pkg install -y socat curl wget
usesh: true
sync: nfs
run: |
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+76
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+66
View File
@@ -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
+86
View File
@@ -0,0 +1,86 @@
name: OPNsense
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/OPNsense.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/OPNsense.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
OPNsense:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
ACME_USE_WGET: 1
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/opnsense-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
prepare: pkg install -y socat curl wget
usesh: true
sync: nfs
run: |
#OPNsense is a firewall appliance whose web GUI holds the 80 port,
#where every --standalone case listens. configd has no "stop"
#action for it and the rc script cannot stop it either, so kill it.
#This belongs here and not in prepare: prepare runs before the
#cache-after-prepare reboot, which would bring the GUI back. And do
#NOT free the port by disabling the GUI's http redirect in
#config.xml: pf's automatic pass rule for the 80 port is generated
#from the web GUI settings, so dropping the redirect also drops the
#rule on the next boot, and the inbound challenge is filtered.
pkill lighttpd || true
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+1
View File
@@ -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"
+1
View File
@@ -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"
+78
View File
@@ -0,0 +1,78 @@
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: |
# openEuler ships every repo with both a baseurl and a metalink.
# The metalink mirror pool is split-brain (dl-cdn.openeuler.openatom.cn
# froze at the 2026-08-20 snapshot while repo.openeuler.org moved on),
# so dnf takes repomd.xml from the stale mirror and then 404s fetching
# the checksummed metadata it names from the fresh ones. Keep only the
# vendor baseurl, which is self-consistent.
sed -i '/^metalink=/d' /etc/yum.repos.d/*.repo
dnf install -y curl socat cronie tar gzip
usesh: true
sync: rsync
copyback: false
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"
+1
View File
@@ -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"
+9 -1
View File
@@ -31,13 +31,21 @@ jobs:
Le_HTTPPort: 5002
TEST_LOCAL: 1
TEST_CA: "Pebble Intermediate CA"
TEST_DNS_MANUAL: 1
steps:
- uses: actions/checkout@v6
- name: Install tools
run: sudo apt-get install -y socat
- name: Run Pebble
run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d
run: |
cd ..
curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml
# Pebble reuses a valid authorization in a new order 50% of the time
# by default, which makes the dns manual mode case a coin flip: a
# reused authorization leaves nothing for the TXT record to answer.
printf 'services:\n pebble:\n environment:\n PEBBLE_AUTHZREUSE: "0"\n' >docker-compose.override.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: Clone acmetest
+1
View File
@@ -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"
+80
View File
@@ -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"
+114
View File
@@ -0,0 +1,114 @@
name: Blacklist Command
# An issue titled "blacklist: <login-or-email>" 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 <fork>.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"
});
+61 -5
View File
@@ -10,9 +10,16 @@ on:
- '**.sh'
- "Dockerfile"
- '.github/workflows/dockerhub.yml'
# A dispatch on a tag ref rebuilds that tag's own image; the weekly
# schedule (default branch only) dispatches the latest release tag so a
# pinned version tag picks up Alpine package security updates (issue 7209).
# master never publishes anything but latest.
schedule:
- cron: '17 3 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -40,24 +47,30 @@ jobs:
build:
runs-on: ubuntu-latest
needs: CheckToken
if: "contains(needs.CheckToken.outputs.hasToken, 'true')"
if: "github.event_name != 'schedule' && contains(needs.CheckToken.outputs.hasToken, 'true')"
permissions:
contents: read
packages: write
steps:
- name: checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
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 +86,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 +99,44 @@ 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"
rebuild:
# weekly: dispatch this workflow on the latest release tag so the tag
# rebuilds its own image from its own commit and its own workflow file
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
permissions:
contents: read
actions: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: dispatch a rebuild of the latest release tag
run: |
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
if [ -z "$tag" ]; then
echo "::error::cannot resolve the latest release tag"
exit 1
fi
echo "dispatching a rebuild of ${tag}"
# A tag cut before this job existed carries a workflow file with no
# workflow_dispatch trigger; the API rejects the dispatch with 422.
# That is expected (nothing to rebuild there), so only a different
# error fails the job.
if ! out="$(gh workflow run dockerhub.yml --repo "${GITHUB_REPOSITORY}" --ref "${tag}" 2>&1)"; then
echo "$out"
case "$out" in
*"does not have 'workflow_dispatch' trigger"*)
echo "::warning::${tag} predates the dispatch trigger; skipping the rebuild"
;;
*)
exit 1
;;
esac
fi
+115 -5
View File
@@ -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."
})
+1 -1
View File
@@ -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({
+1 -1
View File
@@ -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({
+110
View File
@@ -0,0 +1,110 @@
name: Revert Command
# An issue titled "revert: <wiki-commit-sha>" 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 <fork>.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"
});
+67
View File
@@ -0,0 +1,67 @@
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.
# The job mirrors first and then fails when the pushed tag is lightweight,
# which is what the release form produces: that tag can never carry a
# signature, so the failure has to be loud.
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 }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# Mirror the object the pushed tag actually points at: the commit
# for a lightweight tag, the tag object itself for an annotated or
# signed one. Pointing the mirror at the commit would strip the
# signature, so "git verify-tag v3.1.3" would fail while
# "git verify-tag 3.1.3" succeeds.
_ref="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq '.object.sha + " " + .object.type')"
sha="${_ref%% *}"
objtype="${_ref##* }"
if [ -z "$sha" ] || [ "$sha" = "null" ]; then
echo "Could not resolve refs/tags/$TAG"
exit 1
fi
if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v$TAG already exists, nothing to do."
else
gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
echo "Created tag v$TAG -> $sha"
fi
- name: Check that the tag is signable
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# A release published from the GitHub UI creates the tag server-side
# as a lightweight ref, which points straight at a commit and can
# never carry a signature (3.1.5 shipped that way, see issue 7273).
# The tag has to be created locally with "git tag -s" and pushed
# BEFORE the release is published, then selected on the release form.
objtype="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.type)"
if [ "$objtype" != "tag" ]; then
echo "::error::refs/tags/$TAG points at a $objtype, not a tag object. It is a lightweight tag and carries no signature. Recreate it locally with 'git tag -s $TAG' and push it before publishing the release."
exit 1
fi
echo "refs/tags/$TAG is a tag object."
+325
View File
@@ -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 <fork>.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
+20 -4
View File
@@ -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
+8
View File
@@ -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.
+2 -2
View File
@@ -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 \
+150 -16
View File
@@ -1,7 +1,21 @@
<p align="center">
<a href="https://zerossl.com/?fromacme.sh">
<img src="https://github.com/user-attachments/assets/7531085e-399b-4ac2-82a2-90d14a0b7f05" alt="zerossl.com">
</a>
<a href="https://zerossl.com?utm_source=acme-sh">
<picture>
<!-- Dark mode -->
<source
media="(prefers-color-scheme: dark)"
srcset="https://github.com/user-attachments/assets/1308516b-e0cc-496d-b5df-e3932423ead6" />
<!-- Light mode -->
<source
media="(prefers-color-scheme: light)"
srcset="https://github.com/user-attachments/assets/4ba7a79e-8cc9-4d49-87fc-02d44fb7b043" />
<!-- Fallback for environments without media queries -->
<img
alt="ZeroSSL"
src="https://github.com/user-attachments/assets/4ba7a79e-8cc9-4d49-87fc-02d44fb7b043"
height="auto" />
</picture>
</a>
</p>
<h1 align="center">🔐 acme.sh</h1>
@@ -17,9 +31,15 @@
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Solaris.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Solaris.yml/badge.svg" alt="Solaris"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/DragonFlyBSD.yml/badge.svg" alt="DragonFlyBSD"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/MidnightBSD.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/MidnightBSD.yml/badge.svg" alt="MidnightBSD"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg" alt="GhostBSD"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Omnios.yml/badge.svg" alt="Omnios"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenIndiana.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenIndiana.yml/badge.svg" alt="OpenIndiana"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg" alt="Tribblix"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg" alt="Haiku"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg" alt="Hurd"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg" alt="OpenEuler"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg" alt="HardenedBSD"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg" alt="OPNsense"></a>
</p>
<p align="center">
@@ -112,6 +132,12 @@
|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
|30|[![HardenedBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml)|HardenedBSD
|31|[![OPNsense](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml)|OPNsense
> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
@@ -146,6 +172,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/)) |
---
@@ -204,6 +231,31 @@ Cron entry example:
acme.sh -h
```
#### 🔏 Verify a Release
Release tags from `3.1.6` on are signed with the maintainer's SSH key. The
signing happens on the maintainer's machine, so the private key is never
available to CI. The public half is [`allowed_signers`](allowed_signers) in
this repository. From a clone:
```bash
git config gpg.ssh.allowedSignersFile allowed_signers
```
```bash
git verify-tag 3.1.6
```
The signature covers the tag object, which pins the commit and therefore the
whole tree, so a good signature verifies every file at that release and no
separate tarball checksum is needed. Build a tarball from the verified tag:
```bash
git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
```
> ⚠️ Tags up to `3.1.5` are unsigned.
---
### 2️⃣ Issue a Certificate
@@ -396,7 +448,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 <ca>` | 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 <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 <N>` | Adds `persistUntil=<unix-timestamp>` 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 +525,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 +537,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 +553,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=<certID>`** 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 +603,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 +627,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 +662,8 @@ This project exists thanks to all the people who contribute.
<a href="https://github.com/acmesh-official/acme.sh/graphs/contributors"><img src="https://opencollective.com/acmesh/contributors.svg?width=890&button=false" /></a>
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 +689,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 +699,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 +711,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 <strong>ZeroSSL</strong> as part of our commitment to providing secure and reliable SSL/TLS solutions. We welcome contributions and feedback from the community!
+1838 -253
View File
File diff suppressed because it is too large Load Diff
+341
View File
@@ -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 <<EOF
$(printf '%s\n' "$1" | tr ' ' '\n')
EOF
return 0
}
_acme_sh_files() {
local _file
while IFS= read -r _file; do
[ -n "$_file" ] || continue
COMPREPLY=("${COMPREPLY[@]}" "$_file")
done <<EOF
$(compgen -f -- "$cur")
EOF
if command -v compopt >/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 <<EOF
$(compgen -d -- "$cur")
EOF
if command -v compopt >/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 "<domain>.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
+22
View File
@@ -0,0 +1,22 @@
# acme.sh release signing key.
#
# Release tags are signed with this key. Its private half is held by the
# maintainer and is never available to CI, so a compromise of the build
# pipeline cannot produce a tag that verifies against this file.
#
# Signing starts at 3.1.6; tags up to 3.1.5 are unsigned.
#
# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE
#
# To verify a release tag, from a clone of this repository:
#
# git config gpg.ssh.allowedSignersFile allowed_signers
# git verify-tag 3.1.6
#
# A good signature covers the tag object, which pins the commit, which pins
# the whole tree -- so verifying the tag verifies every file at that
# release. Build a tarball from the verified tag with:
#
# git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
#
github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB
+222
View File
@@ -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
}
+2 -2
View File
@@ -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."
+16 -3
View File
@@ -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
+43 -3
View File
@@ -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
+175
View File
@@ -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 <<EOF
{
"type": "regular",
"scope": "global",
"certname": "$_fortigate_cert_name",
"key_file_content": "$_fortigate_key_base64",
"file_content": "$_fortigate_cert_base64"
}
EOF
)
_fortigate_url="https://${FGT_HOST}:${FGT_PORT}/api/v2/monitor/vpn-certificate/local/import"
_debug "Uploading certificate via URL: $_fortigate_url"
_H1="Authorization: Bearer $FGT_TOKEN"
_fortigate_response=$(_post "$_fortigate_payload" "$_fortigate_url" "" "POST" "application/json")
_debug "FortiGate API Response: $_fortigate_response"
_fortigate_parse_response "$_fortigate_response" "Deploying certificate" || return 1
}
# Function to upload a CA certificate to the firewall
# FortiGate does not automatically extract the CA from the full chain.
_fortigate_upload_ca_cert() {
_fortigate_ca_base64=$(_base64 <"$_fortigate_cca" | tr -d '\n')
_fortigate_payload=$(
cat <<EOF
{
"import_method": "file",
"scope": "global",
"file_content": "$_fortigate_ca_base64"
}
EOF
)
_fortigate_url="https://${FGT_HOST}:${FGT_PORT}/api/v2/monitor/vpn-certificate/ca/import"
_debug "Uploading CA certificate via URL: $_fortigate_url"
_H1="Authorization: Bearer $FGT_TOKEN"
_fortigate_response=$(_post "$_fortigate_payload" "$_fortigate_url" "" "POST" "application/json")
_debug "FortiGate API CA Response: $_fortigate_response"
# FortiGate error -328 means that the CA certificate already exists.
if echo "$_fortigate_response" | grep -q '"error":[ ]*-328'; then
_debug "CA certificate already exists. Skipping CA upload."
return 0
fi
_fortigate_parse_response "$_fortigate_response" "Deploying CA certificate" || return 1
}
# Function to activate the new certificate
_fortigate_set_active_web_cert() {
_fortigate_payload=$(
cat <<EOF
{
"admin-server-cert": "$_fortigate_cert_name"
}
EOF
)
_fortigate_url="https://${FGT_HOST}:${FGT_PORT}/api/v2/cmdb/system/global"
_debug "Setting GUI certificate..."
_H1="Authorization: Bearer $FGT_TOKEN"
_fortigate_response=$(_post "$_fortigate_payload" "$_fortigate_url" "" "PUT" "application/json")
_fortigate_parse_response "$_fortigate_response" "Assigning active certificate" || return 1
}
# Function to clean up the previously deployed certificate
_fortigate_cleanup_previous_certificate() {
_getdeployconf FGT_LAST_CERT
if [ -n "$FGT_LAST_CERT" ] && [ "$FGT_LAST_CERT" != "$_fortigate_cert_name" ]; then
_debug "Found previously deployed certificate: $FGT_LAST_CERT. Deleting it."
_fortigate_url="https://${FGT_HOST}:${FGT_PORT}/api/v2/cmdb/vpn.certificate/local/${FGT_LAST_CERT}"
_H1="Authorization: Bearer $FGT_TOKEN"
_fortigate_response=$(_post "" "$_fortigate_url" "" "DELETE" "application/json")
_debug "Delete certificate API response: $_fortigate_response"
_fortigate_parse_response "$_fortigate_response" "Delete previous certificate" || return 1
else
_debug "No previous certificate found."
fi
}
# Main deploy-hook function
fortigate_deploy() {
# Include date and time to ensure unique names.
_fortigate_cert_name="$(echo "$1" | sed 's/*/WILDCARD_/g')_$(date -u +"%Y-%m-%d_%H-%M-%S")"
_fortigate_ckey="$2"
_fortigate_cca="$4"
_fortigate_cfullchain="$5"
if [ ! -f "$_fortigate_ckey" ] || [ ! -f "$_fortigate_cfullchain" ]; then
_err "Valid key and/or certificate not found."
return 1
fi
# Save required environment variables if set; otherwise load saved values.
for _fortigate_var in FGT_HOST FGT_TOKEN FGT_PORT; do
if [ -n "$(eval echo "\$$_fortigate_var")" ]; then
_debug "Detected ENV variable $_fortigate_var. Saving to file."
_savedeployconf "$_fortigate_var" "$(eval echo "\$$_fortigate_var")" 1
else
_debug "Attempting to load variable $_fortigate_var from file."
_getdeployconf "$_fortigate_var"
fi
done
if [ -z "$FGT_HOST" ] || [ -z "$FGT_TOKEN" ]; then
_err "FGT_HOST and FGT_TOKEN must be set."
return 1
fi
FGT_PORT="${FGT_PORT:-443}"
_debug "Using FortiGate port: $FGT_PORT"
# Upload the new certificate.
_fortigate_deployer || return 1
# Upload the CA certificate.
if [ -n "$_fortigate_cca" ] && [ -f "$_fortigate_cca" ]; then
_fortigate_upload_ca_cert || return 1
else
_debug "No CA certificate provided."
fi
# Activate the new certificate.
_fortigate_set_active_web_cert || return 1
# Delete the previously deployed certificate only after successful activation.
_fortigate_cleanup_previous_certificate || return 1
# Save the new certificate name for cleanup during the next deployment.
_savedeployconf "FGT_LAST_CERT" "$_fortigate_cert_name" 1
}
+2 -2
View File
@@ -74,9 +74,9 @@ fritzbox_deploy() {
_info "Log in to the FRITZ!Box"
_fritzbox_challenge="$(_get "${DEPLOY_FRITZBOX_URL}/login_sid.lua" | sed -e 's/^.*<Challenge>//' -e 's/<\/Challenge>.*$//')"
if _exists iconv; then
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF16LE | _digest md5 hex)"
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF-16LE | _digest md5 hex)"
elif _exists uconv; then
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF16LE | _digest md5 hex)"
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF-16LE | _digest md5 hex)"
else
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | perl -p -e 'use Encode qw/encode/; print encode("UTF-16LE","$_"); $_="";' | _digest md5 hex)"
fi
+1 -1
View File
@@ -57,7 +57,7 @@ gcore_cdn_deploy() {
_request="{\"username\":\"$Le_Deploy_gcore_cdn_username\",\"password\":\"$Le_Deploy_gcore_cdn_password\"}"
_debug _request "$_request"
export _H1="Content-Type:application/json"
_response=$(_post "$_request" "https://api.gcore.com/auth/jwt/login")
_response=$(_post "$_request" "https://api.gcore.com/iam/auth/jwt/login")
_debug _response "$_response"
_regex=".*\"access\":\"\([-._0-9A-Za-z]*\)\".*$"
_debug _regex "$_regex"
+64 -56
View File
@@ -43,7 +43,8 @@
# needing to reload HAProxy. Default is "no".
#
# Require the socat binary. DEPLOY_HAPROXY_STATS_SOCKET variable uses the socat
# address format.
# address format. The certificate can be deployed to a comma separated ',' list
# of hosts ("TCP4:10.0.0.1:1999,TCP4:10.0.0.2:1999")
#
# export DEPLOY_HAPROXY_MASTER_CLI="UNIX:/run/haproxy-master.sock"
#
@@ -172,10 +173,7 @@ haproxy_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_haproxy_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"
@@ -193,7 +191,6 @@ haproxy_deploy() {
_issuer="${_pem}.issuer"
_ocsp="${_pem}.ocsp"
_reload="${Le_Deploy_haproxy_reload}"
_statssock="${Le_Deploy_haproxy_stats_socket}"
_info "Deploying PEM file"
# Create a temporary PEM file
@@ -272,12 +269,18 @@ haproxy_deploy() {
_cafile_argument=""
fi
_debug _cafile_argument "${_cafile_argument}"
# if OpenSSL/LibreSSL is v1.1 or above, the format for the -header option has changed
# OpenSSL 1.1+ expects -header Host=value (one argument), while
# LibreSSL keeps the old two-argument form -header Host value at any
# version (3.x/4.x), so it must be detected by name, not by number.
_openssl_name=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f1)
_openssl_version=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f2)
_debug _openssl_name "${_openssl_name}"
_debug _openssl_version "${_openssl_version}"
_openssl_major=$(echo "${_openssl_version}" | cut -d '.' -f1)
_openssl_minor=$(echo "${_openssl_version}" | cut -d '.' -f2)
if [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then
if [ "${_openssl_name}" = "LibreSSL" ]; then
_header_sep=" "
elif [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then
_header_sep="="
else
_header_sep=" "
@@ -327,62 +330,67 @@ haproxy_deploy() {
# Update certificate over HAProxy stats socket or master CLI.
if _exists socat; then
# look for the certificate on the stats socket, to chose between updating or creating one
_socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'"
_debug _socat_cert_cmd "${_socat_cert_cmd}"
eval "${_socat_cert_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_newcert="1"
_info "Creating new certificate '${_pem}' over HAProxy ${_socketname}."
# certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate.
_socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'"
_debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}"
eval "${_socat_crtlist_show_cmd}"
IFS=','
for _statssock in ${Le_Deploy_haproxy_stats_socket}; do
# look for the certificate on the stats socket, to choose between updating or creating one
_socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'"
_debug _socat_cert_cmd "${_socat_cert_cmd}"
eval "${_socat_cert_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'"
return "${_ret}"
_newcert="1"
_info "Creating new certificate '${_pem}' over HAProxy ${_socketname}."
# certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate.
_socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'"
_debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}"
eval "${_socat_crtlist_show_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'"
return "${_ret}"
fi
# create a new certificate
_socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'"
_debug _socat_new_cmd "${_socat_new_cmd}"
eval "${_socat_new_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Couldn't create '${_pem}' in haproxy"
return "${_ret}"
fi
else
_info "Update existing certificate '${_pem}' over HAProxy ${_socketname}."
fi
# create a new certificate
_socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'"
_debug _socat_new_cmd "${_socat_new_cmd}"
eval "${_socat_new_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Couldn't create '${_pem}' in haproxy"
return "${_ret}"
fi
else
_info "Update existing certificate '${_pem}' over HAProxy ${_socketname}."
fi
_socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'"
_secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}"
eval "${_socat_cert_set_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't update '${_pem}' in haproxy"
return "${_ret}"
fi
_socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'"
_debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}"
eval "${_socat_cert_commit_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't commit '${_pem}' in haproxy"
return ${_ret}
fi
if [ "${_newcert}" = "1" ]; then
# if this is a new certificate, it needs to be inserted into the crt-list`
_socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'"
_debug _socat_cert_add_cmd "${_socat_cert_add_cmd}"
eval "${_socat_cert_add_cmd}"
# printf %b, not "echo -e": dash's echo has no -e and sends a literal "-e " to the socket.
# "Transaction updated" is replied instead of "created" when an uncommitted transaction exists.
_socat_cert_set_cmd="printf '%b\n' '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -qE 'Transaction (created|updated)'"
_secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}"
eval "${_socat_cert_set_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't update '${_pem}' in haproxy"
return "${_ret}"
fi
fi
_socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'"
_debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}"
eval "${_socat_cert_commit_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't commit '${_pem}' in haproxy"
return ${_ret}
fi
if [ "${_newcert}" = "1" ]; then
# if this is a new certificate, it needs to be inserted into the crt-list`
_socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'"
_debug _socat_cert_add_cmd "${_socat_cert_add_cmd}"
eval "${_socat_cert_add_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't update '${_pem}' in haproxy"
return "${_ret}"
fi
fi
done
else
_err "'socat' is not available, couldn't update over ${_socketname}"
fi
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env sh
# Here is a script to deploy cert to ikuai using curl
#
# it requires following environment variables:
#
# IKUAI_SCHEME="http" - http or https , defaults to "http"
# IKUAI_HOSTNAME="localhost" - host , defaults to "192.168.9.1"
# IKUAI_PORT="80" - port , defaults to "80"
# IKUAI_USERNAME="admin" - username , defaults to "admin"
# IKUAI_PASSWORD="yourPassword" - password
# IKUAI_CERT_ID=1 - ikuai cert id , defaults to 1, and only 1 is supported for now !!!
#
#returns 0 means success, otherwise error.
#
######## Public functions #####################
#
#domain keyfile certfile cafile fullchain
ikuai_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"
# Get deploy conf
_getdeployconf IKUAI_SCHEME
_getdeployconf IKUAI_HOSTNAME
_getdeployconf IKUAI_PORT
_getdeployconf IKUAI_USERNAME
_getdeployconf IKUAI_PASSWORD
_getdeployconf IKUAI_CERT_ID
# Use default if not provided
[ -n "$IKUAI_SCHEME" ] || IKUAI_SCHEME="http"
[ -n "$IKUAI_HOSTNAME" ] || IKUAI_HOSTNAME="192.168.9.1"
[ -n "$IKUAI_PORT" ] || IKUAI_PORT=80
[ -n "$IKUAI_USERNAME" ] || IKUAI_USERNAME="admin"
[ -n "$IKUAI_CERT_ID" ] || IKUAI_CERT_ID=1
if [ -z "$IKUAI_PASSWORD" ]; then
_err "please define IKUAI_PASSWORD."
return 1
fi
_debug2 IKUAI_SCHEME "$IKUAI_SCHEME"
_debug2 IKUAI_HOSTNAME "$IKUAI_HOSTNAME"
_debug2 IKUAI_PORT "$IKUAI_PORT"
_debug2 IKUAI_USERNAME "$IKUAI_USERNAME"
_secure_debug2 IKUAI_PASSWORD "$IKUAI_PASSWORD"
_info "Login to ikuai ..."
_ikuai_url="$IKUAI_SCHEME://$IKUAI_HOSTNAME:$IKUAI_PORT"
_pass_md5="$(printf "%s" "$IKUAI_PASSWORD" | _digest md5 hex | _lower_case)"
_pass_salt="$(printf "salt_11%s" "$IKUAI_PASSWORD" | _base64)"
_debug2 _ikuai_url "$_ikuai_url"
_login_req="{\"username\":\"$IKUAI_USERNAME\",\"passwd\":\"$_pass_md5\",\"pass\":\"$_pass_salt\",\"remember_password\":\"\"}"
_response=$(_post "$_login_req" "$_ikuai_url/Action/login" "" "POST" "application/json")
_err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)"
# check ErrMsg
if [ "$_err_msg" != "Success" ]; then
_err "Failed to login to ikuai: $_err_msg"
return 1
fi
# check cookie
_cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2 | sed 's/;.*//')"
if [ -z "$_cookie" ]; then
_err "Fail to get the cookie."
return 1
fi
# Set cookie header
_H1="Cookie: $_cookie; username=$IKUAI_USERNAME; login=1"
_info "Deploy the cert to ikuai ... "
# Should replace \n to @ ," " to #
_cert_content_single_line="$(tr <"$_cfullchain" '\n' '@' | tr ' ' '#')"
_key_content_single_line="$(tr <"$_ckey" '\n' '@' | tr ' ' '#')"
_debug2 _cert_content_single_line "$_cert_content_single_line"
_secure_debug2 _key_content_single_line "$_key_content_single_line"
_key_manager_req="{\"func_name\":\"key_manager\",\"action\":\"save\",\"param\":{\"ca\":\"$_cert_content_single_line\",\"key\":\"$_key_content_single_line\",\"id\":$IKUAI_CERT_ID,\"enabled\":\"yes\",\"comment\":\"\"}}"
_response=$(_post "$_key_manager_req" "$_ikuai_url/Action/call" "" "POST" "application/json")
_err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)"
# check ErrMsg
if [ "$_err_msg" != "Success" ]; then
_err "Failed to deploy the cert to ikuai: $_err_msg"
return 1
fi
_info "Save the deploy config ... "
# Save the config
_savedeployconf IKUAI_SCHEME "$IKUAI_SCHEME"
_savedeployconf IKUAI_HOSTNAME "$IKUAI_HOSTNAME"
_savedeployconf IKUAI_PORT "$IKUAI_PORT"
_savedeployconf IKUAI_USERNAME "$IKUAI_USERNAME"
_savedeployconf IKUAI_PASSWORD "$IKUAI_PASSWORD"
_savedeployconf IKUAI_CERT_ID "$IKUAI_CERT_ID"
_info "Successfully deployed certificate to ikuai. Enjoy! :>"
return 0
}
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env sh
# Script to deploy a certificate to a JetKVM (https://jetkvm.com) KVM-over-IP
# device over SSH. See also:
# https://github.com/acmesh-official/acme.sh/wiki/deployhooks
#
# JetKVM only supports key-based SSH authentication (root@<device>, password
# logins are disabled) once "Developer Mode" is enabled and a public key is
# pasted into its web UI (Settings > Advanced). SSH keys must already be
# exchanged and a passwordless login confirmed working (e.g. `ssh
# root@jetkvm.example.com true`) before using this hook.
#
# JetKVM's minimal userspace does not ship an scp binary or SFTP server, so
# unlike deploy/ssh.sh this hook has no "use scp" option: it always writes
# the certificate and key by piping a small POSIX shell script to the
# remote "sh" over stdin (only depends on "sh", "cat", "chmod", "mkdir",
# "mv" and "rm" on the device side). The remote path, filenames and file
# permissions are firmware constants on this single-purpose, single-root
# appliance, so they are not configurable here.
#
# JetKVM's "Custom" TLS mode (device web UI: Settings > Network > HTTPS
# Mode, must already be set to "Custom" before this hook's uploads take
# effect) reads the certificate/key from that fixed location and does not
# hot-reload: a device reboot is required to pick up a new certificate.
# This hook's restart command therefore defaults to "reboot" -- a blank
# DEPLOY_JETKVM_RESTART_CMD is treated the same as unset (falls back to
# "reboot") rather than silently skipping it, since a renewed certificate
# that's never actually applied defeats the point of automating this; set
# it to the literal value "none" to opt out and apply/verify manually.
# The restart command is run detached on the device (nohup ... &) so this
# hook's ssh call can return before the reboot itself lands, rather than
# racing the connection teardown.
#
# The certificate and key are staged under fixed temporary names on the
# device and only renamed into their final names (an atomic "mv", on the
# same filesystem) once both have been fully written and chmod'ed. This
# keeps a dropped connection or a failed write from ever leaving the
# device with a truncated or mismatched certificate/key pair for its own
# HTTPS listener, and a "trap ... EXIT" in the generated script removes
# any leftover staged file however that script exits.
#
# Before writing anything, this hook also checks that the device's HTTPS
# Mode is already "Custom" -- uploading a certificate that mode won't
# even serve would otherwise be a silent no-op. There is currently no
# documented/headless way to read this back (JetKVM's own JSON-RPC
# getTLSState/setTLSState calls require an authenticated WebRTC session,
# see https://github.com/jetkvm/kvm/issues/1240 and the still-open
# https://github.com/jetkvm/kvm/pull/1515), so this greps the device's
# own config file instead: JetKVM's firmware (see web_tls.go / config.go
# in https://github.com/jetkvm/kvm) persists the mode as the plain-JSON
# field "tls_mode" (values "", "self-signed", or "custom") in
# /userdata/kvm_config.json.
#
# None of the above (storage path, filenames, config file, reboot-to-apply
# behavior) is part of JetKVM's stable/documented API; it was confirmed
# against real JetKVM hardware, but is worth a spot-check after a JetKVM
# firmware upgrade -- set DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE=no to skip the
# HTTPS-mode check entirely if a future firmware version changes that
# file's format out from under it.
#
# The following variables exported from environment will be used. If not
# set then values previously saved in the domain.conf file are used. All
# of them are optional.
#
# export DEPLOY_JETKVM_USER="root" # defaults to "root"
# export DEPLOY_JETKVM_HOST="jetkvm.example.com" # defaults to the cert's domain
# export DEPLOY_JETKVM_PORT="22" # defaults to 22
# export DEPLOY_JETKVM_SSH_CMD="ssh -T" # defaults to "ssh -T"
# export DEPLOY_JETKVM_RESTART_CMD="reboot" # defaults to "reboot"; set to "none" to skip it
# export DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE="yes" # defaults to "yes" (verify tls_mode=custom before upload); set to "no" to skip
#
# Example:
# ```sh
# export DEPLOY_JETKVM_HOST="192.168.1.50"
# acme.sh --deploy -d jetkvm.example.com --deploy-hook jetkvm
# ```
#
# returns 0 means success, otherwise error.
######## Public functions #####################
#domain keyfile certfile cafile fullchain
jetkvm_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 [ ! -s "$_ckey" ] || [ ! -s "$_cfullchain" ]; then
_err "JetKVM deploy needs both a private key and a fullchain certificate (not available, e.g., after --signcsr)."
return 1
fi
_getdeployconf DEPLOY_JETKVM_USER
if [ -z "$DEPLOY_JETKVM_USER" ]; then
DEPLOY_JETKVM_USER="root"
fi
_savedeployconf DEPLOY_JETKVM_USER "$DEPLOY_JETKVM_USER"
_getdeployconf DEPLOY_JETKVM_HOST
if [ -z "$DEPLOY_JETKVM_HOST" ]; then
_debug "Using _cdomain as DEPLOY_JETKVM_HOST, please set if not correct."
DEPLOY_JETKVM_HOST="$_cdomain"
fi
_savedeployconf DEPLOY_JETKVM_HOST "$DEPLOY_JETKVM_HOST"
_getdeployconf DEPLOY_JETKVM_PORT
if [ -z "$DEPLOY_JETKVM_PORT" ]; then
DEPLOY_JETKVM_PORT="22"
fi
_savedeployconf DEPLOY_JETKVM_PORT "$DEPLOY_JETKVM_PORT"
_getdeployconf DEPLOY_JETKVM_SSH_CMD
if [ -z "$DEPLOY_JETKVM_SSH_CMD" ]; then
DEPLOY_JETKVM_SSH_CMD="ssh -T"
fi
_savedeployconf DEPLOY_JETKVM_SSH_CMD "$DEPLOY_JETKVM_SSH_CMD" "base64"
_getdeployconf DEPLOY_JETKVM_RESTART_CMD
if [ -z "$DEPLOY_JETKVM_RESTART_CMD" ]; then
DEPLOY_JETKVM_RESTART_CMD="reboot"
fi
_savedeployconf DEPLOY_JETKVM_RESTART_CMD "$DEPLOY_JETKVM_RESTART_CMD" "base64"
_getdeployconf DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE
if [ -z "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE" ]; then
DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE="yes"
fi
_savedeployconf DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE"
_info "Deploying certificate to JetKVM device $DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST:$DEPLOY_JETKVM_PORT"
# Firmware constants on a single-purpose, single-root appliance -- not
# user configuration. If JetKVM ever moves these, that's a hook update,
# not a setting (a saved-per-domain override would just as easily hide
# the fix from anyone already using this hook).
_jetkvm_remote_path="/userdata/jetkvm/tls"
_jetkvm_cert_name="user-defined.crt"
_jetkvm_key_name="user-defined.key"
_jetkvm_config_file="/userdata/kvm_config.json"
_jetkvm_mode_exitcode=3
_jetkvm_config_missing_exitcode=4
_jetkvm_run_id="$$.$(_time)"
_jetkvm_cert_marker="ACME_JETKVM_CERT_$_jetkvm_run_id"
_jetkvm_key_marker="ACME_JETKVM_KEY_$_jetkvm_run_id"
_jetkvm_cert_tmp="$_jetkvm_remote_path/.$_jetkvm_cert_name.tmp"
_jetkvm_key_tmp="$_jetkvm_remote_path/.$_jetkvm_key_name.tmp"
_jetkvm_cert_target="$_jetkvm_remote_path/$_jetkvm_cert_name"
_jetkvm_key_target="$_jetkvm_remote_path/$_jetkvm_key_name"
# Command substitution strips all trailing newlines, so the printf below
# always emits the content with exactly one trailing newline before the
# heredoc terminator -- regardless of whether the source file already
# ended with one -- so the terminator is guaranteed to start its own line.
_jetkvm_cert_content="$(cat "$_cfullchain")"
_jetkvm_key_content="$(cat "$_ckey")"
_jetkvm_upload_script="$(
echo "#!/bin/sh"
echo "set -e"
echo "umask 077"
printf "trap \"rm -f '%s' '%s'\" EXIT\n" "$_jetkvm_cert_tmp" "$_jetkvm_key_tmp"
if [ "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE" != "no" ]; then
# Uploading a certificate that HTTPS Mode won't even serve would
# otherwise fail silently -- see the header comment for why this
# greps the device's own config file rather than querying it
# through a documented API (there isn't one for reading this
# headlessly yet). The config file is checked for readability
# separately so a missing/renamed file isn't misreported as
# HTTPS Mode being wrong.
printf "if [ ! -r '%s' ]; then exit %s; fi\n" "$_jetkvm_config_file" "$_jetkvm_config_missing_exitcode"
printf 'if ! grep -q '\''"tls_mode" *: *"custom"'\'' '\''%s'\''; then exit %s; fi\n' "$_jetkvm_config_file" "$_jetkvm_mode_exitcode"
fi
printf "mkdir -p '%s'\n" "$_jetkvm_remote_path"
printf "cat > '%s' <<'%s'\n" "$_jetkvm_cert_tmp" "$_jetkvm_cert_marker"
printf '%s\n' "$_jetkvm_cert_content"
echo "$_jetkvm_cert_marker"
printf "chmod 0644 '%s'\n" "$_jetkvm_cert_tmp"
printf "cat > '%s' <<'%s'\n" "$_jetkvm_key_tmp" "$_jetkvm_key_marker"
printf '%s\n' "$_jetkvm_key_content"
echo "$_jetkvm_key_marker"
printf "chmod 0600 '%s'\n" "$_jetkvm_key_tmp"
printf "mv '%s' '%s'\n" "$_jetkvm_cert_tmp" "$_jetkvm_cert_target"
printf "mv '%s' '%s'\n" "$_jetkvm_key_tmp" "$_jetkvm_key_target"
)"
_secure_debug "Generated upload script" "$_jetkvm_upload_script"
_info "Connecting to JetKVM device $DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST:$DEPLOY_JETKVM_PORT to deploy certificate"
# shellcheck disable=SC2086
printf '%s\n' "$_jetkvm_upload_script" | $DEPLOY_JETKVM_SSH_CMD -p "$DEPLOY_JETKVM_PORT" "$DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST" sh
_ret=$?
if [ "$_ret" = "$_jetkvm_config_missing_exitcode" ]; then
_err "JetKVM config file ($_jetkvm_config_file) was not found or not readable on the device -- this hook's assumptions may be out of date after a firmware upgrade. Certificate was NOT uploaded."
return "$_ret"
fi
if [ "$_ret" = "$_jetkvm_mode_exitcode" ]; then
_err "JetKVM HTTPS Mode is not set to \"Custom\" (checked \"tls_mode\" in $_jetkvm_config_file on the device). Set it in the device's web UI (Settings > Network > HTTPS Mode) before this hook can take effect. Certificate was NOT uploaded."
return "$_ret"
fi
if [ "$_ret" != "0" ]; then
_err "Error code $_ret returned uploading certificate to JetKVM device"
return "$_ret"
fi
_info "Certificate and key uploaded to $_jetkvm_remote_path on the device"
if [ "$DEPLOY_JETKVM_RESTART_CMD" = "none" ]; then
_info "Certificate successfully deployed to JetKVM device. DEPLOY_JETKVM_RESTART_CMD=none, skipping restart command."
return 0
fi
# Run the restart command detached (nohup ... &) so this ssh call
# returns as soon as it's launched, before the device actually reboots,
# rather than racing the connection teardown -- observed, against real
# hardware, that a reboot racing the SSH session's own exit can make
# ssh itself exit anywhere from a clean 0 to a connection-reset 255.
# Since the restart command then runs as an unwaited background job on
# the device, this ssh call reports success as soon as that job is
# launched -- it does NOT confirm nohup, sh, or the restart command
# itself actually exist or succeed (measured: a nonexistent restart
# command, and even a missing nohup binary, both still return 0 here).
# Only an outright SSH connection failure (unreachable host, auth
# failure, etc.) is caught below. "sleep" runs on the device's own
# shell, not acme.sh's, so acme.sh's _sleep wrapper does not apply.
_info "Running post-upload command on JetKVM device: $DEPLOY_JETKVM_RESTART_CMD"
# Escape any single quotes in the (user-configurable, free-text)
# restart command before nesting it inside the outer 'sleep N; ...'
# single-quoted string -- otherwise a value like "sh -c 'sync; reboot'"
# breaks the quoting and only part of it ends up inside the detached
# background job.
_jetkvm_restart_cmd_escaped=$(printf '%s' "$DEPLOY_JETKVM_RESTART_CMD" | sed "s/'/'\\\\''/g")
_jetkvm_detached_cmd="nohup sh -c 'sleep 2; $_jetkvm_restart_cmd_escaped' >/dev/null 2>&1 &"
# shellcheck disable=SC2086
if ! $DEPLOY_JETKVM_SSH_CMD -p "$DEPLOY_JETKVM_PORT" "$DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST" "$_jetkvm_detached_cmd"; then
_err "Certificate was uploaded, but connecting to the JetKVM device to launch the restart command failed."
return 1
fi
_info "Certificate deployed to JetKVM device; it will restart shortly to apply it."
return 0
}
+2 -2
View File
@@ -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 class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_message=$(echo "$_response" | sed -n '/<div class="message-body ">/,/<\/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 class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_message=$(echo "$_response" | sed -n '/<div class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_info "_message" "$_message"
if [ -z "$_message" ]; then
_err "Fail to apply certificate."
+1 -4
View File
@@ -121,10 +121,7 @@ lighttpd_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_lighttpd_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"
+20 -9
View File
@@ -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 "<deploy_file1>" "<deploy_file2>?"
_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
}
+3 -1
View File
@@ -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
}
+14 -3
View File
@@ -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
+17 -10
View File
@@ -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
}
+17 -10
View File
@@ -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
}
+2 -1
View File
@@ -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=\\\"\\\";\
+2 -2
View File
@@ -175,7 +175,7 @@ _get_unleashed_version() {
_post_upload() {
_post_action="$1"
_post_file="$2"
_post_upfile="$2"
_post_boundary="----FormBoundary$(date "+%s%N")"
@@ -183,7 +183,7 @@ _post_upload() {
printf -- "--%s\r\n" "$_post_boundary"
printf -- "Content-Disposition: form-data; name=\"u\"; filename=\"%s\"\r\n" "$_post_action"
printf -- "Content-Type: application/octet-stream\r\n\r\n"
printf -- "%s\r\n" "$(cat "$_post_file")"
printf -- "%s\r\n" "$(cat "$_post_upfile")"
printf -- "--%s\r\n" "$_post_boundary"
printf -- "Content-Disposition: form-data; name=\"action\"\r\n\r\n"
+280
View File
@@ -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 <method> <params_json>
# 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
}
+39 -12
View File
@@ -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() {
@@ -207,8 +232,6 @@ _ssh_deploy() {
do if [ -d \"\$fn\" ] && [ \"\$(expr \$now - \$(date -ur \$fn +%s) )\" -ge \"15552000\" ]; \
then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; done; }; $_cmdstr"
# Alternate version of above... _cmdstr="find $_backupprefix* -type d -mtime +180 2>/dev/null | xargs rm -rf; $_cmdstr"
# Create our backup directory for overwritten cert files.
_cmdstr="mkdir -p $_backupdir; $_cmdstr"
_info "Backup of old certificate files will be placed in remote directory $_backupdir"
_info "Backup directories erased after 180 days."
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
@@ -222,7 +245,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
if [ -n "$DEPLOY_SSH_KEYFILE" ]; then
if [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_KEYFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_KEYFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_KEYFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -259,7 +282,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_CERTFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_CERTFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CERTFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -300,7 +323,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_CAFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_CAFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CAFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -345,8 +368,8 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_FULLCHAIN $_backupdir >/dev/null;"
if [ "$DEPLOY_SSH_FULLCHAIN" = "yes" ]; then
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_FULLCHAIN ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_FULLCHAIN $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
fi
@@ -428,9 +451,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
+21 -22
View File
@@ -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"
+518
View File
@@ -0,0 +1,518 @@
#!/usr/bin/env sh
# shellcheck disable=SC2016
# TrueNAS deploy script for SCALE/CORE using websocket (websocat binary)
# It is recommend to use a wildcard certificate
#
# Tested with TrueNAS SCALE 25.10 (API "wss://host/api/current", JSON-RPC 2.0).
#
# Unlike "truenas_ws" hook, this script does NOT use midclt, the truenas_api_client Python package.
# It only depends on:
# - jq
# - websocat (a static binary you deploy)
#
# Why: avoids installing a Python environment / TrueNAS package on remote machine just to push a certificate.
#
# IMPORTANT: This script is written in pure POSIX sh (no coproc, no bash arrays).
#
#
# ---------------------------------------------------------------------------
# Environment variables
# ---------------------------------------------------------------------------
#
# # Use the folowing URL to create a new API token: <TRUENAS_HOSTNAME OR IP>/ui/apikeys
#
# Required:
# export DEPLOY_TRUENAS_APIKEY="<API_KEY_GENERATED_IN_THE_WEB_UI>"
#
# Optional:
# export DEPLOY_TRUENAS_HOSTNAME="<TRUENAS_HOSTNAME_OR_IP>" (required on first run)
# export DEPLOY_TRUENAS_PROTOCOL="ws" # ws or wss (default: ws)
# export DEPLOY_TRUENAS_PORT="80" # 80, 443, 8443 (default: 80)
# NOTE: defaults are intentionally "ws"/80, not "wss"/443: a freshly
# installed TrueNAS serves its Web UI over plain HTTP on port 80 out
# of the box, and port 443 is not listening until HTTPS is configured.
# Port 80 stays reachable even after HTTPS is enabled, so this keeps
# the hook working on first run without extra setup.
# export DEPLOY_TRUENAS_UPDATE_FTP="no" # yes or no (default: no) also updates the FTP certificate
# export DEPLOY_TRUENAS_UPDATE_APPS="no" # yes or no (default: no) also updates the certificate for any
# iX app exposing a "certificate_id" option.
# WARNING: this redeploys (restarts) every matching app.
# ---------------------------------------------------------------------------
########################
### Public functions ###
########################
# truenas_websocat_deploy
#
# Deploy new certificate to TrueNAS services with websocat binary
#
# Arguments
# 1: Domain
# 2: Key-File
# 3: Certificate-File
# 4: CA-File
# 5: FullChain-File
# Returns:
# 0: Success
# 1: Missing or invalid API Key
# 2: TrueNAS not ready (health check failed)
# 3: (reserved)
# 4: FTP & iX App cert error
# 5: WebUI cert error
# 6: Certificate creation job error
# 7: Websocat / transport call error (socket write/read failed)
# 8: Missing binary or invalid configuration
# 9: TrueNAS API returned an explicit error (JSON-RPC .error field)
truenas_websocat_deploy() {
_jq_bin=$(command -v jq 2>/dev/null)
if [ -z "$_jq_bin" ]; then
_err "jq binary not found in PATH. Install it using your system's package manager."
return 8
fi
_websocat_bin=$(command -v websocat 2>/dev/null)
if [ -z "$_websocat_bin" ]; then
_err "websocat binary not found in PATH. Install it using your system's package manager, or download a static binary from https://github.com/vi/websocat/releases."
return 8
fi
_domain="$1"
_file_key="$2"
_file_cert="$3"
_file_cca="$4"
_file_fullchain="$5"
_debug _domain "$_domain"
_debug _file_key "$_file_key"
_debug _file_cert "$_file_cert"
_debug _file_ca "$_file_cca"
_debug _file_fullchain "$_file_fullchain"
if [ ! -x "$_jq_bin" ]; then
_err "Binary not found or not executable: $_jq_bin"
return 8
fi
if [ ! -x "$_websocat_bin" ]; then
_err "Binary not found or not executable: $_websocat_bin"
return 8
fi
### ---- Configuration ----
_info "Checking environment variables..."
_getdeployconf DEPLOY_TRUENAS_APIKEY
_getdeployconf DEPLOY_TRUENAS_HOSTNAME
_getdeployconf DEPLOY_TRUENAS_PROTOCOL
_getdeployconf DEPLOY_TRUENAS_PORT
_getdeployconf DEPLOY_TRUENAS_UPDATE_FTP
_getdeployconf DEPLOY_TRUENAS_UPDATE_APPS
# Check API Key
if [ -z "$DEPLOY_TRUENAS_APIKEY" ]; then
_err "TrueNAS API key not found, please set the DEPLOY_TRUENAS_APIKEY environment variable."
return 1
fi
# Check Hostname, default to localhost if not set
if [ -z "$DEPLOY_TRUENAS_HOSTNAME" ]; then
_info "TrueNAS hostname not set. Using 'localhost'."
DEPLOY_TRUENAS_HOSTNAME="localhost"
fi
# Check protocol, default to ws if not set: a freshly installed TrueNAS serves its Web UI over plain HTTP, so wss/443 is not available out of the box.
# Use DEPLOY_TRUENAS_PROTOCOL="wss" once HTTPS is configured, since the payload otherwise carries the API key and private key in plain text.
if [ -z "$DEPLOY_TRUENAS_PROTOCOL" ]; then
_info "TrueNAS protocol not set. Using 'ws'."
DEPLOY_TRUENAS_PROTOCOL="ws"
fi
# Check port, default to 80 if not set (see protocol comment above)
if [ -z "$DEPLOY_TRUENAS_PORT" ]; then
_info "TrueNAS port not set. Using '80'."
DEPLOY_TRUENAS_PORT="80"
fi
case "$DEPLOY_TRUENAS_PORT" in
'' | *[!0-9]*)
_err "Invalid TrueNAS port '$DEPLOY_TRUENAS_PORT'. DEPLOY_TRUENAS_PORT must be numeric."
return 8
;;
esac
_truenas_websocat_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME:$DEPLOY_TRUENAS_PORT/api/current"
# Check FTP update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_FTP" ]; then
_info "Certificate update for FTP is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_FTP="no"
fi
# Check Apps update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_APPS" ]; then
_info "Certificate update for Apps is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_APPS="no"
fi
_debug2 DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_debug2 DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_debug2 DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_debug2 DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_debug _truenas_websocat_uri "$_truenas_websocat_uri"
_secure_debug2 DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_info "Environment variables: OK"
### ---- Persistent WebSocket connection (FIFOs, sh/dash compatible) ----
#
# Authentication is tied to the WebSocket connection:
# the SAME connection must stay open from login until the end, otherwise every subsequent call comes back unauthenticated.
# We use two FIFOs + `exec` to talk to a background websocat process, without relying on bash-only extensions.
_websocat_tmpdir=$(mktemp -d /tmp/truenas_websocat.XXXXXX) || {
_err "mktemp failed"
return 3
}
_websocat_fifo_in="${_websocat_tmpdir}/in"
_websocat_fifo_out="${_websocat_tmpdir}/out"
mkfifo "$_websocat_fifo_in" "$_websocat_fifo_out" || {
_err "mkfifo failed"
rm -rf "$_websocat_tmpdir"
return 3
}
"$_websocat_bin" -n -k "$_truenas_websocat_uri" <"$_websocat_fifo_in" >"$_websocat_fifo_out" 2>"${_websocat_tmpdir}/err.log" &
_websocat_pid=$!
# Opening "in" for read+write avoids a deadlock if websocat hasn't opened the fifo for reading yet at the time we write to it.
exec 3<>"$_websocat_fifo_in"
exec 4<"$_websocat_fifo_out"
sleep 1
if ! kill -0 "$_websocat_pid" 2>/dev/null; then
_err "websocat exited prematurely."
_err "$(cat "${_websocat_tmpdir}/err.log" 2>/dev/null)"
exec 3>&- 4<&-
rm -rf "$_websocat_tmpdir"
return 3
fi
_websocat_req_counter=0
_truenas_websocat_cleanup() {
exec 3>&- 2>/dev/null
exec 4<&- 2>/dev/null
[ -n "$_websocat_pid" ] && kill "$_websocat_pid" 2>/dev/null
rm -rf "$_websocat_tmpdir" 2>/dev/null
}
# _truenas_websocat_rpc_call <method> <json_params>
# Does NOT log the payload/response: some calls (certificate.create, core.get_jobs) contain the certificate and private key in plain text, which would massively bloat the logs.
_truenas_websocat_rpc_call() {
_truenas_websocat_method="$1"
_truenas_websocat_params="$2"
_websocat_req_counter=$((_websocat_req_counter + 1))
_req_id="$_websocat_req_counter"
_truenas_websocat_payload=$("$_jq_bin" -c -n \
--arg jsonrpc "2.0" \
--arg id "$_req_id" \
--arg method "$_truenas_websocat_method" \
--argjson params "$_truenas_websocat_params" \
'{jsonrpc: $jsonrpc, id: $id, method: $method, params: $params}')
printf '%s\n' "$_truenas_websocat_payload" >&3 || {
_err "Socket write failed (method: $_truenas_websocat_method)"
return 7
}
IFS= read -r _truenas_websocat_response <&4 || {
_err "Socket read failed (method: $_truenas_websocat_method)"
return 7
}
printf '%s' "$_truenas_websocat_response"
}
# _truenas_websocat_rpc_has_error <response> <label> -> returns 0 (and prints) if an error was found, 1 otherwise
_truenas_websocat_rpc_has_error() {
_msg=$(printf '%s' "$1" | "$_jq_bin" -r '.error.message // empty' 2>/dev/null)
if [ -n "$_msg" ]; then
_err "RPC error ($2): $_msg"
return 0
fi
return 1
}
# Polls once per second and gives up after _TRUENAS_WEBSOCAT_JOB_TIMEOUT seconds (default 60s)
# if the job never leaves RUNNING/WAITING, so a stuck TrueNAS job cannot hang the deploy hook forever.
# NOTE: this same timeout also bounds app.update jobs when DEPLOY_TRUENAS_UPDATE_APPS=yes (iX App redeploy)
# raise _TRUENAS_WEBSOCAT_JOB_TIMEOUT if an app takes longer than that to redeploy (e.g. image pull, migrations).
_truenas_websocat_wait_for_job() {
_jobid="$1"
_truenas_websocat_job_elapsed=0
_truenas_websocat_job_timeout="${_TRUENAS_WEBSOCAT_JOB_TIMEOUT:-60}"
while true; do
if [ "$_truenas_websocat_job_elapsed" -ge "$_truenas_websocat_job_timeout" ]; then
_err "Job $_jobid: timed out after ${_truenas_websocat_job_timeout}s."
return 6
fi
sleep 1
_truenas_websocat_job_elapsed=$((_truenas_websocat_job_elapsed + 1))
_job_resp=$(_truenas_websocat_rpc_call "core.get_jobs" "[[[\"id\",\"=\",${_jobid}]]]") || return 6
if _truenas_websocat_rpc_has_error "$_job_resp" "core.get_jobs"; then return 6; fi
_state=$(printf '%s' "$_job_resp" | "$_jq_bin" -r '.result[0].state // empty')
case "$_state" in
SUCCESS)
printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].result'
return 0
;;
FAILED | ABORTED)
_err "Job $_jobid failed: $(printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].error')"
return 6
;;
"")
_err "Job $_jobid: unexpected response."
return 6
;;
esac
done
}
### ---- 1. Health check ----
_info "Testing connection to TrueNAS WebSocket at $_truenas_websocat_uri..."
_ping_resp=$(_truenas_websocat_rpc_call "core.ping" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ping_resp" "core.ping"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_ping_resp" | "$_jq_bin" -r '.result // empty')" != "pong" ]; then
_err "Health check failed (no pong received)."
_truenas_websocat_cleanup
return 2
fi
_info "Health check OK (pong received)."
### ---- 2. Authentication & Check ----
_info "Authenticating with API Key..."
_key_params=$("$_jq_bin" -c -n --arg k "$DEPLOY_TRUENAS_APIKEY" '[$k]')
_auth_resp=$(_truenas_websocat_rpc_call "auth.login_with_api_key" "$_key_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_auth_resp" "auth.login_with_api_key"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_auth_resp" | "$_jq_bin" -r '.result // empty')" != "true" ]; then
_err "Authentication failed (invalid API key?)."
_truenas_websocat_cleanup
return 1
fi
_info "Connected to TrueNAS ($_truenas_websocat_uri)."
_savedeployconf DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_savedeployconf DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_savedeployconf DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_savedeployconf DEPLOY_TRUENAS_PORT "$DEPLOY_TRUENAS_PORT"
_savedeployconf DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_savedeployconf DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_info "Checking TrueNAS system version..."
_ver_resp=$(_truenas_websocat_rpc_call "system.info" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ver_resp" "system.info"; then
_truenas_websocat_cleanup
return 9
fi
_sys_version=$(printf '%s' "$_ver_resp" | "$_jq_bin" -r '.result.version // .result // "Unknown"')
_info "TrueNAS System Version: $_sys_version"
### ---- 3. Read certificate files ----
_info "Reading certificate files for $_domain..."
if [ ! -f "$_file_fullchain" ] || [ ! -f "$_file_key" ]; then
_err "Certificate or key file not found."
_truenas_websocat_cleanup
return 5
fi
_cert_content=$("$_jq_bin" -sR . "$_file_fullchain")
_key_content=$("$_jq_bin" -sR . "$_file_key")
_safe_domain=$(echo "$_domain" | tr '*.' '_')
_cert_name="acme_${_safe_domain}_$(date +%Y%m%d_%H%M%S)"
_debug _certname "$_cert_name"
### ---- 4. Current Web UI certificate ----
_info "Retrieving current Web UI configuration..."
_config_resp=$(_truenas_websocat_rpc_call "system.general.config" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_config_resp" "system.general.config"; then
_truenas_websocat_cleanup
return 9
fi
_old_cert_id=$(printf '%s' "$_config_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
_info "Current Web UI Certificate ID: ${_old_cert_id:-None}"
### ---- 5. Import the new certificate (asynchronous job) ----
_info "Importing new certificate '$_cert_name'..."
_create_params=$("$_jq_bin" -n \
--arg name "$_cert_name" \
--argjson cert "$_cert_content" \
--argjson key "$_key_content" \
'[{create_type: "CERTIFICATE_CREATE_IMPORTED", name: $name, certificate: $cert, privatekey: $key}]')
_new_cert_resp=$(_truenas_websocat_rpc_call "certificate.create" "$_create_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_new_cert_resp" "certificate.create"; then
_truenas_websocat_cleanup
return 9
fi
_new_cert_jobid=$(printf '%s' "$_new_cert_resp" | "$_jq_bin" -r '.result // empty')
case "$_new_cert_jobid" in
'' | *[!0-9]*)
_err "Unexpected response from certificate.create (expected a job ID)."
_truenas_websocat_cleanup
return 6
;;
esac
_info "Import job certificate started (job ID: $_new_cert_jobid), waiting..."
_job_result=$(_truenas_websocat_wait_for_job "$_new_cert_jobid") || {
_truenas_websocat_cleanup
return 6
}
_new_cert_id=$(printf '%s' "$_job_result" | "$_jq_bin" -r '.id // empty')
if [ -z "$_new_cert_id" ]; then
_err "Could not retrieve the imported certificate's ID."
_truenas_websocat_cleanup
return 6
fi
_info "Certificate imported: '$_cert_name' (ID $_new_cert_id)."
### ---- 6. Assign to the Web UI ----
_info "Assigning certificate ID $_new_cert_id to Web UI..."
_update_resp=$(_truenas_websocat_rpc_call "system.general.update" "[{\"ui_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_update_resp" "system.general.update"; then
_truenas_websocat_cleanup
return 9
fi
_assigned_id=$(printf '%s' "$_update_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
if [ "$_assigned_id" != "$_new_cert_id" ]; then
_err "Failed to assign the certificate to the Web UI."
_truenas_websocat_cleanup
return 5
fi
_info "Restarting TrueNAS Web UI service..."
_restart_resp=$(_truenas_websocat_rpc_call "system.general.ui_restart" "[]")
_truenas_websocat_rpc_has_error "$_restart_resp" "system.general.ui_restart"
_info "Web UI certificate updated and UI restarted."
# Need TrueNas to sleep before perform other actions
_info "Waiting for Web UI restart."
sleep 5
### ---- 7. FTP (optional) ----
if [ "$DEPLOY_TRUENAS_UPDATE_FTP" = "yes" ]; then
_info "Sending certicate for FTP service..."
_ftp_resp=$(_truenas_websocat_rpc_call "ftp.update" "[{\"ssltls_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_ftp_resp" "ftp.update"; then
_err "Failed to update the FTP certificate."
_truenas_websocat_cleanup
return 4
else
_ftp_certid=$(printf '%s' "$_ftp_resp" | "$_jq_bin" -r '.result.ssltls_certificate // empty')
if [ "$_ftp_certid" = "$_new_cert_id" ]; then
_info "FTP certificate updated."
else
_err "FTP certificate: unexpected response."
_truenas_websocat_cleanup
return 4
fi
fi
fi
### ---- 8. iX Apps (optional - redeploys every matching app) ----
if [ "$DEPLOY_TRUENAS_UPDATE_APPS" = "yes" ]; then
_info "Sending certicate for iX Apps..."
_apps_resp=$(_truenas_websocat_rpc_call "app.query" "[]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_apps_resp" "app.query"; then
_err "Could not list apps."
_truenas_websocat_cleanup
return 4
else
for _app_name in $(printf '%s' "$_apps_resp" | "$_jq_bin" -r '.result[].name'); do
_app_cfg=$(_truenas_websocat_rpc_call "app.config" "[\"${_app_name}\"]") || {
_truenas_websocat_cleanup
return 4
}
_has_cert_opt=$(printf '%s' "$_app_cfg" | "$_jq_bin" -r '.result.network // {} | has("certificate_id")' 2>/dev/null)
if [ "$_has_cert_opt" = "true" ]; then
_info "Updating certificate for app '$_app_name' (this will redeploy it)..."
_app_update_resp=$(_truenas_websocat_rpc_call "app.update" "[\"${_app_name}\", {\"values\": {\"network\": {\"certificate_id\": ${_new_cert_id}}}}]") || {
_truenas_websocat_cleanup
return 4
}
_app_jobid=$(printf '%s' "$_app_update_resp" | "$_jq_bin" -r '.result // empty')
case "$_app_jobid" in
'' | *[!0-9]*)
_err "App '$_app_name': no job ID returned."
_truenas_websocat_cleanup
return 4
;;
*)
if ! _truenas_websocat_wait_for_job "$_app_jobid" >/dev/null; then
_err "App '$_app_name': update not confirmed."
_truenas_websocat_cleanup
return 4
fi
;;
esac
fi
done
fi
fi
### ---- 9. Delete the old certificate (non blocking) ----
if [ -n "$_old_cert_id" ] && [ "$_old_cert_id" != "$_new_cert_id" ] && [ "$_old_cert_id" != "null" ]; then
_del_resp=$(_truenas_websocat_rpc_call "certificate.delete" "[${_old_cert_id}]")
if ! _truenas_websocat_rpc_has_error "$_del_resp" "certificate.delete"; then
_del_jobid=$(printf '%s' "$_del_resp" | "$_jq_bin" -r '.result // empty')
case "$_del_jobid" in
'' | *[!0-9]*) : ;;
*)
_truenas_websocat_wait_for_job "$_del_jobid" >/dev/null || _info "Old certificate: deletion not confirmed ."
;;
esac
fi
fi
_truenas_websocat_cleanup
_info "TrueNAS deployment completed successfully."
return 0
}
+37 -17
View File
@@ -16,7 +16,12 @@
#
# # API KEY
# # Use the folowing URL to create a new API token: <TRUENAS_HOSTNAME OR IP>/ui/apikeys
# export DEPLOY_TRUENAS_APIKEY="<API_KEY_GENERATED_IN_THE_WEB_UI"
# export DEPLOY_TRUENAS_APIKEY="<API_KEY_GENERATED_IN_THE_WEB_UI>"
# Optional:
# export DEPLOY_TRUENAS_HOSTNAME="<TRUENAS_HOSTNAME_OR_IP>"
# export DEPLOY_TRUENAS_PROTOCOL="wss" # ws or wss
# export DEPLOY_TRUENAS_PORT="443" # optional, e.g. 80, 443, 8443
#
### Private functions
@@ -38,15 +43,15 @@ _ws_call() {
_debug "_ws_call arg1" "$1"
_debug "_ws_call arg2" "$2"
_debug "_ws_call arg3" "$3"
if [ $# -eq 3 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1" "$2" "$3")
fi
if [ $# -eq 2 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1" "$2")
fi
if [ $# -eq 1 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1")
fi
# TrueNAS 26.0.0-BETA3 and later need a --plain option for midclt call, detect if it is available
_midclt_plain=""
case "$(midclt --help 2>/dev/null)" in
*--plain*) _midclt_plain="--plain" ;;
esac
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" $_midclt_plain call "$@")
_debug "_ws_response" "$_ws_response"
printf "%s" "$_ws_response"
return 0
@@ -56,7 +61,6 @@ _ws_call() {
_ws_upload_cert() {
/usr/bin/env python - <<EOF
import sys
from truenas_api_client import Client
@@ -78,7 +82,6 @@ with Client(uri="$_ws_uri") as c:
print("R:0")
print("E:_ws_upload_cert error!")
sys.exit(7)
EOF
return $?
@@ -181,6 +184,8 @@ truenas_ws_deploy() {
_getdeployconf DEPLOY_TRUENAS_APIKEY
_getdeployconf DEPLOY_TRUENAS_HOSTNAME
_getdeployconf DEPLOY_TRUENAS_PROTOCOL
_getdeployconf DEPLOY_TRUENAS_PORT
# Check API Key
if [ -z "$DEPLOY_TRUENAS_APIKEY" ]; then
_err "TrueNAS API key not found, please set the DEPLOY_TRUENAS_APIKEY environment variable."
@@ -196,7 +201,21 @@ truenas_ws_deploy() {
_info "TrueNAS protocol not set. Using 'ws'."
DEPLOY_TRUENAS_PROTOCOL="ws"
fi
_ws_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME/websocket"
# Check port, optional
if [ -n "$DEPLOY_TRUENAS_PORT" ]; then
case "$DEPLOY_TRUENAS_PORT" in
'' | *[!0-9]*)
_err "Invalid TrueNAS port '$DEPLOY_TRUENAS_PORT'. DEPLOY_TRUENAS_PORT must be numeric."
return 8
;;
esac
_ws_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME:$DEPLOY_TRUENAS_PORT/websocket"
else
_ws_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME/websocket"
fi
_debug2 DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_debug2 DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_debug _ws_uri "$_ws_uri"
@@ -216,13 +235,14 @@ truenas_ws_deploy() {
if [ "$_ws_response" != "TRUE" ]; then
_err "TrueNAS is not ready."
_err "Please check environment variables DEPLOY_TRUENAS_APIKEY, DEPLOY_TRUENAS_HOSTNAME and DEPLOY_TRUENAS_PROTOCOL."
_err "Please check environment variables DEPLOY_TRUENAS_APIKEY, DEPLOY_TRUENAS_HOSTNAME, DEPLOY_TRUENAS_PROTOCOL and DEPLOY_TRUENAS_PORT."
_err "Verify API key."
return 2
fi
_savedeployconf DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_savedeployconf DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_savedeployconf DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_savedeployconf DEPLOY_TRUENAS_PORT "$DEPLOY_TRUENAS_PORT"
_info "TrueNAS health: OK"
########## System info
@@ -236,8 +256,8 @@ truenas_ws_deploy() {
_info "Gather current WebUI certificate..."
_ws_response="$(_ws_call "system.general.config")"
_ui_certificate_id=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."id"')
_ui_certificate_name=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."name"')
_ui_certificate_id=$(printf "%s" "$_ws_response" | jq -r '.ui_certificate | if type == "object" then .id else . end')
_ui_certificate_name=$(printf "%s" "$_ws_response" | jq -r 'if (.ui_certificate | type) == "object" then .ui_certificate.name else .ui_certificate_name end')
_info "Current WebUI certificate ID: $_ui_certificate_id"
_info "Current WebUI certificate name: $_ui_certificate_name"
@@ -312,7 +332,7 @@ truenas_ws_deploy() {
_info "Replace WebUI certificate..."
_ws_response=$(_ws_call "system.general.update" "{\"ui_certificate\": $_new_certid}")
_changed_certid=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."id"')
_changed_certid=$(printf "%s" "$_ws_response" | jq -r '.ui_certificate | if type == "object" then .id else . end')
if [ "$_changed_certid" != "$_new_certid" ]; then
_err "WebUI certificate change error.."
return 5
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env sh
# Deploy hook for UniFi OS, via the certificate REST API.
#
# Works against any UniFi OS whose management UI exposes
# /api/userCertificates. Confirmed on:
# - UniFi OS Server (the separately-installed, self-hosted application)
# on macOS and on Linux. Windows should also work (it runs under
# WSL2), but has not been tested.
# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on
# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916).
# No lower version bound is claimed -- if the UI has a certificate
# manager, this hook should work.
#
# `unifios` vs `unifi`: the split is the access method, not the product
# line. `unifi` writes files / a Java keystore and needs local or SSH
# access on the device; this hook drives the same REST API the web UI
# uses and works remotely. Use `unifi` where acme.sh runs on the device
# itself, this hook where it does not.
#
# The API is served on the management port, which differs per install:
# UniFi OS Server listens on 11443 (hence the default below), while
# UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to
# "https://<host>" there.
#
# Endpoints used, all as the web UI itself calls them:
# POST /api/auth/login - session login (cookie + JWT)
# GET /api/userCertificates - list uploaded certificates
# POST /api/userCertificates - upload a new certificate
# DELETE /api/userCertificates/{id} - remove a certificate
# PUT /api/userCertificates/{id}/status - activate/deactivate a certificate
#
# This was reverse-engineered from the browser's Network tab while using the
# real GUI upload/activate/delete flow -- it is undocumented but is the same
# code path the UI uses, so it's far more robust than editing settings.yaml,
# http/local-certs.conf, or the underlying Postgres user_certificates table
# directly (all of which are also touched by this API, but only as a result
# of the app's own internal logic, which handles cert parsing, active-cert
# bookkeeping, and nginx config regeneration correctly on its own).
#
# Auth: POST /api/auth/login returns a `TOKEN` cookie containing a JWT whose
# payload has a `csrfToken` claim. That value must be echoed back as the
# `x-csrf-token` header on every subsequent state-changing request (a classic
# double-submit CSRF pattern). No other cookies were found to be necessary.
#
# Uses core acme.sh helpers throughout (_post/_get, _json_encode,
# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or
# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all
# honored the same as every other hook. The management API's cert may be
# self-signed -- it always is on a fresh install, and there is no reliable
# way to tell in advance whether an earlier run has already replaced it --
# so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see
# acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS
# verification for the rest of the acme.sh run, e.g. the connection to the
# ACME CA.
#
# Design: This hook does not save a certificate ID between renewals. Each
# upload gets a name unique to that run: the domain name plus a timestamp.
# This name never collides with an entry from a previous deploy. This is
# true even if that entry is still active. The hook uploads and activates
# the new certificate before it removes any old entries. If a failure
# occurs during this process, the server still has a valid, active
# certificate. The hook removes old entries only after activation is
# complete. It removes only entries whose name starts with the domain name,
# because this is the hook's own naming convention. As a result, this step
# can only affect entries that this hook created for this domain. It can
# never affect a certificate that a user uploaded manually, and it can
# never affect a self-signed certificate.
#
# Settings:
# DEPLOY_UNIFIOS_HOST - base URL of the management API
# (default: "https://localhost:11443", i.e. a UniFi OS Server on the
# same machine as acme.sh; set it to "https://<host>" for UniFi OS
# hardware or any remote target)
# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required)
# DEPLOY_UNIFIOS_PASSWORD - UniFi OS admin password (required)
#
# Example:
# export DEPLOY_UNIFIOS_USERNAME="acmeuser"
# export DEPLOY_UNIFIOS_PASSWORD="xxxxx"
# acme.sh --deploy -d example.com --deploy-hook unifios
#
# Please report bugs to https://github.com/acmesh-official/acme.sh/issues/7182
_uos_response_code() {
# tr strips the trailing newline along with form feeds; re-terminate
# before the second _egrep_o, whose sed fallback (used wherever egrep -o
# is unavailable) drops an unterminated final line on some platforms.
_uos_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")"
printf '%s\n' "$_uos_code" | _egrep_o "^[0-9][0-9]*"
}
_uos_response_cookie() {
# $1 = cookie name
grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1
}
_uos_split_json() {
# $1 = raw JSON list response
#
# _normalizeJson collapses the response to one line. This removes extra
# space around colons. It also removes any CR or LF characters that the
# server can add. However, _normalizeJson also removes the newline
# character at the end of the line. If the line has no ending newline
# character, some sed programs drop the last line of input. This code
# adds the newline back before the split below, to prevent that problem.
_uos_normalized="$(echo "$1" | _normalizeJson)"
# A literal newline character splits the JSON into one object per line.
# Grep can then match a single certificate entry at a time. This is not
# the two-character "\n" sequence: GNU sed reads "\n" in the replacement
# text as a newline character. POSIX does not define this behavior, and
# BSD sed prints "\n" as two literal characters, not as a newline.
printf '%s\n' "$_uos_normalized" | sed 's/},{/},\
{/g'
}
_uos_grep_literal() {
# $1 = literal text to find, matched without a regex -- portable to
# grep implementations with no -F flag (e.g. Solaris), and avoids "*"
# or "." in a domain name being read as a regex metacharacter.
while IFS= read -r _uos_line || [ -n "$_uos_line" ]; do
case "$_uos_line" in
*"$1"*) printf '%s\n' "$_uos_line" ;;
esac
done
}
unifios_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"
# Scoped to this hook's own subshell -- does not affect the rest of the
# acme.sh run (e.g. the connection to the ACME CA).
export HTTPS_INSECURE=1
_getdeployconf DEPLOY_UNIFIOS_HOST
DEPLOY_UNIFIOS_HOST="${DEPLOY_UNIFIOS_HOST:-https://localhost:11443}"
_savedeployconf DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST"
_debug DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST"
_getdeployconf DEPLOY_UNIFIOS_USERNAME
_getdeployconf DEPLOY_UNIFIOS_PASSWORD
if [ -z "$DEPLOY_UNIFIOS_USERNAME" ] || [ -z "$DEPLOY_UNIFIOS_PASSWORD" ]; then
_err "DEPLOY_UNIFIOS_USERNAME and DEPLOY_UNIFIOS_PASSWORD must be set."
return 1
fi
_debug DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME"
_secure_debug DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD"
_info "Logging in to UniFi OS Server API at $DEPLOY_UNIFIOS_HOST..."
# _json_encode always appends a trailing "\n" escape, even to input with
# no trailing newline (it normalizes via `echo`, unconditionally adding
# one). That's harmless for the key/cert file content below, which
# legitimately ends in a real newline anyway, but wrong for these plain
# strings -- strip the spurious escape it leaves behind.
_uos_user_json="$(printf '%s' "$DEPLOY_UNIFIOS_USERNAME" | _json_encode)"
_uos_user_json="${_uos_user_json%\\n}"
_uos_pass_json="$(printf '%s' "$DEPLOY_UNIFIOS_PASSWORD" | _json_encode)"
_uos_pass_json="${_uos_pass_json%\\n}"
_login_body="{\"username\":\"$_uos_user_json\",\"password\":\"$_uos_pass_json\",\"token\":\"\",\"rememberMe\":false}"
_login_json="$(_post "$_login_body" "$DEPLOY_UNIFIOS_HOST/api/auth/login" "" "POST" "application/json")"
_login_code="$(_uos_response_code)"
if [ "$_login_code" != "200" ]; then
_err "Login failed (HTTP $_login_code)."
_err "Response: $_login_json"
return 1
fi
# Credentials are proven correct now -- save them, rather than only at the
# very end, so a later step failing doesn't discard a working login.
# base64-encoded: _save_conf wraps values in single quotes with no
# escaping, so a literal "'" in the password would otherwise corrupt the
# domain conf (see deploy/synology_dsm.sh for the same pattern).
_savedeployconf DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" "base64"
_savedeployconf DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" "base64"
_uos_token="$(_uos_response_cookie TOKEN)"
if [ -z "$_uos_token" ]; then
_err "Login succeeded but no TOKEN cookie was returned."
return 1
fi
_H1="Cookie: $_uos_token"
export _H1
_uos_jwt_payload="$(echo "$_uos_token" | cut -d '=' -f 2- | cut -d '.' -f 2)"
_uos_csrf="$(_durl_replace_base64 "$_uos_jwt_payload" | _dbase64 | _egrep_o '"csrfToken":"[^"]*"' | cut -d '"' -f 4)"
if [ -z "$_uos_csrf" ]; then
_err "Could not extract csrfToken from session token."
return 1
fi
_H2="x-csrf-token: $_uos_csrf"
export _H2
_info "Uploading new certificate..."
# "name" is a purely cosmetic label -- the server never validates it
# against the certificate's actual CN/SAN, and accepts arbitrary text
# including spaces (confirmed: a cert for example.com served correctly
# after being uploaded under the unrelated name "totally unrelated label").
# The only constraint that matters here is uniqueness: the server rejects
# a second entry with a name it already has, so a bare domain name would
# collide with the previous deploy's entry on every renewal after the
# first. A full human-readable timestamp would make that obvious in the
# UI, but the certificate list's name column is fixed-width and doesn't
# wrap (confirmed against the real UI: a long name overlaps the Expires
# column and makes both unreadable), so keep the suffix short instead --
# Unix epoch seconds are still unique enough for this purpose.
#
# This name includes the key type (rsa or ecdsa), to keep an RSA
# deploy and an ECC deploy of the same domain from sharing this
# prefix. Without the key type, the cleanup step for each deploy
# removes the entry that the other deploy creates. `deploy/haproxy.sh`
# and `deploy/lighttpd.sh` use the same `_isEccKey` check, for the same
# reason.
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_uos_keytype="ecdsa"
else
_uos_keytype="rsa"
fi
_uos_name="$_cdomain $_uos_keytype $(_time)"
_uos_key_json="$(_json_encode <"$_ckey")"
_uos_cert_json="$(_json_encode <"$_cfullchain")"
_create_body="{\"name\":\"$_uos_name\",\"key\":\"$_uos_key_json\",\"cert\":\"$_uos_cert_json\"}"
_create_json="$(_post "$_create_body" "$DEPLOY_UNIFIOS_HOST/api/userCertificates" "" "POST" "application/json")"
_create_code="$(_uos_response_code)"
if [ "$_create_code" = "201" ]; then
_new_id="$(echo "$_create_json" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ -z "$_new_id" ]; then
_err "Could not determine new certificate ID from upload response."
return 1
fi
elif [ "$_create_code" = "400" ] && echo "$_create_json" | grep -q "USER_CERTIFICATE_DUPLICATE"; then
# HTTP 400 alone just means "bad request" -- it's the USER_CERTIFICATE_DUPLICATE
# code in the response body, checked above, that actually confirms this.
# The name above is unique to this run, so a duplicate here can only be
# the server's other uniqueness constraint: this exact certificate (by
# fingerprint) already exists as some other entry -- most likely a retry
# after a prior run already uploaded it (a real renewal always produces a
# new fingerprint, so this shouldn't happen in normal cron use). The
# response body doesn't include the existing entry's id, so look it up
# by fingerprint instead.
# The API's own fingerprint field is SHA-1 (20 bytes), not SHA-256 --
# confirmed against a real response, e.g.
# "fingerprint":"FC:02:50:9C:3B:3F:B7:79:9D:CA:4D:7C:AC:92:E7:D5:EA:F1:3A:29"
# (20 colon-separated groups). _fingerprint (core helper) strips the
# colons that field has, so re-insert them rather than stripping the
# JSON's own colons, which would also remove the ones separating every
# key from its value.
_uos_fingerprint="$(_fingerprint "$_cfullchain" sha1)"
if [ -z "$_uos_fingerprint" ]; then
_err "Could not compute the certificate's fingerprint."
return 1
fi
_uos_fingerprint="$(echo "$_uos_fingerprint" | sed 's/\(..\)/\1:/g; s/:$//')"
_list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")"
_list_code="$(_uos_response_code)"
if [ "$_list_code" != "200" ]; then
_err "Failed to list existing certificates (HTTP $_list_code)."
_err "Response: $_list_json"
return 1
fi
_list_json="$(_uos_split_json "$_list_json")"
_new_id="$(echo "$_list_json" | _uos_grep_literal "\"fingerprint\":\"$_uos_fingerprint\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ -z "$_new_id" ]; then
_err "Certificate upload rejected as a duplicate (server reported USER_CERTIFICATE_DUPLICATE), but no existing entry matching this fingerprint was found."
_err "Response: $_create_json"
return 1
fi
# Reusing the existing entry rather than deleting it and re-uploading
# under today's name+timestamp: the served content is identical either
# way, so replacing it would only cost an extra delete+create round trip
# for no functional benefit. The tradeoff is cosmetic -- this entry keeps
# whatever name it was given whenever it was originally uploaded, so it
# won't reflect today's date in the UI.
_info "Certificate already present as entry $_new_id; reusing it."
else
_err "Certificate upload failed (HTTP $_create_code)."
_err "Response: $_create_json"
return 1
fi
_info "Activating certificate $_new_id..."
_activate_json="$(_post '{"active":true}' "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_new_id/status" "" "PUT" "application/json")"
_activate_code="$(_uos_response_code)"
if [ "$_activate_code" != "200" ]; then
_err "Failed to activate new certificate (HTTP $_activate_code)."
_err "Response: $_activate_json"
return 1
fi
# UniFi OS Server activation is exclusive server-wide. Tests against the
# real API confirm this: activation of one entry deactivates whichever
# other entry was active before, no matter its name or domain. As a
# result, the server serves the certificate that this hook just activated.
# This certificate is already live. If the removal of old entries below
# fails, the hook logs the failure. The deploy does not fail because of
# this.
_info "Checking for old certificate entries to remove..."
_list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")"
_list_code="$(_uos_response_code)"
if [ "$_list_code" != "200" ]; then
_err "Failed to list certificates for cleanup (HTTP $_list_code) -- leaving old entries in place."
else
_list_json="$(_uos_split_json "$_list_json")"
# The pattern below matches the domain name and key type, followed by
# a space. If the space is missing, the pattern can also match a
# different domain that starts with the same text as this domain.
_old_ids="$(echo "$_list_json" | _uos_grep_literal "\"name\":\"$_cdomain $_uos_keytype " | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4 | grep -v "^$_new_id$")"
for _old_id in $_old_ids; do
_info "Removing old certificate entry $_old_id..."
_del_json="$(_post "" "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_old_id" "" "DELETE")"
_del_code="$(_uos_response_code)"
if [ "$_del_code" != "204" ] && [ "$_del_code" != "200" ]; then
_err "Failed to delete old certificate $_old_id (HTTP $_del_code) -- leaving it in place."
_err "Response: $_del_json"
fi
done
fi
_info "UniFi OS Server certificate deployed and activated successfully."
return 0
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env sh
# install a certificate on a Windows host over OpenSSH and bind it to the Remote
# Desktop listener (RDP-Tcp).
#
# One ssh invocation does the whole job:
# * the PFX is built locally, base64'd, and embedded as a string literal
# inside a generated PowerShell script;
# * the script is piped to `powershell.exe -Command -` over ssh. No scp,
# no temp files on the Windows host.
#
# First run:
# export DEPLOY_WIN_RDP_HOST=winserver.example.com
# acme.sh --deploy -d winserver.example.com --deploy-hook windows_rdp
#
# Available variables:
# DEPLOY_WIN_RDP_HOST required SSH host
# DEPLOY_WIN_RDP_USER optional SSH user, must be a local administrator (can also by set via ssh_config)
# DEPLOY_WIN_RDP_PORT optional SSH port, default 22
# DEPLOY_WIN_RDP_SSH_OPTS optional extra ssh options, e.g.
# "-i /root/.ssh/win_id_ed25519 -o StrictHostKeyChecking=yes"
# DEPLOY_WIN_RDP_LISTENER optional RDP listener name, default RDP-Tcp
# DEPLOY_WIN_RDP_RESTART optional "1" to restart TermService after install.
# Active RDP sessions will drop!
windows_rdp_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 ! _exists "ssh"; then
_err "ssh is required but was not found in PATH."
return 1
fi
# ---- configuration ------------------------------------------------------
_getdeployconf DEPLOY_WIN_RDP_HOST
_getdeployconf DEPLOY_WIN_RDP_USER
_getdeployconf DEPLOY_WIN_RDP_PORT
_getdeployconf DEPLOY_WIN_RDP_SSH_OPTS
_getdeployconf DEPLOY_WIN_RDP_LISTENER
_getdeployconf DEPLOY_WIN_RDP_RESTART
if [ -z "$DEPLOY_WIN_RDP_HOST" ]; then
_err "DEPLOY_WIN_RDP_HOST must be set."
return 1
fi
_savedeployconf DEPLOY_WIN_RDP_HOST "$DEPLOY_WIN_RDP_HOST"
[ -n "$DEPLOY_WIN_RDP_USER" ] && _savedeployconf DEPLOY_WIN_RDP_USER "$DEPLOY_WIN_RDP_USER"
[ -n "$DEPLOY_WIN_RDP_PORT" ] && _savedeployconf DEPLOY_WIN_RDP_PORT "$DEPLOY_WIN_RDP_PORT"
[ -n "$DEPLOY_WIN_RDP_SSH_OPTS" ] && _savedeployconf DEPLOY_WIN_RDP_SSH_OPTS "$DEPLOY_WIN_RDP_SSH_OPTS"
[ -n "$DEPLOY_WIN_RDP_LISTENER" ] && _savedeployconf DEPLOY_WIN_RDP_LISTENER "$DEPLOY_WIN_RDP_LISTENER"
[ -n "$DEPLOY_WIN_RDP_RESTART" ] && _savedeployconf DEPLOY_WIN_RDP_RESTART "$DEPLOY_WIN_RDP_RESTART"
_port="${DEPLOY_WIN_RDP_PORT:-22}"
_listener="${DEPLOY_WIN_RDP_LISTENER:-RDP-Tcp}"
if [ -n "$DEPLOY_WIN_RDP_USER" ]; then
_target="$DEPLOY_WIN_RDP_USER@$DEPLOY_WIN_RDP_HOST"
else
_target="$DEPLOY_WIN_RDP_HOST"
fi
_pfx_pass="acme"
# ---- build thumbprint + PFX locally ------------------------------------
_thumb="$(_fingerprint "$_ccert" 'sha1')"
if [ -z "$_thumb" ]; then
_err "Failed to compute certificate thumbprint."
return 1
fi
_debug "Thumbprint: $_thumb"
_debug "Building PFX at $_pfx_file"
_pfx_file="$(_mktemp)"
if ! _toPkcs "$_pfx_file" "$_ckey" "$_ccert" "$_cca" "$_pfx_pass"; then
_err "Failed to build PFX archive."
rm -f "$_pfx_file"
return 1
fi
_pfx_b64=$(_base64 "multiline" <"$_pfx_file")
rm -f "$_pfx_file"
# ---- build installer script --------------------------------------------
if [ "$DEPLOY_WIN_RDP_RESTART" = "1" ]; then
_restart_ps='Restart-Service -Name TermService -Force'
else
_restart_ps='# New RdP connections will pick up the new cert automatically.'
fi
# Escape every literal `$` with `\$` so the shell does not expand it.
# Values substituted from shell: $_pfx_b64, $_pfx_pass, $_thumb, $_listener.
_ps1=$(
cat <<PSEOF
\$ErrorActionPreference = 'Stop'
\$pfxBytes = [Convert]::FromBase64String('${_pfx_b64}')
# Note: It is quite important to use a X509Certificate2Collection here in any case, since we otherwise
# could run into quite a lot of trouble when importing the certificate including its entire chain
# and its private key. Windows might behave arbitrarily and not consistently import the certificate
# at all - unless "Exportable" is included in the storage flags. However, then the certificate seems
# unaccessible to TermService for some weird reasons despite all permissions being set (at least on my
# Win 11 lab machine). This might be some security setting that prevents TermService from working with
# exportable keys? I don't know - importing the entire collection including chain or not always fixes
# the issues.
#
# Note2: If you should have kicked yourself out for some reason, then deleting the certificate will make
# TermService restore the original, self-signed certificate after at least after the second login attempt.
# Deleting the certificate can be easily accomplished via the Powershell, since SSH access will still be
# present in any case - the following command should get you out of trouble:
# \$cert = Get-ChildItem -Path 'Cert:\LocalMachine\My\\${_thumb}' | Select-Object -First 1 | Remove-Item
\$flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]'MachineKeySet,PersistKeySet'
\$certs = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection
\$certs.Import(\$pfxBytes, '${_pfx_pass}', \$flags)
\$store = [System.Security.Cryptography.X509Certificates.X509Store]::new('My', 'LocalMachine')
\$store.Open('ReadWrite')
\$store.AddRange(\$certs)
\$store.Close()
Write-Host "Installed certs into LocalMachine\\My"
\$ts = Get-CimInstance -Namespace root/cimv2/terminalservices -ClassName Win32_TSGeneralSetting -Filter "TerminalName='${_listener}'"
if (-not \$ts) { throw "Listener '${_listener}' not found." }
Set-CimInstance -InputObject \$ts -Property @{SSLCertificateSHA1Hash="${_thumb}"}
Write-Host "Listener ${_listener} now uses ${_thumb}"
${_restart_ps}
PSEOF
)
_debug "Powershell script:${_ps1}"
# ---- run over a single ssh connection ----------------------------------
_ssh_opts="-o BatchMode=yes -p $_port"
if [ -n "$DEPLOY_WIN_RDP_SSH_OPTS" ]; then
_ssh_opts="$_ssh_opts $DEPLOY_WIN_RDP_SSH_OPTS"
fi
_info "Deploying to $DEPLOY_WIN_RDP_HOST ..."
# shellcheck disable=SC2086
if ! printf '%s\n' "$_ps1" | ssh $_ssh_opts "$_target" \
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command -'; then
_err "Remote install failed. Re-run acme.sh with --debug to see the PowerShell output."
return 1
fi
_info "Certificate for $_cdomain deployed and bound to $_listener on $DEPLOY_WIN_RDP_HOST."
return 0
}
+41 -10
View File
@@ -7,6 +7,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_1984hosting
Options:
One984HOSTING_Username Username
One984HOSTING_Password Password
One984HOSTING_TOTP_Secret Base32 TOTP shared secret. Required only if the account has 2FA enabled. Requires oathtool. Used to mint the OTP code automatically at login so cron renewals keep working.
Issues: github.com/acmesh-official/acme.sh/issues/2851
Author: Adrian Fedoreanu
'
@@ -124,11 +125,28 @@ _1984hosting_login() {
_debug "Login to 1984Hosting as user $One984HOSTING_Username."
username=$(printf '%s' "$One984HOSTING_Username" | _url_encode)
password=$(printf '%s' "$One984HOSTING_Password" | _url_encode)
url="https://1984.hosting/api/auth/"
_get "https://1984.hosting/accounts/login/" | grep "csrfmiddlewaretoken"
csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')"
sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')"
# When 2FA is enabled, mint a fresh TOTP code from the stored shared secret.
# Empty otpkey is accepted by the server when 2FA is off.
otpkey=""
if [ -n "$One984HOSTING_TOTP_Secret" ]; then
if ! _exists oathtool; then
_err "oathtool is required to use One984HOSTING_TOTP_Secret for 2FA. Please install it."
return 1
fi
otpcode="$(oathtool --base32 --totp "$One984HOSTING_TOTP_Secret" 2>/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"
+11 -1
View File
@@ -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
+20 -4
View File
@@ -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
+490
View File
@@ -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
}
+10 -1
View File
@@ -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/&/\&amp;/g;s/</\&lt;/g;s/>/\&gt;/g;s/'/\&apos;/g;s/\"/\&quot;/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 "<auth>
<user>%s</user>
<password>%s</password>
<context>%s</context>
</auth>" "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT"
</auth>" "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml"
}
# Arguments:
+2 -1
View File
@@ -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"
+43 -10
View File
@@ -9,7 +9,8 @@ Options:
AZUREDNS_APPID App ID. App ID of the service principal
AZUREDNS_CLIENTSECRET Client Secret. Secret from creating the service principal
AZUREDNS_MANAGEDIDENTITY Use Managed Identity. Use Managed Identity assigned to a resource instead of a service principal. "true"/"false"
AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional.
AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Not saved, provide it on every run. Optional.
AZUREDNS_PRIVATEZONE Use Azure Private DNS Zones instead of Public DNS Zones. "true"/"false"
'
wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS
@@ -39,6 +40,12 @@ dns_azure_add() {
#save subscription id to account conf file.
_saveaccountconf_mutable AZUREDNS_SUBSCRIPTIONID "$AZUREDNS_SUBSCRIPTIONID"
AZUREDNS_PRIVATEZONE="${AZUREDNS_PRIVATEZONE:-$(_readaccountconf_mutable AZUREDNS_PRIVATEZONE)}"
if [ -n "$AZUREDNS_PRIVATEZONE" ]; then
#save public/private dns to account conf file.
_saveaccountconf_mutable AZUREDNS_PRIVATEZONE "$AZUREDNS_PRIVATEZONE"
fi
AZUREDNS_MANAGEDIDENTITY="${AZUREDNS_MANAGEDIDENTITY:-$(_readaccountconf_mutable AZUREDNS_MANAGEDIDENTITY)}"
if [ "$AZUREDNS_MANAGEDIDENTITY" = true ]; then
_info "Using Azure managed identity"
@@ -47,13 +54,15 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID ""
_saveaccountconf_mutable AZUREDNS_APPID ""
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET ""
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN ""
_clearaccountconf_mutable AZUREDNS_BEARERTOKEN
else
_info "You didn't ask to use Azure managed identity, checking service principal credentials or provided bearer token"
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
#AZUREDNS_BEARERTOKEN is short-lived, so it is taken from the environment only and never
#read from or saved to the account conf. Versions up to 3.0.9 cached their internal access
#token under the same name, which must not be replayed as a user token (#7218).
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
@@ -93,7 +102,7 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID"
_saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID"
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET"
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN "$AZUREDNS_BEARERTOKEN"
_clearaccountconf_mutable AZUREDNS_BEARERTOKEN
fi
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
@@ -110,7 +119,9 @@ dns_azure_add() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=2017-09-01"
_azure_set_zone_vars
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=$_azure_api_version"
_debug "$acmeRecordURI"
# Get existing TXT record
_azure_rest GET "$acmeRecordURI" "" "$accesstoken"
@@ -136,7 +147,7 @@ dns_azure_add() {
fi
fi
# Add the txtvalue TXT Record
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"TTL\":10, \"TXTRecords\":[$values]}}"
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"$_azure_ttl_key\":10, \"$_azure_txt_key\":[$values]}}"
_azure_rest PUT "$acmeRecordURI" "$body" "$accesstoken"
if [ "$_code" = "200" ] || [ "$_code" = '201' ]; then
_info "validation value added"
@@ -167,6 +178,8 @@ dns_azure_rm() {
return 1
fi
AZUREDNS_PRIVATEZONE="${AZUREDNS_PRIVATEZONE:-$(_readaccountconf_mutable AZUREDNS_PRIVATEZONE)}"
AZUREDNS_MANAGEDIDENTITY="${AZUREDNS_MANAGEDIDENTITY:-$(_readaccountconf_mutable AZUREDNS_MANAGEDIDENTITY)}"
if [ "$AZUREDNS_MANAGEDIDENTITY" = true ]; then
_info "Using Azure managed identity"
@@ -175,7 +188,7 @@ dns_azure_rm() {
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
#AZUREDNS_BEARERTOKEN comes from the environment only, see the note in dns_azure_add
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
@@ -225,8 +238,11 @@ dns_azure_rm() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=2017-09-01"
_azure_set_zone_vars
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=$_azure_api_version"
_debug "$acmeRecordURI"
# Get existing TXT record
_azure_rest GET "$acmeRecordURI" "" "$accesstoken"
timestamp="$(_time)"
@@ -250,7 +266,7 @@ dns_azure_rm() {
fi
else
# Remove only txtvalue from the TXT Record
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"TTL\":10, \"TXTRecords\":[$values]}}"
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"$_azure_ttl_key\":10, \"$_azure_txt_key\":[$values]}}"
_azure_rest PUT "$acmeRecordURI" "$body" "$accesstoken"
if [ "$_code" = "200" ] || [ "$_code" = '201' ]; then
_info "validation value removed"
@@ -381,6 +397,21 @@ _azure_getaccess_token() {
return 0
}
_azure_set_zone_vars() {
if [ "$AZUREDNS_PRIVATEZONE" = "true" ]; then
_azure_zone_type="privateDnsZones"
_azure_api_version="2024-06-01"
_azure_ttl_key="ttl"
_azure_txt_key="txtRecords"
_debug "Querying private DNS zone"
else
_azure_zone_type="dnszones"
_azure_api_version="2017-09-01"
_azure_ttl_key="TTL"
_azure_txt_key="TXTRecords"
fi
}
_get_root() {
domain=$1
subscriptionId=$2
@@ -388,6 +419,8 @@ _get_root() {
i=1
p=1
_azure_set_zone_vars
## Ref: https://learn.microsoft.com/en-us/rest/api/dns/zones/list?view=rest-dns-2018-05-01&tabs=HTTP
## returns up to 100 zones in one response. Handling more results is not implemented
## (ZoneListResult with continuation token for the next page of results)
@@ -396,7 +429,7 @@ _get_root() {
## https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#azure-dns-limits
## The new limit is 250 Public DNS zones per subscription, while the old limit was only 100
##
_azure_rest GET "https://management.azure.com/subscriptions/$subscriptionId/providers/Microsoft.Network/dnszones?\$top=500&api-version=2017-09-01" "" "$accesstoken"
_azure_rest GET "https://management.azure.com/subscriptions/$subscriptionId/providers/Microsoft.Network/$_azure_zone_type?\$top=500&api-version=$_azure_api_version" "" "$accesstoken"
# Find matching domain name in Json response
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+278 -51
View File
@@ -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 <<EOF
$_records
EOF
_BAIDU_FIND_RESULT="$_ids"
}
# --- HTTP ---
_baidu_bcd_post() {
_api_path="$1"
@@ -317,18 +475,17 @@ _baidu_bcd_post() {
return 1
fi
_H1="Authorization: $_auth"
_H2="x-bce-date: $_ts"
_H3="x-bce-content-sha256: $_payload_hash"
_H4="Host: $BAIDU_BCD_HOST"
_H5=""
_url="https://${BAIDU_BCD_HOST}${_uri}"
_signed_headers_dbg="$(printf "%s" "$_auth" | cut -d / -f 5)"
_baidu_info "POST ${_uri}"
_baidu_info "signedHeaders: $_signed_headers_dbg"
_baidu_info "payload_sha256: $_payload_hash"
_baidu_debug "baidu_bcd.http.payload" "$(_baidu_dbg_trim "$(_baidu_redact_txt "$_payload")")"
_H1="Authorization: $_auth"
_H2="x-bce-date: $_ts"
_H3="x-bce-content-sha256: $_payload_hash"
_H4="Host: $BAIDU_BCD_HOST"
_H5=""
response="$(_post "$_payload" "$_url" "" "POST" "$_content_type")"
_ret="$?"
_baidu_info "ret: $_ret"
@@ -344,6 +501,56 @@ _baidu_bcd_post() {
return 0
}
_baidu_dns_call() {
_method="$1"
_uri="$2"
_payload="$3"
_content_type="application/json"
_attempt=1
_max_attempts=3
while [ "$_attempt" -le "$_max_attempts" ]; do
_ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
_payload_hash="$(printf "%s" "$_payload" | _digest sha256 hex)"
if ! _baidu_bce_auth "$_method" "$_uri" "" "$BAIDU_DNS_HOST" "$_ts" "${Baidu_BCD_Expire:-3600}" "$_content_type" "$_payload_hash"; then
_baidu_err "baidu_dns auth failed"
return 1
fi
_auth="$_BAIDU_BCE_AUTH_RESULT"
_url="https://${BAIDU_DNS_HOST}${_uri}"
# Route through acme.sh's _get/_post (they honor _H1.._H5); no raw curl.
_H1="Authorization: $_auth"
_H2="x-bce-date: $_ts"
_H3="x-bce-content-sha256: $_payload_hash"
_H4="Host: $BAIDU_DNS_HOST"
_H5="Content-Type: $_content_type"
if [ "$_method" = "GET" ]; then
response="$(_get "$_url")"
elif [ "$_method" = "DELETE" ]; then
response="$(_post "" "$_url" "" "DELETE")"
else
response="$(_post "$_payload" "$_url")"
fi
_ret="$?"
_baidu_info "${_method} ${_uri} ret=${_ret}"
# Baidu may return a business error (Exception / 平台服务繁忙) inside HTTP 200.
if [ "$_ret" = "0" ] && ! _contains "$response" "\"code\":\"Exception\"" && ! _contains "$response" "平台服务繁忙"; then
return 0
fi
if [ "$_attempt" -lt "$_max_attempts" ]; then
sleep 2
fi
_attempt=$(_math "$_attempt" + 1)
done
return 1
}
# --- Auth / Signing ---
_baidu_bce_auth() {
# Signing algorithm (bce-auth-v1):
@@ -499,6 +706,14 @@ _baidu_payload_add_txt() {
printf "%s" "{\"domain\":\"${_domain}\",\"view\":\"${_view}\",\"rdType\":\"TXT\",\"ttl\":${_ttl},\"rdata\":\"${_rdata}\",\"zoneName\":\"${_zoneName}\"}"
}
_baidu_payload_add_txt_dns() {
_rr="$(printf "%s" "$1" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
_value="$(printf "%s" "$2" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
_ttl="$3"
_line="$(printf "%s" "$4" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
printf "%s" "{\"rr\":\"${_rr}\",\"type\":\"TXT\",\"value\":\"${_value}\",\"ttl\":${_ttl},\"line\":\"${_line}\",\"description\":\"acme.sh\"}"
}
_baidu_payload_delete() {
_zoneName="$(_baidu_json_escape "$1")"
_recordId="$2"
@@ -541,6 +756,18 @@ _baidu_match_record_id() {
printf "%s" "$_line" | _egrep_o "\"recordId\": *[0-9]*" | _head_n 1 | cut -d : -f 2 | tr -d " "
}
_baidu_match_record_id_dns() {
_line="$1"
_rr="$(printf "%s" "$2" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
_type="$(printf "%s" "$3" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
_value="$(printf "%s" "$4" | tr -d '\r\n' | sed 's/\\/\\\\/g; s/"/\\"/g')"
case "$_line" in
*"\"rr\":\"${_rr}\""*"\"type\":\"${_type}\""*"\"value\":\"${_value}\""*)
printf "%s" "$_line" | sed -n 's/.*"id":"\{0,1\}\([^",}]*\)"\{0,1\}.*/\1/p' | _head_n 1
;;
esac
}
_baidu_hmac_sha256_hexkey() {
_key_hex="$1"
_msg="$2"
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_bergdns_info='bergdns.at
Site: bergdns.at
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_bergdns
Options:
BERGDNS_API_KEY API key, as issued in the account UI. Needs read (to find the zone and the record) and write over the challenge names.
BERGDNS_API_URL API base URL. Optional. Default "https://bergdns.at/v1".
BERGDNS_TTL TTL of the challenge record, in seconds. Optional. Default "60".
BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "60". "0" does not wait.
Issues: github.com/acmesh-official/acme.sh/issues/7261
Author: Kenny Kropp <https://github.com/kekropp>
'
_BERGDNS_DEFAULT_URL='https://bergdns.at/v1'
_BERGDNS_DEFAULT_TTL='60'
_BERGDNS_DEFAULT_WAIT='60'
######## Public functions ####################################################
# Usage: dns_bergdns_add _acme-challenge.www.example.com "token"
# The value is added to the RRset, so a domain and its wildcard can be
# validated at the same time.
dns_bergdns_add() {
fulldomain=$(echo "$1" | _lower_case)
txtvalue=$2
_bergdns_init || return 1
_bergdns_find_zone "$fulldomain" || return 1
_bergdns_find_rrset "$fulldomain" || return 1
_info "Adding TXT $fulldomain in zone $_bergdns_zone_name"
if [ -z "$_bergdns_rrset_id" ]; then
if _bergdns_rest POST "zones/$_bergdns_zone_id/rrsets" \
"{\"name\":\"$fulldomain\",\"type\":\"TXT\",\"ttl\":$BERGDNS_TTL,\"records\":[\"\\\"$txtvalue\\\"\"]}"; then
_bergdns_rrset_id=$(echo "$response" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_debug _bergdns_rrset_id "$_bergdns_rrset_id"
_bergdns_wait "$fulldomain"
return 0
fi
if [ "$_bergdns_code" != "rrset_exists" ]; then
_err "bergdns: could not add the challenge record: $_bergdns_error"
return 1
fi
# another run created it since the lookup above
_bergdns_find_rrset "$fulldomain" || return 1
if [ -z "$_bergdns_rrset_id" ]; then
_err "bergdns: could not find the challenge record at $fulldomain"
return 1
fi
fi
if ! _bergdns_rest POST \
"zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \
"{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then
_err "bergdns: could not add the challenge record: $_bergdns_error"
return 1
fi
_bergdns_wait "$fulldomain"
}
# Usage: dns_bergdns_rm _acme-challenge.www.example.com "token"
# Only this value is removed. Removing the last value deletes the RRset, and
# removing a value that does not exist is not an error.
dns_bergdns_rm() {
fulldomain=$(echo "$1" | _lower_case)
txtvalue=$2
_bergdns_init || return 1
_bergdns_find_zone "$fulldomain" || return 1
_bergdns_find_rrset "$fulldomain" || return 1
if [ -z "$_bergdns_rrset_id" ]; then
_info "bergdns: no TXT records at $fulldomain, nothing to remove"
return 0
fi
_info "Removing TXT $fulldomain from zone $_bergdns_zone_name"
if ! _bergdns_rest DELETE \
"zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \
"{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then
# Any flavour of not-found is a cleanup that has already happened: the
# RRset was removed by a previous run, or by the other half of a
# domain-and-wildcard pair taking the last value with it.
if [ "$_bergdns_status" = "404" ]; then
_info "bergdns: $fulldomain holds no such record any more, nothing to remove"
return 0
fi
_err "bergdns: could not remove the challenge record: $_bergdns_error"
return 1
fi
return 0
}
######## Private functions ###################################################
_bergdns_init() {
BERGDNS_API_KEY="${BERGDNS_API_KEY:-$(_readaccountconf_mutable BERGDNS_API_KEY)}"
BERGDNS_API_URL="${BERGDNS_API_URL:-$(_readaccountconf_mutable BERGDNS_API_URL)}"
BERGDNS_TTL="${BERGDNS_TTL:-$(_readaccountconf_mutable BERGDNS_TTL)}"
BERGDNS_PROPAGATION_TIMEOUT="${BERGDNS_PROPAGATION_TIMEOUT:-$(_readaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT)}"
if [ -z "$BERGDNS_API_KEY" ]; then
BERGDNS_API_KEY=""
_clearaccountconf_mutable BERGDNS_API_KEY
_err "You have not set BERGDNS_API_KEY. Create a key in the bergdns UI and export it:"
_err " export BERGDNS_API_KEY=\"bgd_...\""
return 1
fi
[ -n "$BERGDNS_API_URL" ] || BERGDNS_API_URL="$_BERGDNS_DEFAULT_URL"
[ -n "$BERGDNS_TTL" ] || BERGDNS_TTL="$_BERGDNS_DEFAULT_TTL"
[ -n "$BERGDNS_PROPAGATION_TIMEOUT" ] || BERGDNS_PROPAGATION_TIMEOUT="$_BERGDNS_DEFAULT_WAIT"
# strip trailing slashes
BERGDNS_API_URL=$(echo "$BERGDNS_API_URL" | sed 's#/*$##')
# The TTL is interpolated into the request body and the timeout is counted
# down in arithmetic, so a stray value from the environment or from an old
# account.conf has to be caught here rather than become malformed JSON and
# an opaque 400.
case "$BERGDNS_TTL" in
*[!0-9]* | '')
_err "bergdns: BERGDNS_TTL must be a number of seconds, not \"$BERGDNS_TTL\"."
return 1
;;
esac
case "$BERGDNS_PROPAGATION_TIMEOUT" in
*[!0-9]* | '')
_err "bergdns: BERGDNS_PROPAGATION_TIMEOUT must be a number of seconds, not \"$BERGDNS_PROPAGATION_TIMEOUT\"."
return 1
;;
esac
_saveaccountconf_mutable BERGDNS_API_KEY "$BERGDNS_API_KEY"
# Only what the user actually chose is written back, and a value equal to
# the default clears any older setting. Persisting a default would pin the
# install to today's value, and a later change to the shipped one -- a move
# of the API base above all -- would never reach it; leaving an old setting
# in place would mean the environment could never put one back to default.
if [ "$BERGDNS_API_URL" = "$_BERGDNS_DEFAULT_URL" ]; then
_clearaccountconf_mutable BERGDNS_API_URL
else
_saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL"
fi
if [ "$BERGDNS_TTL" = "$_BERGDNS_DEFAULT_TTL" ]; then
_clearaccountconf_mutable BERGDNS_TTL
else
_saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL"
fi
if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "$_BERGDNS_DEFAULT_WAIT" ]; then
_clearaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT
else
_saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT"
fi
return 0
}
# Usage: _bergdns_find_zone _acme-challenge.www.example.com
# Sets _bergdns_zone_id and _bergdns_zone_name.
# Zones are addressed by an id, not by name, so the zone list is fetched once
# and the longest matching zone name wins.
_bergdns_find_zone() {
_bergdns_fqdn=$1
_bergdns_zone_id=""
_bergdns_zone_name=""
if ! _bergdns_rest GET "zones"; then
_err "bergdns: could not list zones: $_bergdns_error"
return 1
fi
# one zone object per line, so id and name stay together
_bergdns_zone_lines=$(echo "$response" | tr '{' '\n')
_bergdns_cand="$_bergdns_fqdn"
while [ -n "$_bergdns_cand" ]; do
_bergdns_line=$(echo "$_bergdns_zone_lines" | _bergdns_select "$_bergdns_cand" | _head_n 1)
if [ -n "$_bergdns_line" ]; then
_bergdns_zone_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_bergdns_zone_name="$_bergdns_cand"
_debug _bergdns_zone_id "$_bergdns_zone_id"
_debug _bergdns_zone_name "$_bergdns_zone_name"
[ -n "$_bergdns_zone_id" ] && return 0
break
fi
case "$_bergdns_cand" in
*.*) _bergdns_cand=${_bergdns_cand#*.} ;;
*) _bergdns_cand="" ;;
esac
done
_err "bergdns: no zone in this account holds $_bergdns_fqdn."
_err "bergdns: the key must be able to read the zone as well as write the record."
return 1
}
# Usage: _bergdns_find_rrset _acme-challenge.www.example.com
# Sets _bergdns_rrset_id to the id of the TXT RRset at that name, or to an
# empty string if there is none. Records are addressed by id, so the zone's
# RRsets are listed to find it.
_bergdns_find_rrset() {
_bergdns_fqdn=$1
_bergdns_rrset_id=""
if ! _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets"; then
_err "bergdns: could not list the records of $_bergdns_zone_name: $_bergdns_error"
return 1
fi
_bergdns_line=$(echo "$response" | tr '{' '\n' | _bergdns_select "$_bergdns_fqdn" TXT | _head_n 1)
if [ -n "$_bergdns_line" ]; then
_bergdns_rrset_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
fi
_debug _bergdns_rrset_id "$_bergdns_rrset_id"
return 0
}
# Usage: ... | _bergdns_select name [type]
# Reads one JSON object per line and prints those whose "name" is name and,
# when a type is given, whose "type" is that type. The two fields are matched
# one at a time, so neither the order the server writes its keys in nor
# anything sitting between them changes the answer.
#
# The comparison is a shell case, which is literal by construction: grep -F
# does not exist on Solaris, and _contains and _startswith would read the name
# as a regular expression. Each pattern anchors on the start of the object or
# on the comma before the key, so a key that merely ends in "name" cannot
# match.
_bergdns_select() {
_bergdns_sel_name=$1
_bergdns_sel_type=$2
while IFS= read -r _bergdns_sel_line || [ -n "$_bergdns_sel_line" ]; do
case "$_bergdns_sel_line" in
'"name":"'"$_bergdns_sel_name"'"'* | *',"name":"'"$_bergdns_sel_name"'"'*) ;;
*) continue ;;
esac
if [ -n "$_bergdns_sel_type" ]; then
case "$_bergdns_sel_line" in
'"type":"'"$_bergdns_sel_type"'"'* | *',"type":"'"$_bergdns_sel_type"'"'*) ;;
*) continue ;;
esac
fi
printf '%s\n' "$_bergdns_sel_line"
done
}
# Usage: _bergdns_wait _acme-challenge.www.example.com
# Polls the propagation endpoint until all bergdns nameservers serve the
# record. A timeout is logged but does not fail the issuance.
#
# This covers the zone transfer from the primary to the secondaries, which
# takes seconds; the resolver side is acme.sh's own _check_dns_entries, which
# runs after every record has been added and has a timeout of its own.
_bergdns_wait() {
_bergdns_fqdn=$1
if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "0" ] || [ -z "$_bergdns_rrset_id" ]; then
return 0
fi
_bergdns_waited=0
while [ "$_bergdns_waited" -lt "$BERGDNS_PROPAGATION_TIMEOUT" ]; do
if _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/propagation"; then
case "$response" in
*'"propagated":true'*)
_info "bergdns: $_bergdns_fqdn is served by every secondary after ${_bergdns_waited}s"
return 0
;;
esac
elif [ "$_bergdns_code" = "propagation_unavailable" ]; then
# propagation checks are not configured on this server
_info "bergdns: this deployment does not offer propagation checks; not waiting"
return 0
else
case "$_bergdns_status" in
429) ;; # rate limited, worth another go
4*)
# The check is refused rather than pending, and waiting will not
# change that. _check_dns_entries still has to pass, so this is not
# the place to fail the issuance.
_info "bergdns: the propagation check is unavailable ($_bergdns_error); not waiting"
return 0
;;
esac
fi
_sleep 5
_bergdns_waited=$((_bergdns_waited + 5))
done
_info "bergdns: $_bergdns_fqdn was not on every secondary after ${BERGDNS_PROPAGATION_TIMEOUT}s; continuing anyway"
return 0
}
# Usage: _bergdns_rest method endpoint [body]
# Sets response and _bergdns_status. On failure also sets _bergdns_error and,
# where the API itself answered, _bergdns_code.
_bergdns_rest() {
_bergdns_method=$1
_bergdns_endpoint=$2
_bergdns_body=$3
_bergdns_error=""
_bergdns_code=""
_bergdns_status=""
export _H1="Authorization: Bearer $BERGDNS_API_KEY"
export _H2="Accept: application/json"
_bergdns_url="$BERGDNS_API_URL/$_bergdns_endpoint"
_debug _bergdns_url "$_bergdns_url"
# drop the headers of the previous request, so that a request which never
# reaches the server cannot be read as carrying its status
if [ -f "$HTTP_HEADER" ]; then
: >"$HTTP_HEADER"
fi
if [ "$_bergdns_method" = "GET" ]; then
response="$(_get "$_bergdns_url")"
else
_debug2 _bergdns_body "$_bergdns_body"
response="$(_post "$_bergdns_body" "$_bergdns_url" "" "$_bergdns_method" "application/json")"
fi
_bergdns_ret="$?"
_debug2 response "$response"
if [ "$_bergdns_ret" != "0" ]; then
_bergdns_error="the request to $_bergdns_url could not be made"
return 1
fi
_bergdns_status="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug _bergdns_status "$_bergdns_status"
# The HTTP status decides. Errors from the API itself are RFC 9457
# problem+json and carry a "detail" to show and a stable "code" to branch on,
# but a request that never gets that far -- bergdns.at answers from behind a
# reverse proxy, whose 502 and 504 are HTML -- has neither, and reading the
# body alone would take those for success.
case "$_bergdns_status" in
2*) return 0 ;;
esac
_bergdns_error=$(echo "$response" | _egrep_o '"detail":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_bergdns_code=$(echo "$response" | _egrep_o '"code":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
[ -n "$_bergdns_error" ] || _bergdns_error="$_bergdns_url answered HTTP ${_bergdns_status:-(none)}"
_debug _bergdns_code "$_bergdns_code"
return 1
}
+3 -3
View File
@@ -323,21 +323,21 @@ _bhosted_extract_id() {
fi
# JSON: "id":12345
_id="$(printf "%s" "$_resp" | _egrep_o '"id"[[:space:]]*:[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '"id"[ ]*:[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# key=value: id=12345
_id="$(printf "%s" "$_resp" | _egrep_o '(^|[[:space:][:punct:]])id[[:space:]]*=[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '(^|[^0-9a-zA-Z])id[ ]*=[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# "record id 12345" / "recordid 12345"
_id="$(printf "%s" "$_resp" | _egrep_o '(record[[:space:]]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '(record[ ]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_calrissia_info='Calrissia.be DNS API
Site: calrissia.be
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_calrissia
Options:
CALRISSIA_TOKEN Personal access token
Issues: github.com/acmesh-official/acme.sh/issues/6809
Author: Ward Hus
'
CALRISSIA_API="https://my.calrissia.com/api"
dns_calrissia_add() {
fulldomain="$1"
txtvalue="$2"
_calrissia_load_token || return 1
if ! _calrissia_get_root "$fulldomain"; then
_err "Unable to find domain in Calrissia account for: $fulldomain"
return 1
fi
_debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'"
_info "Adding TXT record for $fulldomain"
_body="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120,\"prio\":0}"
_response="$(_calrissia_request POST "/domain/$_domain_id/record" "$_body")"
if ! _contains "$_response" '"id"'; then
_err "Failed to create TXT record: $_response"
return 1
fi
return 0
}
dns_calrissia_rm() {
fulldomain="$1"
txtvalue="$2"
_calrissia_load_token || return 1
if ! _calrissia_get_root "$fulldomain"; then
_err "Unable to find domain in Calrissia account for: $fulldomain"
return 1
fi
_debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'"
# Look the record up from the API instead of relying on local state.
# The record list is embedded in the domain object.
_response="$(_calrissia_request GET "/domain/$_domain_id")"
_debug2 "Response: $_response"
# Split the record objects onto separate lines, then match on both the
# subdomain name and the TXT value to find the record id to delete.
_record_id="$(printf "%s" "$_response" |
tr '{}' '\n' |
grep "\"name\" *: *\"$_sub_domain\"" |
grep "\"content\" *: *\"$txtvalue\"" |
_egrep_o '"id" *: *[0-9]+' |
_head_n 1 |
_egrep_o '[0-9]+')"
if [ -z "$_record_id" ]; then
_info "No matching TXT record found for $fulldomain; nothing to remove"
return 0
fi
_info "Removing TXT record id=$_record_id from domain id=$_domain_id"
if ! _response="$(_calrissia_request DELETE "/domain/$_domain_id/record/$_record_id")" || _contains "$_response" '"error"'; then
_err "Failed to remove TXT record: $_response"
return 1
fi
return 0
}
####################
# Private helpers #
####################
_calrissia_load_token() {
CALRISSIA_TOKEN="${CALRISSIA_TOKEN:-$(_readaccountconf_mutable CALRISSIA_TOKEN)}"
if [ -z "$CALRISSIA_TOKEN" ]; then
_err "CALRISSIA_TOKEN is not set. Generate one at https://identity.calrissia.com under API Keys."
return 1
fi
_saveaccountconf_mutable CALRISSIA_TOKEN "$CALRISSIA_TOKEN"
}
# Sets _domain, _domain_id, _sub_domain for a given FQDN.
_calrissia_get_root() {
_fqdn="$1"
i=1
while true; do
_candidate="$(printf "%s" "$_fqdn" | cut -d . -f "$i"-)"
[ -z "$_candidate" ] && return 1
_debug "Trying root domain: $_candidate"
_response="$(_calrissia_request GET "/domain?full_domain_name=$_candidate")"
_debug2 "Response: $_response"
_domain_id="$(printf "%s" "$_response" |
_egrep_o '"id" *: *[0-9]+' |
_head_n 1 |
_egrep_o '[0-9]+')"
if [ -n "$_domain_id" ]; then
if [ "$i" = "1" ]; then
# The FQDN itself is the zone apex, e.g. a challenge-alias domain.
_sub_domain=""
else
_sub_domain="$(printf "%s" "$_fqdn" | cut -d . -f "1-$((i - 1))")"
fi
_domain="$_candidate"
return 0
fi
i=$((i + 1))
done
}
_calrissia_request() {
_method="$1"
_path="$2"
_body="$3"
export _H1="Authorization: Bearer $CALRISSIA_TOKEN"
export _H2="Accept: application/json"
if [ "$_method" = "GET" ]; then
_get "$CALRISSIA_API$_path"
else
_post "$_body" "$CALRISSIA_API$_path" "" "$_method" "application/json"
fi
}
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_cdmon_info='cdmon
Site: www.cdmon.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cdmon
Options:
CDMON_Key API Key
'
CDMON_Api="https://api-domains.cdmon.services/api-domains"
######## Public functions #####################
# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
# Used to add txt record
dns_cdmon_add() {
fulldomain=$1
txtvalue=$2
CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}"
if [ -z "$CDMON_Key" ]; then
CDMON_Key=""
_err "You didn't specify your cdmon api key yet."
_err "Please create your key and try again."
return 1
fi
_saveaccountconf_mutable CDMON_Key "$CDMON_Key"
_debug "First, we detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_info "Adding record"
if _cdmon_rest "dnsrecords/create" "{\"data\":{\"type\":\"TXT\",\"domain\":\"$_domain\",\"value\":\"$txtvalue\",\"ttl\":120,\"host\":\"$_sub_domain\"}}"; then
if _contains "$response" "\"status\":\"ok\""; then
_info "Added, OK"
return 0
else
_err "Add txt record error."
return 1
fi
fi
_err "Add txt record error."
return 1
}
# Usage: fulldomain txtvalue
# Used to remove the txt record after validation
dns_cdmon_rm() {
fulldomain=$1
txtvalue=$2
CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}"
_debug "First, we detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_info "Removing record"
if _cdmon_rest "dnsrecords/delete" "{\"data\":{\"value\":\"$txtvalue\",\"type\":\"TXT\",\"domain\":\"$_domain\",\"host\":\"$_sub_domain\"}}"; then
if _contains "$response" "\"status\":\"ok\""; then
_info "Deleted, OK"
return 0
else
_err "Delete txt record error."
return 1
fi
fi
_err "Delete txt record error."
return 1
}
#################### 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
if ! _cdmon_rest "domains/list"; then
return 1
fi
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug h "$h"
if [ -z "$h" ]; then
#not valid
return 1
fi
if _contains "$response" "\"domain\":\"$h\""; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain=$h
return 0
fi
p=$i
i=$(_math "$i" + 1)
done
return 1
}
_cdmon_rest() {
ep="$1"
data="$2"
_debug "$ep"
key_trimmed=$(echo "$CDMON_Key" | tr -d '"')
export _H1="Content-Type: application/json"
export _H2="apikey: $key_trimmed"
_debug data "$data"
response="$(_post "$data" "$CDMON_Api/$ep")"
_ret="$?"
unset _H1 _H2
if [ "$_ret" != "0" ]; then
_err "error $ep"
return 1
fi
_debug2 response "$response"
return 0
}
+1 -1
View File
@@ -135,7 +135,7 @@ _dns_cloudns_init_check() {
_dns_cloudns_http_api_call "dns/login.json" ""
if ! _contains "$response" "\"status\":\"Success\""; then
_err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Please check your login credentials."
_err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Server response: $response"
return 1
fi
+4 -2
View File
@@ -15,7 +15,8 @@ CN_API="https://beta.api.core-networks.de"
######## Public functions #####################
dns_cn_add() {
fulldomain=$1
# Core-Networks API requires punycode for IDN domains
fulldomain=$(_idn "$1")
txtvalue=$2
if ! _cn_login; then
@@ -58,7 +59,8 @@ dns_cn_add() {
}
dns_cn_rm() {
fulldomain=$1
# Core-Networks API requires punycode for IDN domains
fulldomain=$(_idn "$1")
txtvalue=$2
if ! _cn_login; then
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_comlaude_info='comlaude.com
Site: comlaude.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_comlaude
Options:
COMLAUDE_USERNAME User account
COMLAUDE_PASSWORD User password
COMLAUDE_API_KEY generated API key
COMLAUDE_GROUP_ID Group ID in comlaude user profile
Get it from the https://www.comlaude.com
Issues: github.com/acmesh-official/acme.sh/issues/7112
'
# ===== CONFIG =====
COMLAUDE_API="https://api.comlaude.com"
########## AUTH ##########
_comlaude_auth() {
_debug "Checking cached ComLaude token"
# Try to get token from account.conf
if [ -z "$COMLAUDE_ACCESS_TOKEN" ]; then
COMLAUDE_ACCESS_TOKEN="$(_readaccountconf_mutable COMLAUDE_ACCESS_TOKEN)"
COMLAUDE_TOKEN_EXPIRY="$(_readaccountconf_mutable COMLAUDE_TOKEN_EXPIRY)"
fi
_now=$(_time)
if [ -n "$COMLAUDE_ACCESS_TOKEN" ] && [ -n "$COMLAUDE_TOKEN_EXPIRY" ] && [ "$_now" -lt "$COMLAUDE_TOKEN_EXPIRY" ]; then
_debug "Using cached ComLaude token (valid ${COMLAUDE_TOKEN_EXPIRY} > ${_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
}
+21 -7
View File
@@ -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
}
+181
View File
@@ -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=<YourToken>' and 'export creolineApiSecret=<YourSecret>'."
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
}
+3 -3
View File
@@ -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)"
+9 -6
View File
@@ -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
+1 -1
View File
@@ -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
'
+7 -4
View File
@@ -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
+25 -90
View File
@@ -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 <add|delete> <extra-json-fields>
_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
}
+37 -8
View File
@@ -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=<one of: $(echo "$_account_id" | tr '\n' ' ')>"
return 1
fi
_debug _account_id "$_account_id"
return 0
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_dnsmint_info='DNSMint.com
DNSMint mints hostnames on domains it operates and serves from its own
authoritative nameservers, so records are published through its API rather
than a zone you run.
Site: dnsmint.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsmint
Options:
DNSMINT_API_KEY API key. dns01:write is enough to issue certificates.
Issues: github.com/acmesh-official/acme.sh/issues/7251
Author: DNSMint
'
DNSMint_Api="${DNSMint_Api:-https://dnsmint.com/api}"
######## Public functions #####################
#Usage: dns_dnsmint_add _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_dnsmint_add() {
fulldomain=$1
txtvalue=$2
_info "Using DNSMint"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _dnsmint_key; then
return 1
fi
# An ACME challenge goes to the DNS-01 endpoint, which derives the hostname
# itself and needs only dns01:write. Any other name is an ordinary record
# under a hostname, which is a different endpoint and a wider scope.
if _startswith "$fulldomain" "_acme-challenge."; then
if _dnsmint_challenge present "$fulldomain" "$txtvalue"; then
_info "Added, OK"
return 0
fi
return 1
fi
if _dnsmint_record_add "$fulldomain" "$txtvalue"; then
_info "Added, OK"
return 0
fi
return 1
}
#Usage: dns_dnsmint_rm _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_dnsmint_rm() {
fulldomain=$1
txtvalue=$2
_info "Using DNSMint"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _dnsmint_key; then
return 1
fi
if _startswith "$fulldomain" "_acme-challenge."; then
if _dnsmint_challenge cleanup "$fulldomain" "$txtvalue"; then
_info "Removed, OK"
return 0
fi
return 1
fi
if _dnsmint_record_rm "$fulldomain" "$txtvalue"; then
_info "Removed, OK"
return 0
fi
return 1
}
#################### Private functions below ##################################
_dnsmint_key() {
DNSMINT_API_KEY="${DNSMINT_API_KEY:-$(_readaccountconf_mutable DNSMINT_API_KEY)}"
if [ -z "$DNSMINT_API_KEY" ]; then
DNSMINT_API_KEY=""
_err "You did not specify DNSMINT_API_KEY yet."
_err "Create a key with the dns01:write scope at https://dnsmint.com/dashboard"
_err "e.g."
_err "export DNSMINT_API_KEY=dnsm_xxxxxxxxxxxx_xxxxxxxx"
return 1
fi
_saveaccountconf_mutable DNSMINT_API_KEY "$DNSMINT_API_KEY"
return 0
}
_dnsmint_headers() {
export _H1="Authorization: Bearer $DNSMINT_API_KEY"
export _H2="Accept: application/json"
export _H3="Content-Type: application/json"
}
# One request. Sets $response and $_code; returns non-zero on a transport error.
_dnsmint_rest() {
_m="$1"
_ep="$2"
_data="$3"
_dnsmint_headers
if [ "$_m" = "GET" ]; then
response="$(_get "$DNSMint_Api$_ep")"
else
_secure_debug2 _data "$_data"
response="$(_post "$_data" "$DNSMint_Api$_ep" "" "$_m")"
fi
_ret="$?"
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
_debug "http response code $_code"
_debug2 response "$response"
if [ "$_ret" != "0" ]; then
_err "error $_ep"
return 1
fi
case "$_code" in
2*) return 0 ;;
*)
# The API says why in the body - a key narrowed to another hostname, a
# name that is not live - and that is more use than the status alone.
_err "error $_ep: HTTP $_code $response"
return 1
;;
esac
}
# The DNS-01 endpoint. It derives the hostname from the challenge name, so
# there is no zone to look up and no record id to track: the value published
# is the value removed.
_dnsmint_challenge() {
_action="$1"
_fqdn="$2"
_value="$3"
_dnsmint_rest POST "/httpreq/$_action" "{\"fqdn\":\"$_fqdn\",\"value\":\"$_value\"}"
}
# Everything below here is for names that are not ACME challenges. A record
# under a hostname is addressed by the hostname's id and a name relative to
# it, so the hostname has to be found first.
_dnsmint_host() {
_name="$1"
_host_id=""
_host_sub=""
if ! _dnsmint_rest GET "/v1/hostnames?limit=500"; then
return 1
fi
for _h in $(echo "$response" | _egrep_o '"hostname":"[^"]*"' | cut -d'"' -f4); do
case "$_name" in
*".$_h")
# Longest suffix wins, so a.b.example.dev prefers b.example.dev over
# example.dev when both are hostnames on the account.
if [ "${#_h}" -gt "${#_host_sub}" ]; then
_host_sub="$_h"
fi
;;
esac
done
if [ -z "$_host_sub" ]; then
_err "$_name is not under a hostname on this account"
return 1
fi
# The id sits next to the hostname in the same object.
_host_id="$(echo "$response" | _egrep_o "\"id\":\"[^\"]*\",\"hostname\":\"$_host_sub\"" | cut -d'"' -f4)"
if [ -z "$_host_id" ]; then
_err "could not read the id for $_host_sub"
return 1
fi
_record_name="${_name%".$_host_sub"}"
_debug _host_sub "$_host_sub"
_debug _record_name "$_record_name"
return 0
}
_dnsmint_record_add() {
_name="$1"
_value="$2"
if ! _dnsmint_host "$_name"; then
return 1
fi
_dnsmint_rest POST "/v1/hostnames/$_host_id/records" \
"{\"name\":\"$_record_name\",\"type\":\"TXT\",\"text\":\"$_value\"}"
}
_dnsmint_record_rm() {
_name="$1"
_value="$2"
if ! _dnsmint_host "$_name"; then
return 1
fi
if ! _dnsmint_rest GET "/v1/hostnames/$_host_id/records"; then
return 1
fi
# Records come back as {"id":...,"name":"<fqdn>","type":"TXT","ttl":...,
# "data":{...,"text":["<value>"]}}. Match on the value so a name holding
# several TXT records loses only the one that was added.
#
# The replacement carries a literal newline: "\n" there is a GNU extension
# and BSD sed inserts the letter n, which would leave the whole reply on one
# line and match the first record under the hostname whatever its value.
#
# echo rather than printf "%s": the reply arrives with no trailing newline,
# and Solaris /usr/bin/sed discards an incomplete final line. Here that line
# is the entire reply, so every removal would report the record already gone.
_records="$(
echo "$response" | sed 's/},{/}\
{/g'
)"
_rid=""
while IFS= read -r _line; do
case "$_line" in
*"\"$_value\""*)
# _head_n 1 because a record object may carry a nested id under "data",
# and two lines in _rid would break the DELETE URL.
_rid="$(echo "$_line" | _egrep_o '"id":"[^"]*"' | cut -d'"' -f4 | _head_n 1)"
if [ -n "$_rid" ]; then
break
fi
;;
esac
done <<EOF
$_records
EOF
if [ -z "$_rid" ]; then
_info "Record already gone, nothing to remove"
return 0
fi
_dnsmint_rest DELETE "/v1/hostnames/$_host_id/records/$_rid" ""
}
+10 -6
View File
@@ -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
+6
View File
@@ -150,6 +150,9 @@ _dns_dynv6_add_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_set_record TXT "$record" "$txtvalue"
if _contains "$response" "$txtvalue"; then
_info "Successfully added record"
@@ -168,6 +171,9 @@ _dns_dynv6_rm_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_get_record_id "$_zone_id" "$record" "$txtvalue"
_del_record "$_zone_id" "$_record_id"
if [ -z "$response" ]; then
+17 -15
View File
@@ -75,6 +75,11 @@ dns_easydns_rm() {
EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}"
EASYDNS_Key="${EASYDNS_Key:-$(_readaccountconf_mutable EASYDNS_Key)}"
if [ -z "$EASYDNS_Token" ] || [ -z "$EASYDNS_Key" ]; then
_err "You didn't specify an easydns.net token or api key. Signup at https://cp.easydns.com/manage/security/api/signup.php"
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
@@ -91,24 +96,21 @@ dns_easydns_rm() {
return 1
fi
count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
_debug count "$count"
if [ "$count" = "0" ]; then
record_id=$(printf "%s\n" "$response" | tr '{' '\n' | grep "\"rdata\":\"$txtvalue\"" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \")
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
_info "Don't need to remove."
else
record_id=$(printf "%s\n" "$response" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | head -n 1)
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
_err "Can not get record id to remove."
return 1
fi
if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
_err "Delete record error."
return 1
fi
_contains "$response" "\"status\":200"
return 0
fi
if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
_err "Delete record error."
return 1
fi
_contains "$response" "\"status\":200"
}
#################### Private functions below ##################################
+6 -11
View File
@@ -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"
}
+267
View File
@@ -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
}
+110
View File
@@ -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"
}
+1 -1
View File
@@ -305,7 +305,7 @@ _freedns_domain_id() {
fi
domain_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's/<tr>/@<tr>/g' | tr '@' '\n' |
grep "<td>$search_domain</td>\|<td>$search_domain(.*)</td>" |
grep -E "<td>$search_domain</td>|<td>$search_domain\(.*\)</td>" |
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...
+20 -9
View File
@@ -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 <root@recolic.net>, @Giova96
Author: Recolic Keghart <root@recolic.net>, @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
}
+38 -9
View File
@@ -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
+263
View File
@@ -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
}
+198
View File
@@ -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 <radu.malica@gmail.com>
'
######## 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 <<EOF
$(_hestia_find_records "$_hestia_sub" "TXT")
EOF
if [ "$_hestia_removed" = "0" ] && [ "$_hestia_failed" = "0" ]; then
_info "No matching TXT record found to remove"
else
_info "Removed $_hestia_removed TXT record(s)"
fi
if [ "$_hestia_failed" != "0" ]; then
return 1
fi
return 0
}
#################### Private functions below ##################################
_hestia_init() {
HESTIA_HOST="${HESTIA_HOST:-$(_readaccountconf_mutable HESTIA_HOST)}"
HESTIA_ACCESS="${HESTIA_ACCESS:-$(_readaccountconf_mutable HESTIA_ACCESS)}"
HESTIA_SECRET="${HESTIA_SECRET:-$(_readaccountconf_mutable HESTIA_SECRET)}"
HESTIA_USER="${HESTIA_USER:-$(_readaccountconf_mutable HESTIA_USER)}"
if [ -z "$HESTIA_HOST" ] || [ -z "$HESTIA_ACCESS" ] || [ -z "$HESTIA_SECRET" ]; then
HESTIA_HOST=""
HESTIA_ACCESS=""
HESTIA_SECRET=""
_err "You must export HESTIA_HOST, HESTIA_ACCESS and HESTIA_SECRET first"
return 1
fi
HESTIA_HOST="${HESTIA_HOST%/}"
if ! echo "$HESTIA_HOST" | grep -qE '^https?://[^/]+$'; then
_err "HESTIA_HOST must be a valid URL (e.g. https://panel.example.com:8083)"
return 1
fi
if [ -z "$HESTIA_USER" ]; then
HESTIA_USER="admin"
fi
_saveaccountconf_mutable HESTIA_HOST "$HESTIA_HOST"
_saveaccountconf_mutable HESTIA_ACCESS "$HESTIA_ACCESS"
_saveaccountconf_mutable HESTIA_SECRET "$HESTIA_SECRET"
_saveaccountconf_mutable HESTIA_USER "$HESTIA_USER"
return 0
}
# Walk up the domain labels until the API returns a DNS zone.
# Sets _hestia_domain to the zone and _hestia_sub to the record name
# relative to the zone. The zone record listing stays in _hestia_response.
_hestia_get_root() {
_hestia_fqdn="${1%.}"
_hestia_i=1
while true; do
_hestia_h=$(printf "%s" "$_hestia_fqdn" | cut -d . -f "$_hestia_i"-100)
_debug2 _hestia_h "$_hestia_h"
if [ -z "$_hestia_h" ]; then
return 1
fi
if _hestia_rest "v-list-dns-records" "$HESTIA_USER" "$_hestia_h" "json"; then
_hestia_domain="$_hestia_h"
if [ "$_hestia_h" = "$_hestia_fqdn" ]; then
_hestia_sub="@"
else
_hestia_sub=$(printf "%s" "$_hestia_fqdn" | cut -d . -f 1-"$(_math "$_hestia_i" - 1)")
fi
return 0
fi
_hestia_i=$(_math "$_hestia_i" + 1)
done
}
# Call the HestiaCP API. Args: cmd [arg1 arg2 ...]
# The response body is stored in _hestia_response.
_hestia_rest() {
_hestia_cmd=$1
shift
_hestia_data="{\"access_key\":\"$HESTIA_ACCESS\",\"secret_key\":\"$HESTIA_SECRET\",\"cmd\":\"$_hestia_cmd\""
_hestia_argn=1
for _hestia_arg in "$@"; do
_hestia_data="$_hestia_data,\"arg$_hestia_argn\":\"$_hestia_arg\""
_hestia_argn=$(_math "$_hestia_argn" + 1)
done
_hestia_data="$_hestia_data}"
_debug2 "Calling $_hestia_cmd"
_hestia_response=$(_post "$_hestia_data" "$HESTIA_HOST/api/" "" "POST" "application/json")
_hestia_ret=$?
_debug2 _hestia_response "$_hestia_response"
if [ "$_hestia_ret" != "0" ]; then
_err "Error connecting to the HestiaCP API"
return 1
fi
if _contains "$_hestia_response" "Error:"; then
return 1
fi
return 0
}
# Extract records matching name and type from the v-list-dns-records
# response in _hestia_response. Prints one "id|value" line per match.
_hestia_find_records() {
_hestia_fname=$1
_hestia_ftype=$2
echo "$_hestia_response" | tr -d '\n' | sed 's/},/}\
/g' | grep -F -- "\"RECORD\": \"$_hestia_fname\"" | grep -F -- "\"TYPE\": \"$_hestia_ftype\"" | while read -r _hestia_line; do
_hestia_id=$(echo "$_hestia_line" | _egrep_o '"ID": "[^"]*' | cut -d '"' -f 4)
_hestia_value=$(echo "$_hestia_line" | _egrep_o '"VALUE": "[^"]*' | cut -d '"' -f 4)
if [ -n "$_hestia_id" ]; then
echo "$_hestia_id|$_hestia_value"
fi
done
}
-256
View File
@@ -1,256 +0,0 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_hetzner_info='Hetzner.com
Site: Hetzner.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_hetzner
Options:
HETZNER_Token API Token
Issues: github.com/acmesh-official/acme.sh/issues/2943
'
HETZNER_Api="https://dns.hetzner.com/api/v1"
######## Public functions #####################
# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
# Used to add txt record
# Ref: https://dns.hetzner.com/api-docs/
dns_hetzner_add() {
full_domain=$1
txt_value=$2
HETZNER_Token="${HETZNER_Token:-$(_readaccountconf_mutable HETZNER_Token)}"
if [ -z "$HETZNER_Token" ]; then
HETZNER_Token=""
_err "You didn't specify a Hetzner api token."
_err "You can get yours from here https://dns.hetzner.com/settings/api-token."
return 1
fi
#save the api key and email to the account conf file.
_saveaccountconf_mutable HETZNER_Token "$HETZNER_Token"
_debug "First detect the root zone"
if ! _get_root "$full_domain"; then
_err "Invalid domain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug "Getting TXT records"
if ! _find_record "$_sub_domain" "$txt_value"; then
return 1
fi
if [ -z "$_record_id" ]; then
_info "Adding record"
if _hetzner_rest POST "records" "{\"zone_id\":\"${HETZNER_Zone_ID}\",\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"value\":\"$txt_value\",\"ttl\":120}"; then
if _contains "$response" "$txt_value"; then
_info "Record added, OK"
_sleep 2
return 0
fi
fi
_err "Add txt record error${_response_error}"
return 1
else
_info "Found record id: $_record_id."
_info "Record found, do nothing."
return 0
# we could modify a record, if the names for txt records for *.example.com and example.com would be not the same
#if _hetzner_rest PUT "records/${_record_id}" "{\"zone_id\":\"${HETZNER_Zone_ID}\",\"type\":\"TXT\",\"name\":\"$full_domain\",\"value\":\"$txt_value\",\"ttl\":120}"; then
# if _contains "$response" "$txt_value"; then
# _info "Modified, OK"
# return 0
# fi
#fi
#_err "Add txt record error (modify)."
#return 1
fi
}
# Usage: full_domain txt_value
# Used to remove the txt record after validation
dns_hetzner_rm() {
full_domain=$1
txt_value=$2
HETZNER_Token="${HETZNER_Token:-$(_readaccountconf_mutable HETZNER_Token)}"
_debug "First detect the root zone"
if ! _get_root "$full_domain"; then
_err "Invalid domain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug "Getting TXT records"
if ! _find_record "$_sub_domain" "$txt_value"; then
return 1
fi
if [ -z "$_record_id" ]; then
_info "Remove not needed. Record not found."
else
if ! _hetzner_rest DELETE "records/$_record_id"; then
_err "Delete record error${_response_error}"
return 1
fi
_sleep 2
_info "Record deleted"
fi
}
#################### Private functions below ##################################
#returns
# _record_id=a8d58f22d6931bf830eaa0ec6464bf81 if found; or 1 if error
_find_record() {
unset _record_id
_record_name=$1
_record_value=$2
if [ -z "$_record_value" ]; then
_record_value='[^"]*'
fi
_debug "Getting all records"
_hetzner_rest GET "records?zone_id=${_domain_id}"
if _response_has_error; then
_err "Error${_response_error}"
return 1
else
_record_id=$(
echo "$response" |
grep -o "{[^\{\}]*\"name\":\"$_record_name\"[^\}]*}" |
grep "\"value\":\"$_record_value\"" |
while read -r record; do
# test for type and
if [ -n "$(echo "$record" | _egrep_o '"type":"TXT"')" ]; then
echo "$record" | _egrep_o '"id":"[^"]*"' | cut -d : -f 2 | tr -d \"
break
fi
done
)
fi
}
#_acme-challenge.www.domain.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=domain.com
# _domain_id=sdjkglgdfewsdfg
_get_root() {
domain=$1
i=1
p=1
domain_without_acme=$(echo "$domain" | cut -d . -f 2-)
domain_param_name=$(echo "HETZNER_Zone_ID_for_${domain_without_acme}" | sed 's/[\.\-]/_/g')
_debug "Reading zone_id for '$domain_without_acme' from config..."
HETZNER_Zone_ID=$(_readdomainconf "$domain_param_name")
if [ "$HETZNER_Zone_ID" ]; then
_debug "Found, using: $HETZNER_Zone_ID"
if ! _hetzner_rest GET "zones/${HETZNER_Zone_ID}"; then
_debug "Zone with id '$HETZNER_Zone_ID' does not exist."
_cleardomainconf "$domain_param_name"
unset HETZNER_Zone_ID
else
if _contains "$response" "\"id\":\"$HETZNER_Zone_ID\""; then
_domain=$(printf "%s\n" "$response" | _egrep_o '"name":"[^"]*"' | cut -d : -f 2 | tr -d \" | head -n 1)
if [ "$_domain" ]; then
_cut_length=$((${#domain} - ${#_domain} - 1))
_sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cut_length")
_domain_id="$HETZNER_Zone_ID"
return 0
else
return 1
fi
else
return 1
fi
fi
fi
_debug "Trying to get zone id by domain name for '$domain_without_acme'."
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
#not valid
return 1
fi
_debug h "$h"
_hetzner_rest GET "zones?name=$h"
if _contains "$response" "\"name\":\"$h\"" || _contains "$response" '"total_entries":1'; then
_domain_id=$(echo "$response" | _egrep_o "\[.\"id\":\"[^\"]*\"" | _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
HETZNER_Zone_ID=$_domain_id
_savedomainconf "$domain_param_name" "$HETZNER_Zone_ID"
return 0
fi
return 1
fi
p=$i
i=$(_math "$i" + 1)
done
return 1
}
#returns
# _response_error
_response_has_error() {
unset _response_error
err_part="$(echo "$response" | _egrep_o '"error":\{[^\}]*\}')"
if [ -n "$err_part" ]; then
err_code=$(echo "$err_part" | _egrep_o '"code":[0-9]+' | cut -d : -f 2)
err_message=$(echo "$err_part" | _egrep_o '"message":"[^"]+"' | cut -d : -f 2 | tr -d \")
if [ -n "$err_code" ] && [ -n "$err_message" ]; then
_response_error=" - message: ${err_message}, code: ${err_code}"
return 0
fi
fi
return 1
}
#returns
# response
_hetzner_rest() {
m=$1
ep="$2"
data="$3"
_debug "$ep"
key_trimmed=$(echo "$HETZNER_Token" | tr -d \")
export _H1="Content-TType: application/json"
export _H2="Auth-API-Token: $key_trimmed"
if [ "$m" != "GET" ]; then
_debug data "$data"
response="$(_post "$data" "$HETZNER_Api/$ep" "" "$m")"
else
response="$(_get "$HETZNER_Api/$ep")"
fi
if [ "$?" != "0" ] || _response_has_error; then
_debug "Error$_response_error"
return 1
fi
_debug2 response "$response"
return 0
}
+5
View File
@@ -40,6 +40,11 @@ _hostingde_apiKey() {
return 1
fi
# The endpoint is the base URL only; the api path is appended below.
# hosting.de's own docs show the full api URL, so strip it if pasted in.
# https://github.com/acmesh-official/acme.sh/issues/6896
HOSTINGDE_ENDPOINT="$(echo "$HOSTINGDE_ENDPOINT" | sed 's|/api/dns/v1/json||; s|/*$||')"
_saveaccountconf_mutable HOSTINGDE_APIKEY "$HOSTINGDE_APIKEY"
_saveaccountconf_mutable HOSTINGDE_ENDPOINT "$HOSTINGDE_ENDPOINT"
}
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_hostinger_info='Hostinger
Site: Hostinger.com
Domains: hostinger.nl
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hostinger
Options:
HOSTINGER_Token API Key
Issues: https://github.com/acmesh-official/acme.sh/issues/6831
Author: Sasha Reid <github@sasha.hackl.es>
'
HOSTINGER_Api="https://developers.hostinger.com/api/dns/v1/zones"
######## Public functions #####################
#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_hostinger_add() {
fulldomain=$1
txtvalue=$2
HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
if [ -z "$HOSTINGER_Token" ]; then
HOSTINGER_Token=""
_err "You didn't specify a Hostinger API Key yet."
_err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
return 1
fi
_saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_Token"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug "Getting existing records"
_hostinger_rest GET "${_domain}"
if [ -z "$response" ]; then
_err "Error"
return 1
fi
# For wildcard cert, the main root domain and the wildcard domain have the same txt subdomain name, so
# we can not use updating anymore.
# count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
# _debug count "$count"
# if [ "$count" = "0" ]; then
_info "Adding record"
if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [{\"content\":\"$txtvalue\"}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":false}"; then
if _contains "$response" "Request accepted"; then
_info "Added, OK"
return 0
elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
_contains "$response" 'DNS:4008'; then
_info "Already exists, OK"
return 0
else
_err "Add txt record error."
return 1
fi
fi
_err "Add txt record error."
return 1
}
#fulldomain txtvalue
dns_hostinger_rm() {
fulldomain=$1
txtvalue=$2
HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
if [ -z "$HOSTINGER_Token" ]; then
HOSTINGER_Token=""
_err "You didn't specify a Hostinger API Key yet."
_err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
return 1
fi
_saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_Token"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug "Getting existing records"
_hostinger_rest GET "${_domain}"
if [ -z "$response" ]; then
_err "Error"
return 1
fi
if _contains "$response" "\"name\":\"$_sub_domain\""; then
# Match the record, and make certain it is a TXT record for the domain not another type. Then remove our target record from the list
remaining_records=$(echo "$response" | _normalizeJson | _egrep_o '{"name":"'"$_sub_domain"'","records":\[[^]]+\],"ttl":[0-9]+,"type":"TXT"\}' | _egrep_o "\[.*\]" | sed -E 's#\{"content":"\\"'"$txtvalue"'\\"","is_disabled":false\},?##g')
if [ "$remaining_records" != "[]" ]; then
remaining_json=$(echo "$remaining_records" | _egrep_o '"content":"\\"[^}]+\\""' | sed -E 's/^(.*)$/{\1},/g' | tr -d '\n' | sed 's/,$//')
# We need to set the remaining records back to Hostinger, as we can't partially delete
_info "Removing $txtvalue from $_sub_domain by setting records to ${remaining_json}"
if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [${remaining_json}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":true}"; then
if _contains "$response" "Request accepted"; then
_info "Updated remaining records, OK"
return 0
elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
_contains "$response" 'DNS:4008'; then
_info "Already exists, OK"
return 0
else
_err "Add txt record error."
return 1
fi
fi
# Otherwise delete the TXT record that matches the subdomain
else
if ! _hostinger_rest DELETE "$_domain" "{\"filters\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\"}]}"; then
_err "Delete record error."
return 1
fi
fi
echo "$response" | grep "Request accepted" >/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
}
+201 -125
View File
@@ -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 <<EOF
$records
$_hostup_find_records
EOF
return 1
@@ -361,22 +441,22 @@ _hostup_json_extract() {
input="${2:-$line}"
# First try to extract quoted values (strings)
quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\":\"[^\"]*\"" | _head_n 1)"
quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*\"[^\"]*\"" | _head_n 1)"
if [ -n "$quoted_match" ]; then
printf "%s" "$quoted_match" |
cut -d : -f2- |
sed 's/^"//' |
sed 's/"$//' |
sed 's/^[ ]*"//' |
sed 's/"[ ]*$//' |
sed 's/\\"/"/g'
return 0
fi
# Fallback for unquoted values (e.g., numeric IDs)
unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\":[^,}]*" | _head_n 1)"
unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*[^,}]*" | _head_n 1)"
if [ -n "$unquoted_match" ]; then
printf "%s" "$unquoted_match" |
cut -d : -f2- |
tr -d '", ' |
tr -d '", ' |
tr -d '\r\n'
return 0
fi
@@ -391,58 +471,56 @@ _hostup_json_escape() {
_hostup_record_key() {
zone_id="$1"
domain="$2"
txtvalue="$3"
safe_zone="$(printf "%s" "$zone_id" | sed 's/[^A-Za-z0-9]/_/g')"
safe_domain="$(printf "%s" "$domain" | _lower_case | sed 's/[^a-z0-9]/_/g')"
if [ -n "$txtvalue" ]; then
safe_value="$(printf "%s" "$txtvalue" | sed 's/[^A-Za-z0-9]/_/g')"
printf "%s_%s_%s" "$safe_zone" "$safe_domain" "$safe_value"
return 0
fi
printf "%s_%s" "$safe_zone" "$safe_domain"
}
_hostup_save_record_id() {
zone_id="$1"
domain="$2"
record_id="$3"
key="$(_hostup_record_key "$zone_id" "$domain")"
_saveaccountconf_mutable "HOSTUP_RECORD_$key" "$record_id"
}
_hostup_get_saved_record_id() {
zone_id="$1"
domain="$2"
key="$(_hostup_record_key "$zone_id" "$domain")"
_readaccountconf_mutable "HOSTUP_RECORD_$key"
}
_hostup_clear_record_id() {
zone_id="$1"
domain="$2"
key="$(_hostup_record_key "$zone_id" "$domain")"
txtvalue="$3"
key="$(_hostup_record_key "$zone_id" "$domain" "$txtvalue")"
_clearaccountconf_mutable "HOSTUP_RECORD_$key"
}
_hostup_extract_record_id() {
record_id="$(_hostup_json_extract "id" "$1")"
if [ -n "$record_id" ]; then
printf "%s" "$record_id"
return 0
legacy_key="$(_hostup_record_key "$zone_id" "$domain")"
if [ "$legacy_key" != "$key" ]; then
_clearaccountconf_mutable "HOSTUP_RECORD_$legacy_key"
fi
printf "%s" "$1" | _egrep_o '"id":[0-9]+' | _head_n 1 | cut -d: -f2
}
_hostup_delete_record_by_id() {
zone_id="$1"
record_id="$2"
if ! _hostup_rest "DELETE" "/dns/zones/$zone_id/records/$record_id" ""; then
return 1
fi
if ! _contains "$_hostup_response" '"success":true'; then
if ! _hostup_rest "DELETE" "/dns-zones/$zone_id/records/$record_id" ""; then
return 1
fi
return 0
}
_hostup_problem_error() {
problem_code="$(_hostup_json_extract "code" "$_hostup_response")"
problem_detail="$(_hostup_json_extract "detail" "$_hostup_response")"
if [ -n "$problem_detail" ]; then
if [ -n "$problem_code" ]; then
_err "HostUp API error ($problem_code): $problem_detail"
else
_err "HostUp API error: $problem_detail"
fi
return 0
fi
return 1
}
_hostup_rest() {
method="$1"
route="$2"
@@ -451,8 +529,7 @@ _hostup_rest() {
_hostup_response=""
export _H1="Authorization: Bearer $HOSTUP_API_KEY"
export _H2="Content-Type: application/json"
export _H3="Accept: application/json"
export _H2="Accept: application/json"
if [ "$method" = "GET" ]; then
_hostup_response="$(_get "$HOSTUP_API_BASE$route")"
@@ -464,7 +541,6 @@ _hostup_rest() {
unset _H1
unset _H2
unset _H3
if [ "$ret" != "0" ]; then
_err "HTTP request failed for $route"
@@ -478,23 +554,23 @@ _hostup_rest() {
case "$http_status" in
200 | 201 | 204) return 0 ;;
401)
_err "HostUp API returned 401 Unauthorized. Check HOSTUP_API_KEY scopes and IP restrictions."
_hostup_problem_error || _err "HostUp API returned 401 Unauthorized. Check HOSTUP_API_KEY scopes and IP restrictions."
return 1
;;
403)
_err "HostUp API returned 403 Forbidden. The API key lacks required DNS scopes."
_hostup_problem_error || _err "HostUp API returned 403 Forbidden. The API key lacks required DNS/domain scopes."
return 1
;;
404)
_err "HostUp API returned 404 Not Found for $route"
_hostup_problem_error || _err "HostUp API returned 404 Not Found for $route"
return 1
;;
429)
_err "HostUp API rate limit exceeded. Please retry later."
_hostup_problem_error || _err "HostUp API rate limit exceeded. Please retry later."
return 1
;;
*)
_err "HostUp API request failed with status $http_status"
_hostup_problem_error || _err "HostUp API request failed with status $http_status"
return 1
;;
esac
+16 -4
View File
@@ -7,11 +7,11 @@ Options:
HUAWEICLOUD_Username Username
HUAWEICLOUD_Password Password
HUAWEICLOUD_DomainName DomainName
HUAWEICLOUD_Region Region. E.g. "cn-north-4". Optional, defaults to "ap-southeast-1".
Issues: github.com/acmesh-official/acme.sh/issues/3265
'
iam_api="https://iam.myhuaweicloud.com"
dns_api="https://dns.ap-southeast-1.myhuaweicloud.com" # Should work
######## Public functions #####################
@@ -30,6 +30,7 @@ dns_huaweicloud_add() {
HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}"
HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}"
HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}"
HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}"
# Check information
if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then
@@ -37,8 +38,11 @@ dns_huaweicloud_add() {
return 1
fi
_huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}"
dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com"
unset token # Clear token
token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")"
token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")"
if [ -z "${token}" ]; then # Check token
_err "dns_api(dns_huaweicloud): Error getting token."
return 1
@@ -65,6 +69,9 @@ dns_huaweicloud_add() {
_saveaccountconf_mutable HUAWEICLOUD_Username "${HUAWEICLOUD_Username}"
_saveaccountconf_mutable HUAWEICLOUD_Password "${HUAWEICLOUD_Password}"
_saveaccountconf_mutable HUAWEICLOUD_DomainName "${HUAWEICLOUD_DomainName}"
if [ -n "${HUAWEICLOUD_Region}" ]; then
_saveaccountconf_mutable HUAWEICLOUD_Region "${HUAWEICLOUD_Region}"
fi
return 0
}
@@ -81,6 +88,7 @@ dns_huaweicloud_rm() {
HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}"
HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}"
HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}"
HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}"
# Check information
if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then
@@ -88,8 +96,11 @@ dns_huaweicloud_rm() {
return 1
fi
_huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}"
dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com"
unset token # Clear token
token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")"
token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")"
if [ -z "${token}" ]; then # Check token
_err "dns_api(dns_huaweicloud): Error getting token."
return 1
@@ -298,6 +309,7 @@ _get_token() {
_username=$1
_password=$2
_domain_name=$3
_region_name=$4
_debug "Getting Token"
body="{
@@ -318,7 +330,7 @@ _get_token() {
},
\"scope\": {
\"project\": {
\"name\": \"ap-southeast-1\"
\"name\": \"${_region_name}\"
}
}
}

Some files were not shown because too many files have changed in this diff Show More