Compare commits

..

7 Commits
master ... dev

Author SHA1 Message Date
wurzelpanzer
ef7b2d3c2e dns_easydns: match the TXT record by its rdata when removing (#7199)
dns_easydns_rm() picked the first id in the search response and ignored
$txtvalue. When two challenge records exist under the same host - for
example when example.com and *.example.com are issued as separate
certificates - a concurrent run's record could be deleted instead of
our own.

Select the record by its rdata instead, following the dns_cf.sh
convention of matching name + value. tr '{' '\n' puts one record per
line, so both _egrep_o branches - egrep -o and the BRE sed fallback -
return the same single id. Without it the sed fallback would return
only the last match, since .* is greedy.

An empty record_id is now treated as "nothing to remove" and returns 0,
rather than being reported as an error.

Also add the credential check that _rm was missing. It deliberately
does not call _saveaccountconf_mutable, as _add already does that.

Co-authored-by: wurzelpanzer <wurzelpanzer@maximolider.net>
2026-08-22 10:15:52 +08:00
Fabian Lesniak
b481ffb81b Merge pull request #5194 from flesniak/myloc
Add dnsapi script for myloc.de/webtropia.com
2026-08-22 10:13:58 +08:00
neil
b1a8eb1c95 installcronjob: match the stale cron path without grep -F 2026-08-20 08:40:49 +08:00
neil
e0c0297ba3 fix cronjob 2026-08-19 22:50:03 +08:00
neil
1cbd223386 deploy/unifios: document UniFi OS hardware support, not just self-hosted
The certificate REST API this hook drives is UniFi OS's own, not
specific to the self-hosted UniFi OS Server: user reports confirm it on
a UDM Pro (UniFi OS 5.1.26) and a UCG Fiber (5.0.16). Reframe the scope
around the endpoint rather than the product line, state that the choice
between unifi and unifios is local/SSH file access vs remote REST API,
and note that the management port is 11443 on UniFi OS Server but 443
on hardware, so DEPLOY_UNIFIOS_HOST must be set there.
2026-08-17 13:42:10 +08:00
neil
5180911929 fix https://github.com/acmesh-official/acme.sh/issues/7195#issuecomment-5281002963 2026-08-14 12:15:15 +08:00
neil
5ff7f0a4e7 Mirror the tag object, not the commit, in vtag.yml
The v-prefixed mirror was created from github.sha, so for an annotated or
signed tag it would point at the commit and drop the signature: "git
verify-tag v3.1.3" fails with "cannot verify a non-tag object of type
commit" while "git verify-tag 3.1.3" succeeds. Resolve refs/tags/<tag>
and mirror whatever object it points at instead, which keeps the current
behaviour for lightweight tags. Also move the workflow expressions into
env instead of interpolating them into the shell command.
2026-08-14 11:53:43 +08:00
7 changed files with 275 additions and 42 deletions

View File

@@ -23,10 +23,22 @@ jobs:
- name: Create the v-prefixed tag - name: Create the v-prefixed tag
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: | run: |
if gh api "repos/${{ github.repository }}/git/ref/tags/v${{ github.ref_name }}" >/dev/null 2>&1; then if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v${{ github.ref_name }} already exists, nothing to do." echo "Tag v$TAG already exists, nothing to do."
exit 0 exit 0
fi fi
gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/tags/v${{ github.ref_name }}" -f sha="${{ github.sha }}" # Mirror the object the pushed tag actually points at: the commit
echo "Created tag v${{ github.ref_name }} -> ${{ github.sha }}" # for a lightweight tag, the tag object itself for an annotated or
# signed one. Pointing the mirror at the commit would strip the
# signature, so "git verify-tag v3.1.3" would fail while
# "git verify-tag 3.1.3" succeeds.
sha="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.sha)"
if [ -z "$sha" ] || [ "$sha" = "null" ]; then
echo "Could not resolve refs/tags/$TAG"
exit 1
fi
gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
echo "Created tag v$TAG -> $sha"

View File

@@ -227,6 +227,31 @@ Cron entry example:
acme.sh -h acme.sh -h
``` ```
#### 🔏 Verify a Release
Release tags from `3.1.5` on are signed with the maintainer's SSH key. The
signing happens on the maintainer's machine, so the private key is never
available to CI. The public half is [`allowed_signers`](allowed_signers) in
this repository. From a clone:
```bash
git config gpg.ssh.allowedSignersFile allowed_signers
```
```bash
git verify-tag 3.1.5
```
The signature covers the tag object, which pins the commit and therefore the
whole tree, so a good signature verifies every file at that release and no
separate tarball checksum is needed. Build a tarball from the verified tag:
```bash
git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
```
> ⚠️ Tags up to `3.1.4` predate the signing key and are unsigned.
--- ---
### 2⃣ Issue a Certificate ### 2⃣ Issue a Certificate

49
acme.sh
View File

@@ -7072,6 +7072,30 @@ _uninstall_win_taskscheduler() {
fi fi
} }
#binpath
#Reads a crontab listing from stdin, prints it without the acme.sh cron
#entries that call binpath.
_filter_cron_bin() {
_fcb_bin="$1"
if [ -z "$_fcb_bin" ]; then
cat
return
fi
#a case pattern with a quoted variable matches binpath literally, which
#grep cannot do portably: Solaris /usr/bin/grep has no -F, and as a regex
#the dot of ~/.acme.sh would stand for any character
while IFS= read -r _fcb_line || [ -n "$_fcb_line" ]; do
case "$_fcb_line" in
*"$_fcb_bin --cron"*)
_debug3 "Dropping cron entry" "$_fcb_line"
;;
*)
echo "$_fcb_line"
;;
esac
done
}
#confighome #confighome
installcronjob() { installcronjob() {
_c_home="$1" _c_home="$1"
@@ -7141,7 +7165,26 @@ installcronjob() {
return 1 return 1
fi fi
fi fi
if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then #An entry that calls LE_WORKING_DIR/PROJECT_ENTRY is dead once that copy is
#gone: ACME_PACKAGED installs never write it, and the package manager
#removes it when it takes over. The entry below would then keep the install
#from adding a working one and cron would fail silently every day, so drop
#the stale entries first.
_cron_stale=""
if [ ! -f "$LE_WORKING_DIR/$PROJECT_ENTRY" ] && [ "$_cron_entries" ]; then
_cron_kept="$(echo "$_cron_entries" | _filter_cron_bin "\"$LE_WORKING_DIR\"/$PROJECT_ENTRY")"
if [ "$_cron_kept" != "$_cron_entries" ]; then
_info "Removing the cron job that calls the missing $LE_WORKING_DIR/$PROJECT_ENTRY"
_cron_entries="$_cron_kept"
_cron_stale=1
fi
fi
#>/dev/null: grep would print the matching crontab line to the console
_cron_add=""
if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron" >/dev/null; then
_cron_add=1
fi
if [ "$_cron_add" ] || [ "$_cron_stale" ]; then
if _exists uname && uname -a | grep SunOS >/dev/null; then if _exists uname && uname -a | grep SunOS >/dev/null; then
_CRONTAB_STDIN="$_CRONTAB --" _CRONTAB_STDIN="$_CRONTAB --"
else else
@@ -7151,7 +7194,9 @@ installcronjob() {
if [ "$_cron_entries" ]; then if [ "$_cron_entries" ]; then
echo "$_cron_entries" echo "$_cron_entries"
fi fi
echo "$_cron_entry" if [ "$_cron_add" ]; then
echo "$_cron_entry"
fi
} | $_CRONTAB_STDIN } | $_CRONTAB_STDIN
fi fi
if [ "$?" != "0" ]; then if [ "$?" != "0" ]; then

20
allowed_signers Normal file
View File

@@ -0,0 +1,20 @@
# acme.sh release signing key.
#
# Release tags are signed with this key. Its private half is held by the
# maintainer and is never available to CI, so a compromise of the build
# pipeline cannot produce a tag that verifies against this file.
#
# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE
#
# To verify a release tag, from a clone of this repository:
#
# git config gpg.ssh.allowedSignersFile allowed_signers
# git verify-tag 3.1.5
#
# A good signature covers the tag object, which pins the commit, which pins
# the whole tree -- so verifying the tag verifies every file at that
# release. Build a tarball from the verified tag with:
#
# git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
#
github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB

View File

@@ -1,24 +1,29 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# Deploy hook for UniFi OS Server (self-hosted). # Deploy hook for UniFi OS, via the certificate REST API.
# #
# Supports: # Works against any UniFi OS whose management UI exposes
# - UniFi OS Server on macOS # /api/userCertificates. Confirmed on:
# - UniFi OS Server on Linux # - UniFi OS Server (the separately-installed, self-hosted application)
# - UniFi OS Server on Windows should also work (runs under WSL2), but # on macOS and on Linux. Windows should also work (it runs under
# has not been tested. # WSL2), but has not been tested.
# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on
# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916).
# No lower version bound is claimed -- if the UI has a certificate
# manager, this hook should work.
# #
# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). # `unifios` vs `unifi`: the split is the access method, not the product
# line. `unifi` writes files / a Java keystore and needs local or SSH
# access on the device; this hook drives the same REST API the web UI
# uses and works remotely. Use `unifi` where acme.sh runs on the device
# itself, this hook where it does not.
# #
# This is a different product from the Cloud Key / UDM hardware and # The API is served on the management port, which differs per install:
# self-hosted Unifi Controller covered by the `unifi` deploy hook above # UniFi OS Server listens on 11443 (hence the default below), while
# (that hook already covers Cloud Key running UnifiOS v2.0.0+/Gen2/2+) -- # UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to
# this hook targets the separately-installed, self-hosted "UniFi OS Server" # "https://<host>" there.
# application instead, which stores certificates in its own Postgres
# database via a REST API rather than a Java keystore, so the `unifi`
# hook's approach does not apply here.
# #
# UniFi OS Server exposes a REST API on its management port (default # Endpoints used, all as the web UI itself calls them:
# 11443) that its own web UI uses for certificate management:
# POST /api/auth/login - session login (cookie + JWT) # POST /api/auth/login - session login (cookie + JWT)
# GET /api/userCertificates - list uploaded certificates # GET /api/userCertificates - list uploaded certificates
# POST /api/userCertificates - upload a new certificate # POST /api/userCertificates - upload a new certificate
@@ -41,8 +46,9 @@
# Uses core acme.sh helpers throughout (_post/_get, _json_encode, # Uses core acme.sh helpers throughout (_post/_get, _json_encode,
# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or # _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or
# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all # python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all
# honored the same as every other hook. The management API's cert is # honored the same as every other hook. The management API's cert may be
# self-signed (it's a management-only port, not meant for public exposure), # 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 # 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 # 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 # verification for the rest of the acme.sh run, e.g. the connection to the
@@ -63,9 +69,11 @@
# #
# Settings: # Settings:
# DEPLOY_UNIFIOS_HOST - base URL of the management API # DEPLOY_UNIFIOS_HOST - base URL of the management API
# (default: "https://localhost:11443") # (default: "https://localhost:11443", i.e. a UniFi OS Server on the
# DEPLOY_UNIFIOS_USERNAME - UniFi OS Server admin username (required) # same machine as acme.sh; set it to "https://<host>" for UniFi OS
# DEPLOY_UNIFIOS_PASSWORD - UniFi OS Server admin password (required) # hardware or any remote target)
# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required)
# DEPLOY_UNIFIOS_PASSWORD - UniFi OS admin password (required)
# #
# Example: # Example:
# export DEPLOY_UNIFIOS_USERNAME="acmeuser" # export DEPLOY_UNIFIOS_USERNAME="acmeuser"

View File

@@ -75,6 +75,11 @@ dns_easydns_rm() {
EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}" EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}"
EASYDNS_Key="${EASYDNS_Key:-$(_readaccountconf_mutable EASYDNS_Key)}" 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" _debug "First detect the root zone"
if ! _get_root "$fulldomain"; then if ! _get_root "$fulldomain"; then
_err "invalid domain" _err "invalid domain"
@@ -91,24 +96,21 @@ dns_easydns_rm() {
return 1 return 1
fi fi
count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2) record_id=$(printf "%s\n" "$response" | tr '{' '\n' | grep "\"rdata\":\"$txtvalue\"" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \")
_debug count "$count" _debug "record_id" "$record_id"
if [ "$count" = "0" ]; then
if [ -z "$record_id" ]; then
_info "Don't need to remove." _info "Don't need to remove."
else return 0
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"
fi 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 ################################## #################### Private functions below ##################################

121
dnsapi/dns_myloc.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_myloc_info='myloc.de
Site: myloc.de
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_myloc
Issues: github.com/acmesh-official/acme.sh/issues/5193
Options:
MYLOC_token API token
'
# updater for the (experimental) API of myloc.de / webtropia.com
# usage: acme.sh --issue -d example.com --dns dns_myloc --dnssleep 60
# API documentation at https://apidoc.myloc.de/
# As the API does not support quering available zones yet, the zone for a given
# fulldomain is searched recursively by removing prefixes one-by-one.
_myloc_api="https://zkm.myloc.de/api"
#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_myloc_add() {
_myloc_fulldomain=$1
_myloc_txtvalue=$2
_myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}"
if [ -z "$_myloc_token" ]; then
_err "You didn't specify MYLOC_token"
return 1
fi
export _H1="Content-Type: application/json"
export _H2="Authorization: Bearer $_myloc_token"
_myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")"
if [ $? -ne 0 ]; then
return 1
fi
# save token if the previous request was successful
_saveaccountconf_mutable MYLOC_token "$_myloc_token"
_info "Adding record"
_myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\",\"ttl\":60}"
_debug "add record request $_myloc_record to ${_myloc_api}/dns/zone/${_myloc_zone}"
_myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "PUT")"
_myloc_status=$?
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug "add record response $_code $_myloc_response"
if [ $_myloc_status -ne 0 ]; then
_err "Add txt record curl error."
return 1
elif [ "$_code" = "204" ] && [ -z "$_myloc_response" ]; then
_info "Add txt record success"
return 0
elif _contains "$_myloc_response" "error" || _contains "$_myloc_response" "unexpected"; then
_err "Add txt record api error."
return 1
else
_err "Add txt record unknown response."
return 1
fi
}
#_myloc_fulldomain _myloc_txtvalue
dns_myloc_rm() {
_myloc_fulldomain=$1
_myloc_txtvalue=$2
_myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}"
if [ -z "$_myloc_token" ]; then
_err "You didn't specify MYLOC_token"
return 1
fi
export _H1="Content-Type: application/json"
export _H2="Authorization: Bearer $_myloc_token"
_myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")"
if [ $? -ne 0 ]; then
return 1
fi
# save token if the previous request was successful
_saveaccountconf_mutable MYLOC_token "$_myloc_token"
_info "Deleting record for $_myloc_fulldomain"
_myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\"}"
_debug "delete record $_myloc_record"
_myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "DELETE")"
_myloc_status=$?
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug "delete response $_code $_myloc_response"
if [ $_myloc_status -ne 0 ] || [ "$_code" != "204" ]; then
_err "Failed to delete record"
return 1
fi
return 0
}
# Usage: _myloc_get_zone "_acme-challenge.sub1.mydomain.com"
# Subdomains are walked until a zone is found or TLD is reached
_myloc_get_zone() {
_myloc_zone=$1
while [ "${_myloc_zone#*.}" != "$_myloc_zone" ]; do
_debug "Get zone trying $_myloc_zone"
_myloc_response="$(_get "${_myloc_api}/dns/zone/${_myloc_zone}")"
_myloc_status=$?
_debug "Get zone response $_myloc_response"
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
if [ $_myloc_status -eq 0 ] && [ "$_code" = "200" ]; then
_debug "Get zone success for $_myloc_zone"
echo "${_myloc_zone}"
return 0
fi
_myloc_zone="${_myloc_zone#*.}"
done
_err "Get zone failed for all candidates"
return 1
}