Compare commits

...

71 Commits
3.1.4 ... dev

Author SHA1 Message Date
neil
94d2f62626 fix 2026-09-04 15:51:12 +08:00
neil
41b6371afa minor 2026-09-04 15:27:35 +08:00
neil
f2b0484137 add trigger 2026-09-04 15:27:06 +08:00
Pablo
943cc0cf25 unifios: extract JSON-split helper, document RSA/ECC name collision (#7200)
* Extract _uos_split_json helper, document RSA/ECC name-prefix collision

Per neilpang's non-blocking review notes on #7184: the _normalizeJson +
split-into-lines block was duplicated at both call sites, now shared via
_uos_split_json(). Also documents (without changing behavior, since it's
harmless today) that an RSA and ECC deploy of the same domain share the
generated name's prefix, each removing the other's entry on cleanup --
citing haproxy.sh/lighttpd.sh's existing .rsa/.ecdsa suffix pattern as
the fix if this ever needs addressing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Replace grep -F with a portable matcher, fix RSA/ECC name collision

grep -F isn't on Solaris, and dropping it naively breaks matching:
wildcard domains and dots collide as regex. _uos_grep_literal replaces
both call sites with a case-based literal match instead.

_uos_name now includes the key type, so RSA and ECC deploys of the
same domain no longer share a cleanup scope.

Per neilpang's review on #7200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix echo's \n handling in _uos_grep_literal, drop unneeded Le_Keylength guard

echo does not behave consistently across different environments. dash
interprets literal \n in a line, splitting it.  printf '%s\n' does not and matches
_uos_split_json's existing pattern. printf behaves more consistently across
environments and is generally preferred over echo.

Le_Keylength guard was a no-op and didn't help under set -u either;
_isEccKey already handles empty. Kept the shellcheck warning suppressed
inline instead of assigning to a core Le_* var.

Per neilpang's review on #7200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:25:22 +08:00
MBWhitestone
fa8dc180aa fix: dynv6 record parsing (#7197) 2026-09-04 14:51:30 +08:00
Mashiro
195cbd5d09 Merge pull request #7220 from moezx/dev
Add AK & SK based Huawei Cloud DNS API
2026-09-04 14:49:50 +08:00
neil
7b5a902755 Let an explicit --days or --valid-to outrank the ARI window
ARI has overridden Le_NextRenewTime unconditionally since 3.1.4, so a user
who passed --days never got the schedule they asked for, and --valid-to was
guarded at issue time but not on the renewal check: the guard survived one
run before the next cron rewrote it and saved it back.

An explicit --days or --valid-to now pins the schedule. The window is still
taken when it is earlier than what the user asked for, so a CA can pull an
urgent renewal forward but can never push a pinned renewal back.
Le_RenewalDays is only written to the domain conf when --days was actually
passed, so its presence there is what marks a schedule as pinned.

A fixed-date --valid-to opts out of ARI entirely: that cert is not renewed
automatically at all, so pulling it forward would change what it does, not
just when it renews.

Both call sites go through the new _calc_ari_renew_time.
2026-09-01 20:26:34 +08:00
neil
4a3f8b5dea Wait for the order to be ready instead of failing the finalize 2026-09-01 18:59:56 +08:00
neil
08ac83459d Treat alreadyRevoked as a successful revocation 2026-08-31 09:24:41 +08:00
neil
2b7487ba72 Back off between retries instead of a flat two seconds 2026-08-30 21:24:02 +08:00
neil
19efdf269a Retry a gateway error from the CA instead of failing the order 2026-08-30 20:47:24 +08:00
neil
cdca555cad Add OPNsense and HardenedBSD to CI 2026-08-30 17:46:48 +08:00
neil
cce4a28b99 Fix the standalone port check on the bsds, macos and haiku 2026-08-30 17:39:57 +08:00
neil
4756183e30 Never truncate the conf file when a saved value breaks the rewrite sed
A value holding a backslash-digit sequence (a backreference to sed) or an
embedded line break made _setopt's replace command fail after the shell
had already truncated the conf file, wiping the whole domain conf; the
next renewal then fails with an empty Le_API and no validation method.
Same class as #2426, which escaped only '&' and '|'.

Escape the backslash too, write the sed output back only when sed
succeeds, reject values holding a line break, and rewrite the file with
printf instead of echo in the append path and in _clear_conf: dash's
builtin echo interprets backslash escapes and corrupted such values on
every rewrite.

https://github.com/acmesh-official/acme.sh/issues/7213
2026-08-30 16:02:57 +08:00
neil
6e1deacac0 dns_azure: never read or persist AZUREDNS_BEARERTOKEN from account.conf
Versions up to 3.0.9 cached the internally-acquired access token as
SAVED_AZUREDNS_BEARERTOKEN. 3.1.0 repurposed that variable for
user-supplied bearer tokens, so after an upgrade the stale cached token
was read back as if user-supplied, skipped the refresh path, and failed
renewals with 401 forever once expired.

A bearer token is short-lived, so persisting it is never useful: take it
from the environment only, and clear any stale saved value on the next
run.

fix https://github.com/acmesh-official/acme.sh/issues/7218
2026-08-30 15:42:27 +08:00
skysaint
c6cd844986 dns_jd: upgrade to JD Cloud v2 API (#7207) 2026-08-30 15:33:32 +08:00
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
Pablo
41bdd4cd0e Add UniFi OS Server deploy hook (#7184)
* Add UniFi OS Server deploy hook

Uses UniFi OS Server's local REST API (login, list, upload, activate,
remove superseded) since it stores certificates in its own Postgres
database rather than flat config files, unlike the Cloud Key/UDM
hardware covered by the existing unifi deploy hook. Tested against
real instances on both macOS and Ubuntu 26.04 (self-hosted, remote).

* Address review: portable sed/grep, scoped HTTPS_INSECURE, fingerprint matching

- Replace GNU-only \n in sed replacement with a portable literal newline
  (matches dnsapi/dns_cpanel_uapi.sh, dnsapi/dns_glesys.sh); pipe the
  list response through _normalizeJson first for consistent formatting.
- Use grep -F for the domain-name match instead of an unescaped BRE --
  a wildcard cert name (*.example.com) broke the regex.
- Drop \W (undocumented, GNU-only) from the cookie lookup in favor of
  an anchored `^Set-Cookie: *NAME=` match.
- Scope HTTPS_INSECURE=1 inside the hook (matches deploy/proxmoxve.sh,
  deploy/fritzbox.sh) instead of requiring the caller to export it for
  the whole acme.sh run, which would also disable verification for the
  connection to the ACME CA.
- On a duplicate-certificate response, match the existing entry by
  fingerprint instead of taking the first name match -- with more than
  one stale entry for a domain, the wrong one could get activated.
- Check the list endpoint's response code before proceeding.
- Save username/password with the "base64" flag (matches
  deploy/synology_dsm.sh) since _save_conf wraps values in unescaped
  single quotes.

* Rework certificate handling: unique names per upload, drop cleanup

Testing against a real UniFi OS Server showed the server enforces name
uniqueness independently of fingerprint uniqueness, and that activation is
exclusive server-wide regardless of name/domain. A unique name per upload
avoids the name-collision path entirely (previously only handled as a
retry-of-identical-content edge case), and removes the need for the
post-hoc cleanup loop, which risked deleting the wrong entry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Shorten generated certificate name to Unix epoch seconds

Real-hardware testing showed the UniFi OS Server certificate list's name
column is fixed-width and doesn't wrap, so a full human-readable timestamp
overlaps the Expires column and makes both unreadable. Epoch seconds are
still short enough to fit while remaining unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add scoped cleanup of old certificate entries, use _time helper

Per review: dropping cleanup entirely went further than the original bug
required, and left old entries (each holding a private key) accumulating
indefinitely. Since every upload now gets a name unique to its domain and
run, cleanup can safely target only entries whose name starts with that
domain -- entries this hook itself created -- excluding the one just
activated. Also swaps date +%s for the core _time helper, and rewrote the
design comments to make them clearer and match the current behavior
instead of the pre-redesign one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:57:44 +08:00
XuChao
a89ba9c2e5 add deploy hook support for ikuai (#6456)
* add deploy hook support for ikuai

* fix shellcheck warn and shfmt the code

* 1.fix config load 2.use _secre_debug2 to log password 3.use fullchain to deploy 4.fix hardcode id 5.change shebang

* fix miss ; after cookie

* 1.fix shfmt ; 2.fix IKUAI_CERT_ID conf load; 3.correct IKUAI_CERT_ID description

* fix some log msg

* fix shfmt
2026-08-13 14:52:48 +08:00
Zhiwei Liang
2e2782f0d8 Remove deprecated Linode API v3 DNS plugin (#7054)
Signed-off-by: Zhiwei Liang <zhiwei.liang@zliang.me>
2026-08-12 22:47:58 +08:00
neil
4a3bc2c919 Fix dns_netcup reporting a bogus 4013 instead of the real zone error
The zone lookup walked the challenge name from the right and ended up
asking netcup for the full "_acme-challenge.<domain>" as a zone name.
That can never be a zone, so netcup answered 4013 "Validation Error",
which replaced the real 5028 "The zone <domain> could not be found" as
the error shown to the user.

Stop one label short of the full name, and fail explicitly when no zone
matched, reporting the last API response plus what to check. Before, a
run where every candidate returned 5028 fell through to logout and
returned success.
2026-08-10 10:00:26 +08:00
neil
05367d3598 Listen on both IPv4 and IPv6 in standalone mode by default
socat binds a single family unless told which one: up to 1.7.x the
default IP version for TCP-LISTEN is 4, and 1.8.0 made it "no
preference", which resolves to whatever getaddrinfo and bindv6only
happen to give. So an order carrying both an IPv4 and an IPv6
identifier could never pass both http-01 challenges.

Bind one socket per family instead, with ipv6only on the IPv6 one so
the two do not collide. IPv4-mapped IPv6 addresses are not a portable
alternative, OpenBSD does not support them at all. The IPv6 listener
is best effort, a host without IPv6 still gets the IPv4 one. The
python fallback does the same. --listen-v4 and --listen-v6 keep
forcing a single family, and passing both now means both.

Le_Listen_V4 and Le_Listen_V6 were mutually exclusive in the domain
conf, which silently dropped one of them on renewal, and
_starttlsserver let -4 win when both were set.

Fixes #7185
2026-08-08 13:09:30 +08:00
Alexey Morozov
f1cbba05f6 feat: added nexdns dnsapi (#7170)
* feat: added nexdns dnsapi

Adds a DNS-01 hook for NexDNS, an authoritative DNS service with a REST API.

dns_nexdns_add walks the label list to find the zone that owns the challenge
name and creates the TXT record in it. dns_nexdns_rm lists the TXT records at
that name, picks the one carrying exactly this challenge value and deletes it
by id, so a wildcard and its base domain do not remove each other's record.

A 429 is waited out and the request retried, in the shape dns_hetznercloud.sh
and dns_bunny.sh already use.

* dns_nexdns: cap the rate-limit wait, judge success by status, add the tracking issue
2026-08-07 09:22:28 +08:00
neil
f67be78ff4 Fix dns_namecheap ignoring IsOurDNS when matching the root zone
_get_root_by_getList() matched the candidate suffix as an unanchored
substring of the whole domains.getList response and never looked at the
IsOurDNS attribute. A domain parked on Namecheap's webhosting DNS is
listed with IsOurDNS="false", yet it was still accepted as the root zone,
so _get_root() returned success and the domains.dns.getHosts probe that
would have found the real zone never ran. Every following getHosts call
was then refused with error 2030288 "not using proper DNS servers" and
the challenge failed with "invalid tld".

Match the exact <Domain Name="..."> entry instead and require
IsOurDNS="true", so a subdomain delegated to Namecheap BasicDNS/FreeDNS
under a parent that is not on Namecheap DNS now resolves to its own zone.
Matching the entry exactly also drops the old substring/regex match, in
which the dots of a domain matched any character.

Fixes #7178
2026-08-06 19:36:26 +08:00
neil
9aad4dcbd5 Fix multideploy MULTIDEPLOY_FILENAME conf read and allow an absolute path
_getdeployconf assigns and exports the variable, it does not print the
value, so wrapping it in a command substitution ran it in a subshell and
always yielded an empty string. A MULTIDEPLOY_FILENAME saved by an
earlier run was therefore never restored on renewal and the hook
silently fell back to multideploy.yml. Call it the same way every other
deploy hook does.

Also treat a MULTIDEPLOY_FILENAME starting with '/' as an absolute path
instead of always resolving it under DOMAIN_PATH, so one deploy file can
live outside the certificate directory and be shared by all domains.
Names without a leading '/' keep resolving under DOMAIN_PATH as before.
2026-08-05 23:01:54 +08:00
neil
603a126a7c Fix synology_dsm logging out after the temp admin is already deleted
_temp_admin_cleanup ran before _logout, so the logout request carried
the session id of an account synouser had already removed and DSM kept
the orphaned entry in Connected Users. Swap the order in both terminal
branches, and add the missing _logout to the two post-login error paths
(CRT list failure, certificate not found without SYNO_CREATE).

_logout overwrites the global $response, so the upload-failure branch
prints its error message before calling it.

Reported by @Bertl75 in #7174
2026-08-05 19:56:13 +08:00
neil
5e6c263211 Fix empty finalize URL when resuming a saved DNS-manual order
The decision to resume a pending order is keyed on Le_Vlist, but the
decision to keep Le_OrderFinalize/Le_LinkOrder was keyed on the webroot
being exactly "dns". Any other webroot with a saved Le_Vlist skipped
newOrder and then finalized against an empty URL.

Key both on Le_Vlist, and always clear Le_LinkCert, which is per-run
state that is never read back from the saved domain conf.

Fixes #7177
2026-08-05 19:53:52 +08:00
neil
b4925052dd Fix dns_cyon cleanup failing on FreeBSD
_cyon_delete_txt relied on `printf "%b"` to convert a sed-injected literal
`\n` into a real newline, but `%b` also processes the `\"` escapes that the
JSON response is full of. glibc/bash/dash keep the backslash of such an
undefined escape, FreeBSD's printf (sh builtin and /usr/bin/printf alike)
drops it -- so `data-hash=\"..\"` became `data-hash=".."`, the extraction
regex matched nothing, _dns_entries stayed empty and no TXT record was ever
deleted.

Drop the newline injection and use _egrep_o, which already yields one match
per line, then parse each line with sed.

Also feed the read loop a newline-terminated list: `printf "%s"` left the
last line unterminated, so `read` returned non-zero at EOF and the loop
skipped the final entry on every platform.

Verified identical output on FreeBSD 14.3, Linux/bash and Linux/dash.

Fixes #7169
2026-08-02 21:55:52 +08:00
neil
ea5e70564d Fix --make-dns-persist-value printing a wildcard TXT record name
For -d '*.example.com' the printed record name kept the literal '*' label
(_validation-persist.*.example.com). The CA never queries that name, so
issuance fails with "No TXT record found for DNS-PERSIST-01 challenge".

Per draft-ietf-acme-dns-persist-01 sec 4 and 10.2 the record is published at
the base domain's Validation Domain Name; the wildcard scope comes from
'policy=wildcard' in the record value (sec 5.1), not from a '*' label in the
record name. Strip the leading "*." in a new _dns_persist_txt_name helper,
and imply --dns-persist-wildcard for a wildcard -d, since without
policy=wildcard the printed record can never authorize the wildcard.

Fixes #7168
2026-08-02 21:47:51 +08:00
Joel Samson
bf90b845b2 Refactor dns_freemyip.sh for enhanced compatibility (#7166)
* Refactor dns_freemyip.sh for clarity and compatibility

Updated dns_freemyip.sh for better readability and compatibility with ASUSWRT-Merlin. Improved error handling and response logging.

* Update author information in dns_freemyip.sh

* replace both loops with POSIX shell counters

replace both loops with POSIX shell counters

* Typo

Typo

* Fix error message for freemyip API request failure

Remove existing token leak. Not my regression.

* Refactor retry logic and improve error handling

* Remove unnecessary blank lines in dns_freemyip.sh

* Clean up dns_freemyip.sh by removing blank lines

Removed unnecessary blank lines in the script to improve readability.
2026-08-02 21:34:41 +08:00
Goncharenko Alexander
0565443622 dns_yc: fix TXT record removal failing with "Unknown key file format" (#7150)
* dns_yc: restore YC_SA_Key_File in dns_yc_rm before signing the JWT

dns_yc_rm() never rebuilt YC_SA_Key_File from YC_SA_Key_File_PEM_b64 /
YC_SA_Key_File_Path like dns_yc_add() does. Per the DNS API dev guide,
add()/rm() run in separate subshells, so rm() must repeat add()'s setup
steps rather than rely on variables set during add().

Without it, when _yc_login() needs a fresh JWT during removal (the IAM
token from the add phase isn't available), it signs with an empty/unset
key path, and openssl fails with "Unknown key file format". The
resulting auth failure then surfaces misleadingly as "invalid domain" in
_get_root, and the TXT record is never deleted.

Verified against a real Yandex Cloud account/zone with --staging: before
the fix, removal failed with the same errors reported in the issue;
after adding the missing key-restoration block, add + remove both
succeed and the TXT record is actually deleted.

* dns_yc: preserve other TXT values when removing one at the same name

dns_yc_rm previously sent the full current data array (all existing
TXT values at the name) to the deletions API, wiping out the whole
rrset instead of only the value being removed. This breaks wildcard +
base domain issuance, where both share the same _acme-challenge name
with two different values: removing the first one deleted both,
leaving nothing for the second removal to find.

* dns_yc: read persisted config from domain conf before account conf

YC_Zone_ID, YC_Folder_ID, YC_SA_ID, YC_SA_Key_ID (zone-ID mode) and
YC_SA_Key_File_PEM_b64/Path were always saved via _savedomainconf
(domain.conf), but only ever read back via _readaccountconf_mutable
(account.conf). Once the env vars were unset, none of these could be
recovered from the saved config, so dns_yc_add/dns_yc_rm failed with
"You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File."
even though the values had been persisted correctly on the prior run.

* dns_yc: replace grep -Fxv/sed with a portable loop in dns_yc_rm

Solaris's /usr/bin/grep supports neither -F nor -x, so
_remaining_txtvalue was always empty there and the preserve-other-
values logic silently fell back to deleting the whole rrset (with a
grep usage error on stderr on every rm). The sed trailing-comma strip
had a matching issue on Solaris, whose sed drops an unterminated last
line. CI didn't catch this because the fallback path also returns
"done: true". Use a plain for-loop with word splitting instead.

* dns_yc: use upsertRecordSets.deletions to remove a single TXT value

updateRecordSets has no "merges" field (only deletions/additions), so
the previous preserve-other-values logic silently did nothing -- the
TXT record was never actually removed, a regression from before that
change (which at least deleted the whole rrset). CI didn't catch it
because _clearupdns runs dns_yc_rm in a subshell and ignores its exit
code.

upsertRecordSets.deletions removes only the specified value from the
rrset directly, so the getRecordSet read and the remaining-value
recomputation are no longer needed at all.

Verified against a real zone (base + wildcard domain sharing one
_acme-challenge name): adding both values then removing one leaves
the other in place, and removing the second cleans up fully.

* dns_yc: don't delete the user's own key file in YC_SA_Key_File_Path mode

_yc_login unconditionally rm'd $YC_SA_Key_File after signing. That's
fine for the PEM_b64 path, where it's a decoded temp file, but in
YC_SA_Key_File_Path mode it's the user's own persistent key file --
the first successful login permanently deleted it, so every
subsequent dns_yc_rm/renewal hit "Unknown key file format" (the exact
symptom this PR is about, just from a different cause). Track whether
the key file is our own temp copy and only delete it in that case.

Verified with a stubbed _yc_login: a temp-mode key gets removed after
login, a path-mode key survives.

* dns_yc: clear both domain and account conf on invalid config

The failure branch in dns_yc_add only ever called _clearaccountconf,
but YC_Zone_ID/YC_Folder_ID/YC_SA_Key_File_PEM_b64/Path are persisted
via _savedomainconf, and YC_SA_ID/YC_SA_Key_ID may have been saved via
_saveaccountconf_mutable (Folder_ID mode, which stores under a
SAVED_ prefix read back by _readaccountconf_mutable). Clearing only
one store left stale values behind in whichever one wasn't touched.

Verified by seeding both domain.conf and account.conf with leftover
values, then triggering this branch and confirming both config files
end up empty.
2026-07-28 10:04:20 +08:00
neil
057c940895 add OpenEuler 2026-07-26 15:01:41 +08:00
neil
7c12deb7ef fix: grep -A is not portable, breaks ARI on Solaris
Solaris /usr/bin/grep has no -A ("illegal option -- A"), so _getAKI
printed an error to stderr on every cron renewal and returned empty.
The empty AKI silently corrupts the RFC 9773 ARI certID, so ARI is
never available and renewal falls back to the fixed schedule.

Split the pipeline into a testable stdin filter _extractAKI and select
the value line with a portable sed range instead.

Same fix for the two hooks that still used grep -A: dns_world4you.sh
(also replaces the GNU-only "\s" in the same expression) and
deploy/keyhelp.sh (the -A 2 window could truncate the div range that
follows it, so it is just dropped).

https://github.com/acmesh-official/acme.sh/issues/7159
2026-07-25 16:00:03 +08:00
neil
830782fd1d fix dns_yc: avoid empty-matchable _egrep_o pattern that hangs OmniOS
OmniOS native egrep -o infinite-loops emitting empty lines when the
pattern can match the empty string, so `_egrep_o "[^:]*$"` never lets
the pipeline finish and dns_yc hangs until the CI timeout. Require at
least one character instead. `+` is not usable because the sed fallback
in _egrep_o parses BRE.
2026-07-25 13:34:06 +08:00
Qhilm
698f6c7329 Feat: Shelly deploy hook for firmware 2.0.0+ (#7145)
* feat: add Shelly Gen3+ deploy hook with RFC 7616 HTTP Digest auth

Adds deploy/shelly.sh for deploying Let's Encrypt HTTPS server certificates
to Shelly Gen3+ devices (Gen4 tested) via JSON-RPC over HTTP.

- RFC 7616 SHA-256 HTTP Digest authentication (Authorization header)
- Uploads fullchain.pem and private key via Shelly.PutHTTPServerCert / PutHTTPServerKey
- Auto-reboot support (SHELLY_REBOOT to disable)
- Auth auto-detection: no password = no auth, password = Digest
- Nonce counter (nc) increments per request per RFC 7616
- Tested against Shelly 2PM Gen4 (firmware 2.0.0)

Also adds deploy/test_shelly.sh for self-testing the hook logic without
a real device (mocked _post).

* fix: address review feedback on shelly deploy hook

- Fix _secure_debug calls to use two arguments (label + value)
- Remove bash-only $RANDOM cnonce fallback; openssl always available
- Parse $HTTP_HEADER directly instead of raw curl re-request
- Detect auth via HTTP 401 status line, not empty response body
- Route reboot through _shelly_rpc to rebuild auth header with correct nc
- Remove export HTTPS_INSECURE=1 (no-op for http://, leaks to other hooks)
- Clear _H1 before returning from shelly_deploy
- Prefix all helper variables with _shelly_ to avoid namespace collisions
- Delete deploy/test_shelly.sh (deploy/ files become hook names)
- Fix missing trailing newline

* fix: validate shelly JSON-RPC responses are valid JSON

Non-JSON responses like HTTP 429 'Too Many Requests' would pass
the empty-response and '"error"' checks and be reported as success.
Now reject any response that doesn't start with '{' and contain '"id"'.

* fix: add 1s delay between shelly cert/key clear and upload calls

The Shelly device has a race condition where uploading data immediately
after clearing the existing cert/key returns -103 'Missing required
argument data!'. A 1-second delay fixes this.

* fix: remove clear-before-upload in shelly deploy hook

Shelly auto-removes all three TLS files (cert, key, CA bundle) when any
single one is cleared. The old sequence clear-cert → upload-cert →
clear-key → upload-key resulted in the key clear wiping the newly
uploaded cert, leaving only the key at boot time. The mbedtls
pk_check_pair then silently skipped the HTTPS listener.

Fix: just upload directly (overwrite in place). No clearing needed.

* Fix ShellCheck SC2090 and shfmt in shelly deploy hook

SC2090: false positive on export _H1 (used quoted in _post)
shfmt: no space after "<" in _json_encode redirects

* moved  two lines to cover the whole if block

---------

Co-authored-by: neil <github@neilpang.com>
Co-authored-by: cysimons <cysimons@cisco.com>
2026-07-25 13:03:56 +08:00
Alec Höfler
749fa2487e feat: added realtoxmedia dnsapi (#7156) 2026-07-24 13:55:17 +08:00
Radu
24d573b6d3 Merge pull request #7140 from radumalica/dns_hestiacp
feat: add dnsapi for HestiaCP
2026-07-23 11:50:20 +08:00
neil
1774d838ca add GNU hurd 2026-07-22 20:42:14 +08:00
neil
4c8a143086 fix proxmoxve/proxmoxbs deploy: fail on non-2xx API response
The success check only grepped "message" from the response body, but
PVE/PBS auth failures return HTTP 401 with an empty body, so wrong or
unauthorized API tokens were reported as "Certificate successfully
deployed". Also _retval captured the exit code of the message pipeline
instead of _post. Check the HTTP status line from $HTTP_HEADER and
capture _post's exit code directly.

fix https://github.com/acmesh-official/acme.sh/issues/7141
2026-07-20 10:02:54 +08:00
neil
6feb1df83c fix cpanel_uapi: pass --user to DomainInfo list_domains when run as root
The auto mode sitelist query was missing the --user branch that the
install_ssl calls already have, so deploy always failed under root.
fix https://github.com/acmesh-official/acme.sh/issues/7139
2026-07-19 10:20:58 +08:00
neil
97c5aca136 add cache-after-prepare: true 2026-07-18 09:32:26 +08:00
neil
7fa3018219 feat: add ACME_PACKAGED for distro-packaged installs
When ACME_PACKAGED is set (e.g. exported by a distro package wrapper):
- --install does not copy the script or the hooks into LE_WORKING_DIR;
  the cron job and the shell alias point to the packaged script instead
- --upgrade, --install-online and the cron AUTO_UPGRADE path refuse and
  point to the system package manager
- --uninstall does not remove the packaged files

https://github.com/acmesh-official/acme.sh/issues/7135
2026-07-17 22:03:14 +08:00
neil
9bfcf2cf1a Merge pull request #7128 from achmadalifn4/master
Fix Notify Waha
2026-07-17 21:58:41 +08:00
neil
59a97d7f8b fix bug for solaris.
dnsapi/deploy: remove POSIX character classes from sed/grep patterns

Solaris /usr/bin/sed and /usr/bin/grep parse [[:space:]] etc. as a
literal bracket set and silently mis-match. Replace with [ ]* for
JSON matching, a printf-tab bracket for user-input trimming, and
[0-9] for digits; also drop GNU-only sed -r/-E in rage4, selfhost
and selectel, and reuse _strip_blank_lines in byteplus_alb.
2026-07-17 12:38:14 +08:00
neil
a836e747d1 start 3.1.5 2026-07-17 10:10:07 +08:00
ACHMAD ALIF NASRULLOH
8f98786d93 Merge branch 'acmesh-official:master' into master 2026-07-16 11:29:49 +07:00
ACHMAD ALIF NASRULLOH
6d559ae69f Add newline at end of waha.sh
Fix missing newline at end of file.
2026-07-16 11:29:39 +07:00
ACHMAD ALIF NASRULLOH
24501ac0ca Merge branch 'dev' into master 2026-07-16 11:27:57 +07:00
Achmad Alif Nasrulloh
a82cf763cf fix(notify): remove duplicate Content-Type header in waha hook 2026-07-16 11:23:18 +07:00
neil
7cc16cd09a Merge pull request #7126 from acmesh-official/dev
sync
2026-07-14 22:35:07 +08:00
Achmad Alif Nasrulloh
d45b6fe8e6 fix(notify): waha: tighten response check 2026-07-12 16:07:33 +08:00
Achmad Alif Nasrulloh
237f2d9c3b Add notify waha support 2026-07-12 16:07:33 +08:00
neil
3e64b5e6f5 Merge pull request #7119 from acmesh-official/dev
sync
2026-07-12 10:11:32 +08:00
neil
347cc207cd Merge pull request #7096 from acmesh-official/dev
one-click revert and ban
2026-07-06 17:40:51 +08:00
neil
1651a5a609 Merge pull request #7095 from acmesh-official/dev
sync
2026-07-06 16:37:03 +08:00
neil
0c76c1f211 Merge pull request #7094 from acmesh-official/dev
wiki-guard: use WIKI_GUARD_TOKEN (PAT with read:org) to enumerate org…
2026-07-06 16:28:12 +08:00
neil
e00d3cfde3 Merge pull request #7092 from acmesh-official/dev
wiki-guard: log the number of write-access members loaded
2026-07-06 15:24:23 +08:00
neil
4187ec23c1 Merge pull request #7091 from acmesh-official/dev
wiki-guard: trust repo/org members with write access in all rule checks
2026-07-06 15:12:22 +08:00
neil
9f0ef7abcd Merge pull request #7090 from acmesh-official/dev
sync
2026-07-06 14:28:08 +08:00
neil
0e2659b768 Merge pull request #7088 from acmesh-official/dev
add wiki-guard workflow: auto-restore wiki pages deleted or renamed b…
2026-07-06 13:37:00 +08:00
neil
f50401a342 Merge pull request #7086 from acmesh-official/dev
sync
2026-07-06 10:37:29 +08:00
neil
992886c4eb Merge pull request #7078 from acmesh-official/dev
sync
2026-07-03 19:56:09 +08:00
ZeroSSL-Andreas
d60c75b4e3 New Banner
Updated README to include responsive images for dark and light modes.
2026-06-19 17:06:57 +02:00
neil
3c848adbb7 Merge pull request #7008 from acmesh-official/dev
sync
2026-06-04 22:50:25 +02:00
neil
f265c304da Merge pull request #6968 from acmesh-official/dev
sync
2026-05-16 11:17:24 +02:00
61 changed files with 3238 additions and 507 deletions

View File

@@ -66,7 +66,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- name: Set env file - name: Set env file
@@ -114,7 +114,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install tools - name: Install tools
run: | run: |
brew untap aws/tap || true brew untap aws/tap || true
@@ -167,7 +167,7 @@ jobs:
- name: Set git to use LF - name: Set git to use LF
run: | run: |
git config --global core.autocrlf false git config --global core.autocrlf false
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install cygwin base packages with chocolatey - name: Install cygwin base packages with chocolatey
run: | run: |
choco config get cacheLocation choco config get cacheLocation
@@ -231,12 +231,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/freebsd-vm@v1 - uses: vmactions/freebsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 prepare: pkg install -y socat curl
usesh: true usesh: true
@@ -289,12 +290,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/ghostbsd-vm@v1 - uses: vmactions/ghostbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 prepare: pkg install -y socat curl
usesh: true usesh: true
@@ -345,12 +347,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openbsd-vm@v1 - uses: vmactions/openbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 prepare: pkg_add socat curl libiconv
usesh: true usesh: true
@@ -401,12 +404,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/netbsd-vm@v1 - uses: vmactions/netbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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: | prepare: |
/usr/sbin/pkg_add curl socat /usr/sbin/pkg_add curl socat
@@ -458,12 +462,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/dragonflybsd-vm@v1 - uses: vmactions/dragonflybsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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: | prepare: |
pkg install -y libnghttp2 pkg install -y libnghttp2
@@ -519,12 +524,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/midnightbsd-vm@v1 - uses: vmactions/midnightbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 prepare: mport install socat curl || true
usesh: true usesh: true
@@ -576,12 +582,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/solaris-vm@v1 - uses: vmactions/solaris-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 sync: nfs
prepare: | prepare: |
@@ -635,12 +642,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/omnios-vm@v1 - uses: vmactions/omnios-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 sync: nfs
prepare: pkg install socat prepare: pkg install socat
@@ -691,12 +699,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openindiana-vm@v1 - uses: vmactions/openindiana-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 sync: nfs
prepare: pkg install socat prepare: pkg install socat
@@ -747,12 +756,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/tribblix-vm@v1 - uses: vmactions/tribblix-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 sync: nfs
prepare: zap install socat prepare: zap install socat
@@ -803,12 +813,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}} TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}} TokenName5: ${{ secrets.TokenName5}}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Clone acmetest - name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh 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/haiku-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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}}' 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 sync: rsync
copyback: false copyback: false
@@ -842,3 +853,236 @@ jobs:
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"

View File

@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/dragonflybsd-vm@v1 - uses: vmactions/dragonflybsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/freebsd-vm@v1 - uses: vmactions/freebsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -66,6 +66,7 @@ jobs:
- uses: vmactions/ghostbsd-vm@v1 - uses: vmactions/ghostbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -65,6 +65,7 @@ jobs:
- uses: vmactions/haiku-vm@v1 - uses: vmactions/haiku-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

76
.github/workflows/HardenedBSD.yml vendored Normal file
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
.github/workflows/Hurd.yml vendored Normal file
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"

View File

@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/midnightbsd-vm@v1 - uses: vmactions/midnightbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/netbsd-vm@v1 - uses: vmactions/netbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

86
.github/workflows/OPNsense.yml vendored Normal file
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"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/omnios-vm@v1 - uses: vmactions/omnios-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openbsd-vm@v1 - uses: vmactions/openbsd-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

78
.github/workflows/OpenEuler.yml vendored Normal file
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"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openindiana-vm@v1 - uses: vmactions/openindiana-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/solaris-vm@v1 - uses: vmactions/solaris-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/tribblix-vm@v1 - uses: vmactions/tribblix-vm@v1
with: with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }} 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' envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: | nat: |
"8080": "80" "8080": "80"

View File

@@ -10,9 +10,16 @@ on:
- '**.sh' - '**.sh'
- "Dockerfile" - "Dockerfile"
- '.github/workflows/dockerhub.yml' - '.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: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
@@ -40,13 +47,13 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: CheckToken needs: CheckToken
if: "contains(needs.CheckToken.outputs.hasToken, 'true')" if: "github.event_name != 'schedule' && contains(needs.CheckToken.outputs.hasToken, 'true')"
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- name: checkout code - name: checkout code
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Set up QEMU - name: Set up QEMU
@@ -98,3 +105,26 @@ jobs:
--tag ghcr.io/${{ github.repository }}:${DOCKER_IMAGE_TAG} \ --tag ghcr.io/${{ github.repository }}:${DOCKER_IMAGE_TAG} \
${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \ ${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \
|| echo "::warning::GHCR mirror failed; Docker Hub publish unaffected" || 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}"
# fails with 422 when the tag's workflow file has no workflow_dispatch
# trigger (releases before this job existed); nothing to do then
gh workflow run dockerhub.yml --repo "${GITHUB_REPOSITORY}" --ref "${tag}"

44
.github/workflows/vtag.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Mirror version tag
# Historical release tags are plain version numbers ("3.1.3") and cannot be
# renamed. When a plain version tag is pushed (including the tag created by
# publishing a GitHub release), mirror it as a "v"-prefixed tag ("v3.1.3")
# pointing to the same object, so both forms exist.
# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and
# refs created with GITHUB_TOKEN do not fire workflows anyway.
on:
push:
tags:
- '[0-9]*'
permissions:
contents: write
jobs:
vtag:
if: github.repository == 'acmesh-official/acme.sh'
runs-on: ubuntu-latest
steps:
- name: Create the v-prefixed tag
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v$TAG already exists, nothing to do."
exit 0
fi
# Mirror the object the pushed tag actually points at: the commit
# for a lightweight tag, the tag object itself for an annotated or
# signed one. Pointing the mirror at the commit would strip the
# signature, so "git verify-tag v3.1.3" would fail while
# "git verify-tag 3.1.3" succeeds.
sha="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.sha)"
if [ -z "$sha" ] || [ "$sha" = "null" ]; then
echo "Could not resolve refs/tags/$TAG"
exit 1
fi
gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
echo "Created tag v$TAG -> $sha"

View File

@@ -36,6 +36,10 @@
<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/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/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/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>
<p align="center"> <p align="center">
@@ -130,6 +134,10 @@
|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 |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 |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 |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) > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
@@ -223,6 +231,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

550
acme.sh
View File

@@ -1,6 +1,6 @@
#!/usr/bin/env sh #!/usr/bin/env sh
VER=3.1.4 VER=3.1.5
PROJECT_NAME="acme.sh" PROJECT_NAME="acme.sh"
@@ -1482,39 +1482,57 @@ _readKeyLengthFromCSR() {
fi fi
} }
#port
#Reads a netstat or ss listing on stdin, prints the lines that show a socket
#listening on port.
#Linux and windows print the local address as "addr:port", aix, macos, the
#bsds and solaris print it as "addr.port", so both separators must match.
#The state is "LISTEN" nearly everywhere, "LISTENING" on windows and lower
#case "listen" on haiku, hence the substring match and the -i.
_filter_listen_port() {
_flp_port="$1"
if [ -z "$_flp_port" ]; then
return
fi
grep -i "LISTEN" | grep "[:.]$_flp_port "
}
#port
_ss() { _ss() {
_port="$1" _port="$1"
if _exists "ss"; then if _exists "ss"; then
_debug "Using: ss" _debug "Using: ss"
ss -ntpl 2>/dev/null | grep ":$_port " ss -ntpl 2>/dev/null | _filter_listen_port "$_port"
return 0 return 0
fi fi
if [ "$(uname)" = "AIX" ]; then #aix, macos and the bsds have no "-p protocol" socket listing that works on
_debug "Using: AIX netstat" #all of them: on netbsd "-p" is "Show statistics about protocol" instead
netstat -an | grep "^tcp" | grep "LISTEN" | grep "\.$_port " #(netstat(1), NetBSD 10.1). Their default display does show "the state of
#all sockets" with -a, so use that and keep only the tcp lines.
case "$(uname)" in
AIX | Darwin | DragonFly | *BSD*)
_debug "Using: AIX/BSD netstat"
netstat -an | grep "^tcp" | _filter_listen_port "$_port"
return 0 return 0
fi ;;
esac
if _exists "netstat"; then if _exists "netstat"; then
_debug "Using: netstat" _debug "Using: netstat"
if netstat -help 2>&1 | grep "\-p proto" >/dev/null; then if netstat -help 2>&1 | grep "\-p proto" >/dev/null; then
#for windows version netstat tool #for windows version netstat tool
netstat -an -p tcp | grep "LISTENING" | grep ":$_port " netstat -an -p tcp | _filter_listen_port "$_port"
else
if netstat -help 2>&1 | grep "\-p protocol" >/dev/null; then
netstat -an -p tcp | grep LISTEN | grep ":$_port "
elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then
#for solaris #for solaris
netstat -an -P tcp | grep "\.$_port " | grep "LISTEN" netstat -an -P tcp | _filter_listen_port "$_port"
elif netstat -help 2>&1 | grep "\-p" >/dev/null; then elif netstat -help 2>&1 | grep "\-p" >/dev/null; then
#for full linux #for full linux
netstat -ntpl | grep ":$_port " netstat -ntpl | _filter_listen_port "$_port"
else else
#for busybox (embedded linux; no pid support) #for busybox (embedded linux; no pid support)
netstat -ntl 2>/dev/null | grep ":$_port " netstat -ntl 2>/dev/null | _filter_listen_port "$_port"
fi
fi fi
return 0 return 0
fi fi
@@ -2015,6 +2033,32 @@ _calc_validto_renew_time() {
fi fi
} }
#Usage: _calc_ari_renew_time aristarttime ariendtime now currenttime pinned
#Prints the renew time to take from the CA's ARI suggestedWindow, or nothing
#when the window must be ignored. The point inside the window is derived from
#the current time rather than its start, so renewals spread out across the
#network instead of all firing at the same instant.
#A schedule the user pinned with --days or --valid-to only yields to a window
#that is EARLIER than what the user asked for: the CA can still pull an urgent
#renewal forward, but it can never push a pinned renewal back.
_calc_ari_renew_time() {
_cart_start="$1"
_cart_end="$2"
_cart_now="$3"
_cart_current="$4"
_cart_pinned="$5"
if [ -z "$_cart_start" ] || [ -z "$_cart_end" ] || [ "$_cart_end" -le "$_cart_start" ]; then
return 0
fi
_cart_window=$(_math "$_cart_end" - "$_cart_start")
_cart_offset=$(_math "$_cart_now" % "$_cart_window")
_cart_next=$(_math "$_cart_start" + "$_cart_offset")
if [ "$_cart_pinned" ] && [ "$_cart_current" ] && [ "$_cart_next" -ge "$_cart_current" ]; then
return 0
fi
printf "%s" "$_cart_next"
}
_mktemp() { _mktemp() {
if _exists mktemp; then if _exists mktemp; then
if mktemp 2>/dev/null; then if mktemp 2>/dev/null; then
@@ -2343,6 +2387,76 @@ _tail_c() {
tail -c "$1" 2>/dev/null || tail -"$1"c tail -c "$1" 2>/dev/null || tail -"$1"c
} }
#code
#Is this status the CA's front end failing rather than its ACME
#implementation answering? 502 and 504 mean the proxy could not reach the
#backend or gave up waiting for it, 503 that it is overloaded. The body of
#those is the proxy's html, not problem+json, so no ACME status can be read
#out of it and a caller looking for one abandons an order that is fine.
#Anything else, a 500 from the ACME implementation included, is a real
#answer and must be passed through to the caller.
_is_gateway_error() {
case "$1" in
502 | 503 | 504) return 0 ;;
esac
return 1
}
#response
#Does the CA say the order cannot be finalized yet? By the time acme.sh
#finalizes, every authorization is valid, and an order becomes ready as
#soon as they all are -- so this is the CA's own state lagging, not a
#refusal. A struggling CA lags: ZeroSSL answered this on 2026-08-31 forty
#seconds after the authorization went valid, in the middle of the 502s it
#was serving that morning. Waiting is the answer, not abandoning an order
#whose challenges have all passed.
_is_order_not_ready() {
case "$1" in
*acme:error:orderNotReady*) return 0 ;;
esac
return 1
}
#response
#Does the CA's answer to a revokeCert mean the certificate is revoked? An
#empty body is the plain success. urn:ietf:params:acme:error:alreadyRevoked
#is "The request specified a certificate to be revoked that has already been
#revoked" (RFC 8555 sec 6.7), which is what a user sees when a retry follows
#a request the CA did carry out but could not answer: the gateway ate the
#reply, not the revocation. Either way the certificate is revoked, which is
#what was asked for, so do not report a failure and do not go on to try the
#domain key for a certificate that is already gone.
#Match the error type, not the bare word: claiming a revocation that did not
#happen is far worse than missing one, so a body that merely mentions the
#name must not count.
_is_revoked_response() {
if [ -z "$1" ]; then
return 0
fi
case "$1" in
*acme:error:alreadyRevoked*) return 0 ;;
esac
return 1
}
#attempt
#Seconds to wait before retry number <attempt>, for the cases where the CA
#gave us no Retry-After to go by. A flat two seconds let the whole twenty
#attempt budget burn out in forty eight seconds, which is shorter than the
#gateway outages a CA really has: ZeroSSL answered 502 and 504 for over a
#minute at a time through August 2026, so every renewal that started during
#one of those died instead of waiting it out. Backing off spends the same
#twenty attempts over about six minutes, which is still far below the ten
#minutes at which a Retry-After is read as the CA refusing outright.
_retry_backoff_sec() {
case "$1" in
1) echo 2 ;;
2) echo 5 ;;
3) echo 10 ;;
*) echo 20 ;;
esac
}
# url payload needbase64 keyfile # url payload needbase64 keyfile
_send_signed_request() { _send_signed_request() {
url=$1 url=$1
@@ -2406,8 +2520,9 @@ _send_signed_request() {
nonce="$_CACHED_NONCE" nonce="$_CACHED_NONCE"
_debug2 nonce "$nonce" _debug2 nonce "$nonce"
if [ -z "$nonce" ]; then if [ -z "$nonce" ]; then
_info "Could not get nonce, let's try again." _sleep_nonce_sec="$(_retry_backoff_sec "$_request_retry_times")"
_sleep 2 _info "Could not get nonce, let's try again. Sleeping for $_sleep_nonce_sec seconds."
_sleep "$_sleep_nonce_sec"
continue continue
fi fi
@@ -2466,13 +2581,13 @@ _send_signed_request() {
fi fi
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r') _retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
if [ "$code" = '503' ]; then if _is_gateway_error "$code"; then
_sleep_overload_retry_sec=$_retryafter _sleep_overload_retry_sec=$_retryafter
if [ -z "$_sleep_overload_retry_sec" ]; then if [ -z "$_sleep_overload_retry_sec" ]; then
_sleep_overload_retry_sec=5 _sleep_overload_retry_sec="$(_retry_backoff_sec "$_request_retry_times")"
fi fi
if [ $_sleep_overload_retry_sec -le 600 ]; then if [ $_sleep_overload_retry_sec -le 600 ]; then
_info "It seems the CA server is currently overloaded, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds." _info "The CA server answered $code, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds."
_sleep $_sleep_overload_retry_sec _sleep $_sleep_overload_retry_sec
continue continue
else else
@@ -2499,6 +2614,15 @@ _send_signed_request() {
} }
#Reads a value from stdin, prints it escaped for use as the replacement text
#of a sed s command delimited by '|'. The backslash must go first: a bare one
#starts an escape sequence and backslash-digit is a backreference, both make
#sed error out. Then '&' (the whole-match reference) and the '|' delimiter.
#https://github.com/acmesh-official/acme.sh/issues/7213
_sed_escape_rhs() {
sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g'
}
#setopt "file" "opt" "=" "value" [";"] #setopt "file" "opt" "=" "value" [";"]
_setopt() { _setopt() {
__conf="$1" __conf="$1"
@@ -2514,34 +2638,50 @@ _setopt() {
touch "$__conf" touch "$__conf"
chmod 600 "$__conf" chmod 600 "$__conf"
fi fi
__nl="
"
case "$__val" in
*"$__nl"*)
#the conf format is line based and the file is sourced by the shell, so a
#value holding a line break cannot be represented in it (it would also
#make the replace sed below fail with an unterminated 's' command)
_err "The value of '$__opt' contains a line break, it cannot be saved to $__conf."
return 1
;;
esac
if [ -n "$(_tail_c 1 <"$__conf")" ]; then if [ -n "$(_tail_c 1 <"$__conf")" ]; then
echo >>"$__conf" echo >>"$__conf"
fi fi
if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then
_debug3 OK _debug3 OK
if _contains "$__val" "&"; then __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)"
__val="$(echo "$__val" | sed 's/&/\\&/g')"
fi
if _contains "$__val" "|"; then
__val="$(echo "$__val" | sed 's/|/\\|/g')"
fi
text="$(cat "$__conf")" text="$(cat "$__conf")"
printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf" #capture first, write only on success: redirecting sed straight into the
#conf file truncates it before sed runs, so a failing sed (e.g. on an
#unescaped special character in the value) wiped the whole conf (#2426)
if __text="$(printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then
printf -- "%s\n" "$__text" >"$__conf"
else
_err "Cannot save '$__opt' to $__conf."
return 1
fi
elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then
if _contains "$__val" "&"; then __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)"
__val="$(echo "$__val" | sed 's/&/\\&/g')"
fi
if _contains "$__val" "|"; then
__val="$(echo "$__val" | sed 's/|/\\|/g')"
fi
text="$(cat "$__conf")" text="$(cat "$__conf")"
printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf" if __text="$(printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then
printf -- "%s\n" "$__text" >"$__conf"
else
_err "Cannot save '$__opt' to $__conf."
return 1
fi
else else
_debug3 APP _debug3 APP
echo "$__opt$__sep$__val$__end" >>"$__conf" #printf, not echo: dash's builtin echo interprets backslash escapes in
#the value and would corrupt it
printf -- "%s\n" "$__opt$__sep$__val$__end" >>"$__conf"
fi fi
_debug3 "$(grep -n "^$__opt$__sep" "$__conf")" _debug3 "$(grep -n "^$__opt$__sep" "$__conf")"
} }
@@ -2569,7 +2709,9 @@ _clear_conf() {
_sdkey="$2" _sdkey="$2"
if [ "$_c_c_f" ]; then if [ "$_c_c_f" ]; then
_conf_data="$(cat "$_c_c_f")" _conf_data="$(cat "$_c_c_f")"
echo "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f" #printf, not echo: dash's builtin echo interprets backslash escapes and
#would corrupt saved values that contain them on every rewrite
printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f"
else else
_err "Config file is empty, cannot clear" _err "Config file is empty, cannot clear"
fi fi
@@ -2715,6 +2857,21 @@ _clearcaconf() {
_clear_conf "$CA_CONF" "$1" _clear_conf "$CA_CONF" "$1"
} }
#Starts a socat listener in the background, the pid is set to _socat_pid.
#It uses the content, _content_len, _NC and _SOCAT_ERR of _startserver.
#options
_startsocat() {
_socat_opts="$1"
_debug "_NC" "$_NC $_socat_opts"
$_NC $_socat_opts SYSTEM:"sleep 1; \
echo 'HTTP/1.0 200 OK'; \
echo 'Content-Length\: $_content_len'; \
echo ''; \
printf '%s' '$content';" 2>>"$_SOCAT_ERR" &
_socat_pid="$!"
_debug "_socat_pid" "$_socat_pid"
}
# content localaddress # content localaddress
_startserver() { _startserver() {
content="$1" content="$1"
@@ -2728,16 +2885,24 @@ _startserver() {
_debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V4 "$Le_Listen_V4"
_debug Le_Listen_V6 "$Le_Listen_V6" _debug Le_Listen_V6 "$Le_Listen_V6"
_serverproc_v6=""
if _exists "socat"; then if _exists "socat"; then
_NC="socat" _NC="socat"
if [ "$Le_Listen_V6" ]; then SOCAT_OPTIONS6=""
if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
_NC="$_NC -6" _NC="$_NC -6"
SOCAT_OPTIONS=TCP6-LISTEN SOCAT_OPTIONS=TCP6-LISTEN
elif [ "$Le_Listen_V4" ]; then elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
_NC="$_NC -4" _NC="$_NC -4"
SOCAT_OPTIONS=TCP4-LISTEN SOCAT_OPTIONS=TCP4-LISTEN
else elif [ "$ncaddr" ]; then
#a single local address belongs to a single family, let socat pick it
SOCAT_OPTIONS=TCP-LISTEN SOCAT_OPTIONS=TCP-LISTEN
else
#listen on both ipv4 and ipv6, with one socket for each family:
#ipv4-mapped ipv6 addresses are not available everywhere.
SOCAT_OPTIONS=TCP4-LISTEN
SOCAT_OPTIONS6=TCP6-LISTEN
fi fi
if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then
@@ -2745,6 +2910,10 @@ _startserver() {
fi fi
SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork
if [ "$SOCAT_OPTIONS6" ]; then
#ipv6only keeps this socket from colliding with the ipv4 one
SOCAT_OPTIONS6=$SOCAT_OPTIONS6:$Le_HTTPPort,crlf,reuseaddr,fork,ipv6only=1
fi
#Adding bind to local-address #Adding bind to local-address
if [ "$ncaddr" ]; then if [ "$ncaddr" ]; then
@@ -2753,14 +2922,14 @@ _startserver() {
_content_len="$(printf "%s" "$content" | wc -c)" _content_len="$(printf "%s" "$content" | wc -c)"
_debug _content_len "$_content_len" _debug _content_len "$_content_len"
_debug "_NC" "$_NC $SOCAT_OPTIONS"
export _SOCAT_ERR="$(_mktemp)" export _SOCAT_ERR="$(_mktemp)"
$_NC $SOCAT_OPTIONS SYSTEM:"sleep 1; \ _startsocat "$SOCAT_OPTIONS"
echo 'HTTP/1.0 200 OK'; \ serverproc="$_socat_pid"
echo 'Content-Length\: $_content_len'; \ if [ "$SOCAT_OPTIONS6" ]; then
echo ''; \ #best effort, the host may have no ipv6 support at all
printf '%s' '$content';" 2>"$_SOCAT_ERR" & _startsocat "$SOCAT_OPTIONS6"
serverproc="$!" _serverproc_v6="$_socat_pid"
fi
else else
_PYTHON="" _PYTHON=""
if _exists "python3"; then if _exists "python3"; then
@@ -2772,19 +2941,38 @@ printf '%s' '$content';" 2>"$_SOCAT_ERR" &
fi fi
if [ "$_PYTHON" ]; then if [ "$_PYTHON" ]; then
_debug "Using python: $_PYTHON" _debug "Using python: $_PYTHON"
_AF="socket.AF_INET" #a comma separated list of addresses to listen on, one socket for each
_BIND_ADDR="0.0.0.0" _BIND_ADDR="0.0.0.0,::"
if [ "$Le_Listen_V6" ]; then if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
_AF="socket.AF_INET6"
_BIND_ADDR="::" _BIND_ADDR="::"
elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
_BIND_ADDR="0.0.0.0"
fi fi
if [ "$ncaddr" ]; then if [ "$ncaddr" ]; then
_BIND_ADDR="$ncaddr" _BIND_ADDR="$ncaddr"
fi fi
_debug "_BIND_ADDR" "$_BIND_ADDR"
export _SOCAT_ERR="$(_mktemp)" export _SOCAT_ERR="$(_mktemp)"
$_PYTHON -c "import socket,sys;s=socket.socket($_AF,socket.SOCK_STREAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((sys.argv[2],int(sys.argv[1])));s.listen(5);res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3]; $_PYTHON -c "import socket,sys,select
res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3]
ads=sys.argv[2].split(',')
ls=[]
for ad in ads:
try:
sk=socket.socket(socket.AF_INET6 if ':' in ad else socket.AF_INET,socket.SOCK_STREAM)
sk.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
if ':' in ad and len(ads)>1:
sk.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1)
sk.bind((ad,int(sys.argv[1])))
sk.listen(5)
ls.append(sk)
except Exception:
sys.stderr.write(str(sys.exc_info()[1])+'\n')
if not ls:
sys.exit(1)
while True: while True:
c,a=s.accept() for sk in select.select(ls,[],[])[0]:
c,a=sk.accept()
c.sendall(res.encode() if hasattr(res, 'encode') else res) c.sendall(res.encode() if hasattr(res, 'encode') else res)
c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" & c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" &
serverproc="$!" serverproc="$!"
@@ -2809,6 +2997,11 @@ while True:
_stopserver() { _stopserver() {
pid="$1" pid="$1"
_debug "pid" "$pid" _debug "pid" "$pid"
if [ "$_serverproc_v6" ]; then
_debug "_serverproc_v6" "$_serverproc_v6"
kill $_serverproc_v6 >/dev/null 2>&1
_serverproc_v6=""
fi
if [ -z "$pid" ]; then if [ -z "$pid" ]; then
rm -f "$_SOCAT_ERR" rm -f "$_SOCAT_ERR"
return return
@@ -2882,9 +3075,11 @@ _starttlsserver() {
_debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V4 "$Le_Listen_V4"
_debug Le_Listen_V6 "$Le_Listen_V6" _debug Le_Listen_V6 "$Le_Listen_V6"
if [ "$Le_Listen_V4" ]; then #openssl s_server binds a single socket, so both options together can only
#mean: do not force a family, same as when neither of them is given.
if [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then
__S_OPENSSL="$__S_OPENSSL -4" __S_OPENSSL="$__S_OPENSSL -4"
elif [ "$Le_Listen_V6" ]; then elif [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then
__S_OPENSSL="$__S_OPENSSL -6" __S_OPENSSL="$__S_OPENSSL -6"
fi fi
@@ -3858,6 +4053,11 @@ _on_before_issue() {
if [ -z "$netprc" ]; then if [ -z "$netprc" ]; then
netprc="$(echo "$_netprc" | grep "$LOCAL_ANY_ADDRESS:$_checkport")" netprc="$(echo "$_netprc" | grep "$LOCAL_ANY_ADDRESS:$_checkport")"
fi fi
if [ -z "$netprc" ]; then
#aix, macos, the bsds and solaris print the wildcard local address as
#"*.port", not "0.0.0.0:port", and it blocks $_checkaddr just the same
netprc="$(echo "$_netprc" | grep " [*][:.]$_checkport ")"
fi
if [ "$netprc" ]; then if [ "$netprc" ]; then
_err "$netprc" _err "$netprc"
_err "tcp port $_checkport is already used by $(echo "$netprc" | cut -d : -f 4)" _err "tcp port $_checkport is already used by $(echo "$netprc" | cut -d : -f 4)"
@@ -4355,6 +4555,24 @@ deactivateaccount() {
fi fi
} }
#domain
#Print the Validation Domain Name where the persistent TXT record must be
#published: the "_validation-persist" label prepended to the domain being
#validated (draft-ietf-acme-dns-persist-01 sec 4).
#A wildcard identifier is validated by the record at its base domain, so the
#leading "*." label is dropped: the wildcard scope comes from 'policy=wildcard'
#in the record value, not from a "*" label in the record name (sec 5.1, 10.2).
_dns_persist_txt_name() {
_dpt_domain="$1"
if _startswith "$_dpt_domain" "*."; then
_dpt_domain="$(echo "$_dpt_domain" | sed 's/^\*\.//')"
fi
if [ -z "$_dpt_domain" ]; then
return 1
fi
echo "_validation-persist.$_dpt_domain"
}
#domain wildcard ca_name days #domain wildcard ca_name days
#Print the TXT record(s) the user must add to enable persistent DNS validation #Print the TXT record(s) the user must add to enable persistent DNS validation
#per draft-ietf-acme-dns-persist-01. #per draft-ietf-acme-dns-persist-01.
@@ -4369,6 +4587,20 @@ makednspersistvalue() {
return 1 return 1
fi fi
_txt_name="$(_dns_persist_txt_name "$_mdpv_domain")"
if [ -z "$_txt_name" ]; then
_err "Invalid domain: $_mdpv_domain"
return 1
fi
_debug _txt_name "$_txt_name"
#A wildcard identifier can only be issued if the record carries
#'policy=wildcard', so don't print a record that is guaranteed to fail.
if _startswith "$_mdpv_domain" "*." && [ "$_mdpv_wildcard" != "1" ]; then
_info "$_mdpv_domain is a wildcard domain, adding 'policy=wildcard' automatically."
_mdpv_wildcard="1"
fi
if [ -n "$_mdpv_days" ]; then if [ -n "$_mdpv_days" ]; then
case "$_mdpv_days" in case "$_mdpv_days" in
'' | *[!0-9]*) '' | *[!0-9]*)
@@ -4400,8 +4632,6 @@ makednspersistvalue() {
fi fi
_debug "Account URL" "$_accUri" _debug "Account URL" "$_accUri"
_txt_name="_validation-persist.$_mdpv_domain"
_txt_suffix="; accounturi=$_accUri" _txt_suffix="; accounturi=$_accUri"
if [ "$_mdpv_wildcard" = "1" ]; then if [ "$_mdpv_wildcard" = "1" ]; then
_txt_suffix="$_txt_suffix; policy=wildcard" _txt_suffix="$_txt_suffix; policy=wildcard"
@@ -4919,11 +5149,18 @@ issue() {
if [ -z "$_ACME_IS_RENEW" ]; then if [ -z "$_ACME_IS_RENEW" ]; then
_initpath "$_main_domain" "$_key_length" _initpath "$_main_domain" "$_key_length"
mkdir -p "$DOMAIN_PATH" mkdir -p "$DOMAIN_PATH"
elif ! _hasfield "$_web_roots" "$W_DNS"; then elif [ -z "$Le_Vlist" ]; then
# Whether the saved order is resumed is decided by Le_Vlist below, so key
# this on Le_Vlist too. With no pending order to resume a new one is
# created, and a stale order link from the previous issuance must not be
# reused. https://github.com/acmesh-official/acme.sh/issues/3635
Le_OrderFinalize="" Le_OrderFinalize=""
Le_LinkOrder="" Le_LinkOrder=""
Le_LinkCert=""
fi fi
# Per-run state only: it is set after finalize and never read back from the
# saved domain conf. Carrying it over would make a run that gives up while
# the order is still 'processing' download the previous certificate again.
Le_LinkCert=""
if _hasfield "$_web_roots" "$W_DNS" && [ -z "$FORCE_DNS_MANUAL" ]; then if _hasfield "$_web_roots" "$W_DNS" && [ -z "$FORCE_DNS_MANUAL" ]; then
_err "$_DNS_MANUAL_ERROR" _err "$_DNS_MANUAL_ERROR"
@@ -5737,11 +5974,25 @@ $_authorizations_map"
_info "Let's finalize the order." _info "Let's finalize the order."
_info "Le_OrderFinalize" "$Le_OrderFinalize" _info "Le_OrderFinalize" "$Le_OrderFinalize"
_finalize_retry=0
MAX_FINALIZE_RETRY_TIMES=10
while [ "$_finalize_retry" -lt "$MAX_FINALIZE_RETRY_TIMES" ]; do
_finalize_retry=$(_math "$_finalize_retry" + 1)
if ! _send_signed_request "${Le_OrderFinalize}" "{\"csr\": \"$der\"}"; then if ! _send_signed_request "${Le_OrderFinalize}" "{\"csr\": \"$der\"}"; then
_err "Signing failed." _err "Signing failed."
_on_issue_err "$_post_hook" _on_issue_err "$_post_hook"
return 1 return 1
fi fi
if [ "$code" = "200" ]; then
break
fi
if ! _is_order_not_ready "$response"; then
break
fi
_finalize_wait_sec="$(_retry_backoff_sec "$_finalize_retry")"
_info "The order is not ready to be finalized yet, waiting $_finalize_wait_sec seconds. ($_finalize_retry/$MAX_FINALIZE_RETRY_TIMES)"
_sleep "$_finalize_wait_sec"
done
if [ "$code" != "200" ]; then if [ "$code" != "200" ]; then
_err "Signing failed. Finalize code was not 200." _err "Signing failed. Finalize code was not 200."
_err "$response" _err "$response"
@@ -5925,10 +6176,16 @@ $_authorizations_map"
Le_CertCreateTimeStr=$(_time2str "$Le_CertCreateTime") Le_CertCreateTimeStr=$(_time2str "$Le_CertCreateTime")
_savedomainconf "Le_CertCreateTimeStr" "$Le_CertCreateTimeStr" _savedomainconf "Le_CertCreateTimeStr" "$Le_CertCreateTimeStr"
# Le_RenewalDays is only written to the domain conf when the user actually
# passed --days; the default schedule is never saved. That is what makes the
# presence of the value a reliable "the user pinned this" flag, here and on
# every later renewal check.
if [ -z "$Le_RenewalDays" ]; then if [ -z "$Le_RenewalDays" ]; then
Le_RenewalDays="$DEFAULT_RENEW" Le_RenewalDays="$DEFAULT_RENEW"
_ari_pinned=""
else else
_savedomainconf "Le_RenewalDays" "$Le_RenewalDays" _savedomainconf "Le_RenewalDays" "$Le_RenewalDays"
_ari_pinned="1"
fi fi
if [ "$CA_BUNDLE" ]; then if [ "$CA_BUNDLE" ]; then
@@ -5949,13 +6206,18 @@ $_authorizations_map"
_clearaccountconf "HTTPS_INSECURE" _clearaccountconf "HTTPS_INSECURE"
fi fi
if [ "$Le_Listen_V4" ] || [ "$Le_Listen_V6" ]; then
if [ "$Le_Listen_V4" ]; then if [ "$Le_Listen_V4" ]; then
_savedomainconf "Le_Listen_V4" "$Le_Listen_V4" _savedomainconf "Le_Listen_V4" "$Le_Listen_V4"
_cleardomainconf Le_Listen_V6 else
elif [ "$Le_Listen_V6" ]; then
_savedomainconf "Le_Listen_V6" "$Le_Listen_V6"
_cleardomainconf Le_Listen_V4 _cleardomainconf Le_Listen_V4
fi fi
if [ "$Le_Listen_V6" ]; then
_savedomainconf "Le_Listen_V6" "$Le_Listen_V6"
else
_cleardomainconf Le_Listen_V6
fi
fi
if [ "$Le_ForceNewDomainKey" = "1" ]; then if [ "$Le_ForceNewDomainKey" = "1" ]; then
_savedomainconf "Le_ForceNewDomainKey" "$Le_ForceNewDomainKey" _savedomainconf "Le_ForceNewDomainKey" "$Le_ForceNewDomainKey"
@@ -5997,15 +6259,25 @@ $_authorizations_map"
Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
fi fi
# RFC 9773 ARI: if the CA exposes renewalInfo, override Le_NextRenewTime # RFC 9773 ARI: if the CA exposes renewalInfo, take Le_NextRenewTime from
# with a time picked at random within the suggestedWindow. This both gives # the suggestedWindow. This gives the CA control over renewal scheduling and
# the CA full control over renewal scheduling and disperses renewals across # disperses renewals across the network so all clients don't hit the CA at
# the network so all clients don't hit the CA at the same instant. # the same instant.
# An explicit --days or --valid-to wins over the window, except when the CA
# wants the cert renewed EARLIER than the user asked for: an urgent renewal
# must still get through. A fixed-date --valid-to opts out entirely, because
# there the cert is pinned to an expiry and is not renewed automatically at
# all -- letting ARI pull it forward would change that, not just its timing.
# Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and fall back to # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and fall back to
# the legacy time-based renewal calculation. # the legacy time-based renewal calculation.
if [ "$_notAfter" ]; then
_ari_pinned="1"
fi
if [ "$NO_ARI" = "1" ]; then if [ "$NO_ARI" = "1" ]; then
_debug "NO_ARI=1, skipping ARI suggestedWindow override" _debug "NO_ARI=1, skipping ARI suggestedWindow override"
elif [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ] && [ -z "$_notAfter" ]; then elif [ "$_valid_to" ] && ! _startswith "$_valid_to" "+"; then
_debug "Fixed --valid-to, skipping ARI suggestedWindow override"
elif [ "$ACME_RENEWAL_INFO" ] && [ -f "$CERT_PATH" ]; then
_ari_resp_new="$(_get_ARI "$CERT_PATH")" _ari_resp_new="$(_get_ARI "$CERT_PATH")"
_debug2 "_ari_resp_new" "$_ari_resp_new" _debug2 "_ari_resp_new" "$_ari_resp_new"
_ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')" _ari_start_new="$(echo "$_ari_resp_new" | _egrep_o '"start" *: *"[^"]*' | sed 's/.*"//')"
@@ -6013,13 +6285,15 @@ $_authorizations_map"
if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then if [ "$_ari_start_new" ] && [ "$_ari_end_new" ]; then
_ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")" _ari_start_t_new="$(_date2time "$(echo "$_ari_start_new" | sed 's/\.[0-9]*//')")"
_ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")" _ari_end_t_new="$(_date2time "$(echo "$_ari_end_new" | sed 's/\.[0-9]*//')")"
if [ "$_ari_start_t_new" ] && [ "$_ari_end_t_new" ] && [ "$_ari_end_t_new" -gt "$_ari_start_t_new" ]; then _ari_next_new="$(_calc_ari_renew_time "$_ari_start_t_new" "$_ari_end_t_new" "$(_time)" "$Le_NextRenewTime" "$_ari_pinned")"
_ari_window=$(_math "$_ari_end_t_new" - "$_ari_start_t_new") if [ "$_ari_next_new" ]; then
_ari_offset=$(_math "$(_time)" % "$_ari_window") Le_NextRenewTime="$_ari_next_new"
Le_NextRenewTime=$(_math "$_ari_start_t_new" + "$_ari_offset")
Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
_info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")" _info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")"
_info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" _info "Next renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")"
elif [ "$_ari_pinned" ]; then
_info "ARI suggestedWindow: $(__green "$_ari_start_new") to $(__green "$_ari_end_new")"
_info "It is later than the renewal time you asked for, keeping: $(__green "$Le_NextRenewTimeStr")"
fi fi
fi fi
fi fi
@@ -6078,7 +6352,10 @@ $_authorizations_map"
#some devices and APIs reject them, so the certs are stored back to back. #some devices and APIs reject them, so the certs are stored back to back.
#https://github.com/acmesh-official/acme.sh/issues/1940 #https://github.com/acmesh-official/acme.sh/issues/1940
_strip_blank_lines() { _strip_blank_lines() {
sed '/^[[:space:]]*$/d' #spell out space and tab: Solaris sed treats [[:space:]] as a literal
#bracket set and silently stops matching the blank lines
_sbl_tab="$(printf '\t')"
sed "/^[ $_sbl_tab]*\$/d"
} }
_split_cert_chain() { _split_cert_chain() {
@@ -6159,10 +6436,21 @@ renew() {
# ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check. # ARI (RFC 9773): fetch the CA's suggestedWindow on every renewal check.
# If the window has started, renew now even if Le_NextRenewTime is in the future. # If the window has started, renew now even if Le_NextRenewTime is in the future.
# Le_RenewalDays and Le_Valid_To are only in the domain conf when the user
# passed --days or --valid-to, so their presence is what pins the schedule.
# A pinned schedule still yields to a window that is EARLIER than it, so the
# CA can pull an urgent renewal forward. A fixed-date --valid-to opts out
# entirely: that cert is not renewed automatically at all.
# Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only
# Le_NextRenewTime for the renewal decision. # Le_NextRenewTime for the renewal decision.
_ari_pinned=""
if [ "$Le_RenewalDays" ] || [ "$Le_Valid_To" ]; then
_ari_pinned="1"
fi
if [ "$NO_ARI" = "1" ]; then if [ "$NO_ARI" = "1" ]; then
_debug "NO_ARI=1, skipping ARI suggestedWindow check" _debug "NO_ARI=1, skipping ARI suggestedWindow check"
elif [ "$Le_Valid_To" ] && ! _startswith "$Le_Valid_To" "+"; then
_debug "Fixed --valid-to, skipping ARI suggestedWindow check"
elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then
if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then if _initAPI && [ "$ACME_RENEWAL_INFO" ]; then
_ari_resp="$(_get_ARI "$CERT_PATH")" _ari_resp="$(_get_ARI "$CERT_PATH")"
@@ -6181,16 +6469,19 @@ renew() {
_debug "Le_NextRenewTime" "$Le_NextRenewTime" _debug "Le_NextRenewTime" "$Le_NextRenewTime"
# Update ARI if needed # Update ARI if needed
if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then
_ari_next="$(_calc_ari_renew_time "$_ari_start_t" "$_ari_end_t" "$(_time)" "$Le_NextRenewTime" "$_ari_pinned")"
if [ "$_ari_next" ]; then
_ari_old_time_str="$Le_NextRenewTimeStr" _ari_old_time_str="$Le_NextRenewTimeStr"
_info "Current renewal time: $(__green "$_ari_old_time_str")" _info "Current renewal time: $(__green "$_ari_old_time_str")"
_ari_window=$(_math "$_ari_end_t" - "$_ari_start_t") Le_NextRenewTime="$_ari_next"
_ari_offset=$(_math "$(_time)" % "$_ari_window")
Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset")
Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
_info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")" _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")"
_info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")"
_savedomainconf Le_NextRenewTime "$Le_NextRenewTime" _savedomainconf Le_NextRenewTime "$Le_NextRenewTime"
_savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr" _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr"
else
_debug "ARI wants a later renewal than --days/--valid-to asked for, keeping $Le_NextRenewTimeStr"
fi
fi fi
if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then
_info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal." _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal."
@@ -6974,6 +7265,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"
@@ -7043,7 +7358,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
@@ -7053,7 +7387,9 @@ installcronjob() {
if [ "$_cron_entries" ]; then if [ "$_cron_entries" ]; then
echo "$_cron_entries" echo "$_cron_entries"
fi fi
if [ "$_cron_add" ]; then
echo "$_cron_entry" echo "$_cron_entry"
fi
} | $_CRONTAB_STDIN } | $_CRONTAB_STDIN
fi fi
if [ "$?" != "0" ]; then if [ "$?" != "0" ]; then
@@ -7158,7 +7494,7 @@ revoke() {
_info "Trying account key first." _info "Trying account key first."
if _send_signed_request "$uri" "$data" "" "$ACCOUNT_KEY_PATH"; then if _send_signed_request "$uri" "$data" "" "$ACCOUNT_KEY_PATH"; then
if [ -z "$response" ]; then if _is_revoked_response "$response"; then
_info "Successfully revoked." _info "Successfully revoked."
rm -f "$CERT_PATH" rm -f "$CERT_PATH"
cat "$CERT_KEY_PATH" >"$CERT_KEY_PATH.revoked" cat "$CERT_KEY_PATH" >"$CERT_KEY_PATH.revoked"
@@ -7173,7 +7509,7 @@ revoke() {
if [ -f "$CERT_KEY_PATH" ]; then if [ -f "$CERT_KEY_PATH" ]; then
_info "Trying domain key." _info "Trying domain key."
if _send_signed_request "$uri" "$data" "" "$CERT_KEY_PATH"; then if _send_signed_request "$uri" "$data" "" "$CERT_KEY_PATH"; then
if [ -z "$response" ]; then if _is_revoked_response "$response"; then
_info "Successfully revoked." _info "Successfully revoked."
rm -f "$CERT_PATH" rm -f "$CERT_PATH"
cat "$CERT_KEY_PATH" >"$CERT_KEY_PATH.revoked" cat "$CERT_KEY_PATH" >"$CERT_KEY_PATH.revoked"
@@ -7366,10 +7702,18 @@ deactivate() {
done done
} }
#reads the output of "openssl x509 -text" from stdin, prints the hex AKI
#the value is on the line right after the extension header; "grep -A" is not
#portable (Solaris /usr/bin/grep: "illegal option -- A"), so select from the
#header to EOF and keep the second line of that range
_extractAKI() {
sed -n '/X509v3 Authority Key Identifier/,$p' | _head_n 2 | _tail_n 1 | tr -d ': ' | sed "s/keyid//"
}
#cert #cert
_getAKI() { _getAKI() {
_cert="$1" _cert="$1"
${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep -A 1 "X509v3 Authority Key Identifier" | _tail_n 1 | tr -d ': ' | sed "s/keyid//" ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | _extractAKI
} }
#cert #cert
@@ -7525,6 +7869,15 @@ _installalias() {
_c_home="$1" _c_home="$1"
_initpath _initpath
_alias_bin="$LE_WORKING_DIR/$PROJECT_ENTRY"
if [ ! -f "$_alias_bin" ]; then
#ACME_PACKAGED install: no copy in LE_WORKING_DIR, alias the current script
_script="$(_readlink "$_SCRIPT_")"
if [ -f "$_script" ]; then
_alias_bin="$_script"
fi
fi
_envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env" _envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env"
if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then
echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile" echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile"
@@ -7542,7 +7895,7 @@ _installalias() {
else else
_sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile"
fi fi
_setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$_alias_bin$_c_entry\""
if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then
#the completion file does nothing when sourced by a non-bash shell #the completion file does nothing when sourced by a non-bash shell
_setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\"" _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\""
@@ -7569,7 +7922,7 @@ _installalias() {
else else
_sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile" _sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile"
fi fi
_setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_csh_profile" "source \"$_cshfile\"" _setopt "$_csh_profile" "source \"$_cshfile\""
fi fi
@@ -7581,7 +7934,7 @@ _installalias() {
if [ "$_c_home" ]; then if [ "$_c_home" ]; then
_setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\"" _setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\""
fi fi
_setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_tcsh_profile" "source \"$_cshfile\"" _setopt "$_tcsh_profile" "source \"$_cshfile\""
fi fi
@@ -7655,6 +8008,11 @@ install() {
chmod 700 "$LE_CONFIG_HOME" chmod 700 "$LE_CONFIG_HOME"
fi fi
if [ "$ACME_PACKAGED" ]; then
#the script and its hooks are managed by a system package manager,
#do not copy them into LE_WORKING_DIR. https://github.com/acmesh-official/acme.sh/issues/7135
_info "ACME_PACKAGED is set, skipping the script copy."
else
cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY" cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY"
if [ "$?" != "0" ]; then if [ "$?" != "0" ]; then
@@ -7668,17 +8026,20 @@ install() {
cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/"
_debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion"
fi fi
fi
if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then
_installalias "$_c_home" _installalias "$_c_home"
fi fi
if [ -z "$ACME_PACKAGED" ]; then
for subf in $_SUB_FOLDERS; do for subf in $_SUB_FOLDERS; do
if [ -d "$subf" ]; then if [ -d "$subf" ]; then
mkdir -p "$LE_WORKING_DIR/$subf" mkdir -p "$LE_WORKING_DIR/$subf"
cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/ cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/
fi fi
done done
fi
if [ ! -f "$ACCOUNT_CONF_PATH" ]; then if [ ! -f "$ACCOUNT_CONF_PATH" ]; then
_initconf _initconf
@@ -7706,7 +8067,7 @@ install() {
installcronjob "$_c_home" installcronjob "$_c_home"
fi fi
if [ -z "$NO_DETECT_SH" ]; then if [ -z "$NO_DETECT_SH" ] && [ -z "$ACME_PACKAGED" ]; then
#Modify shebang #Modify shebang
if _exists bash; then if _exists bash; then
_bash_path="$(bash -c "command -v bash 2>/dev/null")" _bash_path="$(bash -c "command -v bash 2>/dev/null")"
@@ -7731,7 +8092,9 @@ install() {
if [ "$_accountemail" ]; then if [ "$_accountemail" ]; then
_saveaccountconf "ACCOUNT_EMAIL" "$_accountemail" _saveaccountconf "ACCOUNT_EMAIL" "$_accountemail"
fi fi
if [ -z "$ACME_PACKAGED" ]; then
_saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)" _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)"
fi
_info OK _info OK
} }
@@ -7745,8 +8108,12 @@ uninstall() {
_uninstallalias _uninstallalias
if [ -z "$ACME_PACKAGED" ]; then
#don't remove the script when it is managed by a system package manager,
#LE_WORKING_DIR may point to the packaged files
rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion"
fi
_info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself."
} }
@@ -7782,6 +8149,9 @@ cron() {
_initpath _initpath
_info "$(__green "===Starting cron===")" _info "$(__green "===Starting cron===")"
if [ "$AUTO_UPGRADE" = "1" ]; then if [ "$AUTO_UPGRADE" = "1" ]; then
if [ "$ACME_PACKAGED" ]; then
_info "ACME_PACKAGED is set, skipping the auto upgrade."
else
export LE_WORKING_DIR export LE_WORKING_DIR
( (
if ! upgrade; then if ! upgrade; then
@@ -7797,6 +8167,7 @@ cron() {
_info "Automatically upgraded to: $VER" _info "Automatically upgraded to: $VER"
fi fi
fi
_TREAT_SKIP_AS_SUCCESS="1" _TREAT_SKIP_AS_SUCCESS="1"
renewAll renewAll
_ret="$?" _ret="$?"
@@ -8037,7 +8408,9 @@ Parameters:
--dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the --dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the
generated TXT record so the issuer is also authorized for wildcards generated TXT record so the issuer is also authorized for wildcards
and subdomains (draft-ietf-acme-dns-persist-01). and subdomains (draft-ietf-acme-dns-persist-01). It is implied when
the domain given to -d is a wildcard (e.g. '*.example.com'); the
record itself is always published at the base domain.
--dns-persist-ca-name <name> Used with '--make-dns-persist-value'. Use the given CA identity domain --dns-persist-ca-name <name> Used with '--make-dns-persist-value'. Use the given CA identity domain
(e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If (e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If
omitted, the identities are read from the ACME directory's omitted, the identities are read from the ACME directory's
@@ -8095,8 +8468,9 @@ Parameters:
--ocsp, --ocsp-must-staple Generate OCSP-Must-Staple extension. --ocsp, --ocsp-must-staple Generate OCSP-Must-Staple extension.
--always-force-new-domain-key Generate new domain key on renewal. Otherwise, the domain key is not changed by default. --always-force-new-domain-key Generate new domain key on renewal. Otherwise, the domain key is not changed by default.
--auto-upgrade [0|1] Valid for '--upgrade' command, indicating whether to upgrade automatically in future. Defaults to 1 if argument is omitted. --auto-upgrade [0|1] Valid for '--upgrade' command, indicating whether to upgrade automatically in future. Defaults to 1 if argument is omitted.
--listen-v4 Force standalone/tls server to listen at ipv4. --listen-v4 Force standalone/tls server to listen at ipv4 only.
--listen-v6 Force standalone/tls server to listen at ipv6. By default the standalone server listens on both ipv4 and ipv6.
--listen-v6 Force standalone/tls server to listen at ipv6 only.
--request-v4 Force client requests to use ipv4 to connect to the CA server. --request-v4 Force client requests to use ipv4 to connect to the CA server.
--request-v6 Force client requests to use ipv6 to connect to the CA server. --request-v6 Force client requests to use ipv6 to connect to the CA server.
--openssl-bin <file> Specifies a custom openssl bin location. --openssl-bin <file> Specifies a custom openssl bin location.
@@ -8125,6 +8499,10 @@ Parameters:
} }
installOnline() { installOnline() {
if [ "$ACME_PACKAGED" ]; then
_err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
return 1
fi
_info "Installing from online archive." _info "Installing from online archive."
_branch="$BRANCH" _branch="$BRANCH"
@@ -8182,6 +8560,10 @@ _getUpgradeHash() {
} }
upgrade() { upgrade() {
if [ "$ACME_PACKAGED" ]; then
_err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
exit 1
fi
if ( if (
_initpath _initpath
[ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0 [ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0

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

@@ -163,8 +163,8 @@ byteplus_alb_deploy() {
# ── 3. Read cert and key ───────────────────────────────────────────────────── # ── 3. Read cert and key ─────────────────────────────────────────────────────
# BytePlus requires NO blank lines between PEM blocks in the certificate chain # BytePlus requires NO blank lines between PEM blocks in the certificate chain
_public_key=$(sed '/^[[:space:]]*$/d' "$_cfullchain" | tr -d '\r') _public_key=$(_strip_blank_lines <"$_cfullchain" | tr -d '\r')
_private_key=$(sed '/^[[:space:]]*$/d' "$_ckey" | tr -d '\r') _private_key=$(_strip_blank_lines <"$_ckey" | tr -d '\r')
if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then
_err "Failed to read certificate or key file." _err "Failed to read certificate or key file."

View File

@@ -87,7 +87,11 @@ cpanel_uapi_deploy() {
# Auto mode # Auto mode
if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then
# call API for site config # call API for site config
if [ -n "$_uapi_user" ]; then
_response=$(uapi --user="$_uapi_user" DomainInfo list_domains)
else
_response=$(uapi DomainInfo list_domains) _response=$(uapi DomainInfo list_domains)
fi
# exit if error in response # exit if error in response
if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then
_err "Error in deploying certificate - cannot retrieve sitelist:" _err "Error in deploying certificate - cannot retrieve sitelist:"

View File

@@ -173,10 +173,7 @@ haproxy_deploy() {
# Set the suffix depending if we are creating a bundle or not # Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_haproxy_bundle}" = "yes" ]; then if [ "${Le_Deploy_haproxy_bundle}" = "yes" ]; then
_info "Bundle creation requested" _info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set # shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
if _isEccKey "${Le_Keylength}"; then if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected" _info "ECC key type detected"
_suffix=".ecdsa" _suffix=".ecdsa"

114
deploy/ikuai.sh Normal file
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
}

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" _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" _H1="Cookie: $_cookie"
_response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=ssl_certificates&action=add" "" "POST") _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" _info "_message" "$_message"
if [ -z "$_message" ]; then if [ -z "$_message" ]; then
_err "Fail to upload certificate." _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" _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") _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" _info "_message" "$_message"
if [ -z "$_message" ]; then if [ -z "$_message" ]; then
_err "Fail to apply certificate." _err "Fail to apply certificate."

View File

@@ -121,10 +121,7 @@ lighttpd_deploy() {
# Set the suffix depending if we are creating a bundle or not # Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_lighttpd_bundle}" = "yes" ]; then if [ "${Le_Deploy_lighttpd_bundle}" = "yes" ]; then
_info "Bundle creation requested" _info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set # shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
if _isEccKey "${Le_Keylength}"; then if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected" _info "ECC key type detected"
_suffix=".ecdsa" _suffix=".ecdsa"

View File

@@ -10,6 +10,10 @@
# Usage (shown values are the examples): # Usage (shown values are the examples):
# 1. Set optional environment variables # 1. Set optional environment variables
# - export MULTIDEPLOY_FILENAME="multideploy.yaml" - "multideploy.yml" will be automatically used if not set" # - 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: # 2. Run command:
# acme.sh --deploy --deploy-hook multideploy -d example.com # acme.sh --deploy --deploy-hook multideploy -d example.com
@@ -49,7 +53,7 @@ multideploy_deploy() {
_debug _cfullchain "$_cfullchain" _debug _cfullchain "$_cfullchain"
_debug _cpfx "$_cpfx" _debug _cpfx "$_cpfx"
MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf MULTIDEPLOY_FILENAME)}" _getdeployconf MULTIDEPLOY_FILENAME
if [ -z "$MULTIDEPLOY_FILENAME" ]; then if [ -z "$MULTIDEPLOY_FILENAME" ]; then
MULTIDEPLOY_FILENAME="multideploy.yml" MULTIDEPLOY_FILENAME="multideploy.yml"
_info "MULTIDEPLOY_FILENAME is not set, so I will use '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, # 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. # verifying the existence of the deploy file, and ensuring only one deploy file is present.
# Arguments: # 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: # Usage:
# _preprocess_deployfile "<deploy_file1>" "<deploy_file2>?" # _preprocess_deployfile "<deploy_file1>" "<deploy_file2>?"
_preprocess_deployfile() { _preprocess_deployfile() {
@@ -87,15 +92,21 @@ _preprocess_deployfile() {
_debug3 "yq is installed." _debug3 "yq is installed."
# Check if deploy file exists # Check if deploy file exists
found_file=""
for file in "$@"; do for file in "$@"; do
_debug3 "Checking file" "$DOMAIN_PATH/$file" if _startswith "$file" "/"; then
if [ -f "$DOMAIN_PATH/$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" _debug3 "File found"
if [ -n "$found_file" ]; then if [ -n "$found_file" ]; then
_err "Multiple deploy files found. Please keep only one deploy file." _err "Multiple deploy files found. Please keep only one deploy file."
return 1 return 1
fi fi
found_file="$file" found_file="$_multideploy_path"
else else
_debug3 "File not found" _debug3 "File not found"
fi 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." _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 return 1
fi fi
if ! _check_deployfile "$DOMAIN_PATH/$found_file"; then if ! _check_deployfile "$found_file"; then
_err "Deploy file is not valid: $DOMAIN_PATH/$found_file" _err "Deploy file is not valid: $found_file"
return 1 return 1
fi fi
echo "$DOMAIN_PATH/$found_file" echo "$found_file"
} }
# Description: # Description:

View File

@@ -116,17 +116,24 @@ HEREDOC
export HTTPS_INSECURE=1 export HTTPS_INSECURE=1
export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}" export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") 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)" response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
_retval=$? case "$_status_code" in
2[0-9][0-9])
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response" _debug3 response "$response"
_info "Certificate successfully deployed" _info "Certificate successfully deployed"
return 0 return 0
else fi
_err "Certificate deployment failed: $message" ;;
esac
_err "Certificate deployment failed (HTTP status $_status_code). $message"
_debug "Response" "$response" _debug "Response" "$response"
return 1 return 1
fi
} }

View File

@@ -128,17 +128,24 @@ HEREDOC
export HTTPS_INSECURE=1 export HTTPS_INSECURE=1
export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}" export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") 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)" response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
_retval=$? case "$_status_code" in
2[0-9][0-9])
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response" _debug3 response "$response"
_info "Certificate successfully deployed" _info "Certificate successfully deployed"
return 0 return 0
else fi
_err "Certificate deployment failed: $message" ;;
esac
_err "Certificate deployment failed (HTTP status $_status_code). $message"
_debug "Response" "$response" _debug "Response" "$response"
return 1 return 1
fi
} }

280
deploy/shelly.sh Normal file
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
}

View File

@@ -344,6 +344,7 @@ synology_dsm_deploy() {
else else
_err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more." _err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more."
fi fi
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1 return 1
fi fi
@@ -354,6 +355,7 @@ synology_dsm_deploy() {
if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then
_err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set." _err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set."
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1 return 1
fi fi
@@ -389,13 +391,13 @@ synology_dsm_deploy() {
else else
_info "Restart HTTP services not necessary." _info "Restart HTTP services not necessary."
fi fi
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_logout _logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 0 return 0
else else
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_err "Unable to update certificate, got error response: $response." _err "Unable to update certificate, got error response: $response."
_logout _logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1 return 1
fi fi
} }
@@ -403,6 +405,8 @@ synology_dsm_deploy() {
#################### Private functions below ################################## #################### Private functions below ##################################
_logout() { _logout() {
# Logout CERT user only to not occupy a permanent session, e.g. in DSM's "Connected Users" widget (based on previous variables) # 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") response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=logout&_sid=$sid")
_debug3 response "$response" _debug3 response "$response"
} }

341
deploy/unifios.sh Normal file
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
}

View File

@@ -9,7 +9,7 @@ Options:
AZUREDNS_APPID App ID. App ID of the service principal AZUREDNS_APPID App ID. App ID of the service principal
AZUREDNS_CLIENTSECRET Client Secret. Secret from creating 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_MANAGEDIDENTITY Use Managed Identity. Use Managed Identity assigned to a resource instead of a service principal. "true"/"false"
AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional. AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Not saved, provide it on every run. Optional.
' '
wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS
@@ -47,13 +47,15 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID "" _saveaccountconf_mutable AZUREDNS_TENANTID ""
_saveaccountconf_mutable AZUREDNS_APPID "" _saveaccountconf_mutable AZUREDNS_APPID ""
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET "" _saveaccountconf_mutable AZUREDNS_CLIENTSECRET ""
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN "" _clearaccountconf_mutable AZUREDNS_BEARERTOKEN
else else
_info "You didn't ask to use Azure managed identity, checking service principal credentials or provided bearer token" _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_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}" AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}" 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_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID="" AZUREDNS_SUBSCRIPTIONID=""
@@ -93,7 +95,7 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID" _saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID"
_saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID" _saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID"
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET" _saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET"
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN "$AZUREDNS_BEARERTOKEN" _clearaccountconf_mutable AZUREDNS_BEARERTOKEN
fi fi
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
@@ -175,7 +177,7 @@ dns_azure_rm() {
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}" AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}" AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}" 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_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID="" AZUREDNS_SUBSCRIPTIONID=""

View File

@@ -323,21 +323,21 @@ _bhosted_extract_id() {
fi fi
# JSON: "id":12345 # 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 if [ -n "$_id" ]; then
printf "%s" "$_id" printf "%s" "$_id"
return 0 return 0
fi fi
# key=value: id=12345 # 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 if [ -n "$_id" ]; then
printf "%s" "$_id" printf "%s" "$_id"
return 0 return 0
fi fi
# "record id 12345" / "recordid 12345" # "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 if [ -n "$_id" ]; then
printf "%s" "$_id" printf "%s" "$_id"
return 0 return 0

View File

@@ -75,7 +75,7 @@ dns_creoline_rm() {
return 1 return 1
fi fi
record_id=$(echo "$response" | _egrep_o "\"id\"[[:space:]]*:[[:space:]]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") 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" _debug "record_id" "$record_id"
if [ -z "$record_id" ]; then if [ -z "$record_id" ]; then
@@ -108,10 +108,10 @@ _get_root() {
return 1 return 1
fi fi
_sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
_debug _sub_domain "$_sub_domain" _debug _sub_domain "$_sub_domain"
_domain=$(echo "$response" | _egrep_o "\"domain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ") _domain=$(echo "$response" | _egrep_o "\"domain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
_debug _domain "$_domain" _debug _domain "$_domain"
if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
@@ -171,7 +171,7 @@ _creoline_rest() {
_err "URI:$uri" _err "URI:$uri"
return 1 return 1
elif _contains "$response" "message"; then elif _contains "$response" "message"; then
message=$(echo "$response" | _egrep_o "\"message\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \") message=$(echo "$response" | _egrep_o "\"message\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \")
_err "Error: $message" _err "Error: $message"
_err "URI:$uri" _err "URI:$uri"
return 1 return 1

View File

@@ -285,15 +285,15 @@ _cyon_delete_txt() {
list_txt_url="https://my.cyon.ch/domain/dnseditor/list-async" 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}" _debug list_txt_response "${list_txt_response}"
if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi
# Find and delete all acme challenge entries for the $fulldomain. # 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_type="$(printf "%s" "$_identifier" | cut -d'|' -f1)"
dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)" dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)"

View File

@@ -30,8 +30,9 @@ dns_czechia_add() {
return 1 return 1
fi fi
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//') _czechia_tab="$(printf '\t')"
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') _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 if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken." _err "Missing zone or CZ_AuthorizationToken."
@@ -108,8 +109,9 @@ dns_czechia_rm() {
return 1 return 1
fi fi
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//') _czechia_tab="$(printf '\t')"
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') _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 if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken." _err "Missing zone or CZ_AuthorizationToken."
@@ -180,12 +182,13 @@ _czechia_load_conf() {
} }
_czechia_pick_zone() { _czechia_pick_zone() {
_czechia_pz_tab="$(printf '\t')"
_fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//') _fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//')
_best_zone="" _best_zone=""
_zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g') _zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g')
for _z in $_zones_space; do 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 [ -z "$_clean_z" ] && continue
case "$_fd" in case "$_fd" in

View File

@@ -150,6 +150,9 @@ _dns_dynv6_add_http() {
fi fi
_get_zone_name "$_zone_id" _get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"} record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_set_record TXT "$record" "$txtvalue" _set_record TXT "$record" "$txtvalue"
if _contains "$response" "$txtvalue"; then if _contains "$response" "$txtvalue"; then
_info "Successfully added record" _info "Successfully added record"
@@ -168,6 +171,9 @@ _dns_dynv6_rm_http() {
fi fi
_get_zone_name "$_zone_id" _get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"} record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_get_record_id "$_zone_id" "$record" "$txtvalue" _get_record_id "$_zone_id" "$record" "$txtvalue"
_del_record "$_zone_id" "$_record_id" _del_record "$_zone_id" "$_record_id"
if [ -z "$response" ]; then if [ -z "$response" ]; then

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,23 +96,20 @@ 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"
if [ "$count" = "0" ]; 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" _debug "record_id" "$record_id"
if [ -z "$record_id" ]; then if [ -z "$record_id" ]; then
_err "Can not get record id to remove." _info "Don't need to remove."
return 1 return 0
fi fi
if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
_err "Delete record error." _err "Delete record error."
return 1 return 1
fi fi
_contains "$response" "\"status\":200" _contains "$response" "\"status\":200"
fi
} }

View File

@@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_freemyip
Options: Options:
FREEMYIP_Token API Token FREEMYIP_Token API Token
Issues: github.com/acmesh-official/acme.sh/issues/6247 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?" FREEMYIP_DNS_API="https://freemyip.com/update?"
@@ -72,18 +72,26 @@ dns_freemyip_rm() {
_get_root() { _get_root() {
_fmi_d="$1" _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. # There is random failure while calling freemyip API too fast. This function automatically retry until success.
_freemyip_get_until_ok() { _freemyip_get_until_ok() {
_fmi_url="$1" _fmi_url="$1"
for i in $(seq 1 8); do _fmi_i=1
_debug "HTTP GET freemyip.com API '$_fmi_url', retry $i/8..." while [ "$_fmi_i" -le 8 ]; do
_get "$_fmi_url" | tee /dev/fd/2 | grep OK && return 0 _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 _sleep 1 # DO NOT send the request too fast
_fmi_i=$((_fmi_i + 1))
done 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 return 1
} }
@@ -93,13 +101,16 @@ _is_root_domain_published() {
_webroot="$(_get_root "$_fmi_d")" _webroot="$(_get_root "$_fmi_d")"
_info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet" _info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet"
for i in $(seq 1 3); do _fmi_i=1
_debug "'$_webroot' ns lookup, retry $i/3..." while [ "$_fmi_i" -le 3 ]; do
_debug "'$_webroot' ns lookup, retry $_fmi_i/3..."
if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then
_debug "'$_webroot' already has a TXT record published!" _debug "'$_webroot' already has a TXT record published!"
return 0 return 0
fi fi
_sleep 10 # Give it some time to propagate the TXT record _sleep 10 # Give it some time to propagate the TXT record
_fmi_i=$((_fmi_i + 1))
done done
return 1 return 1
} }

198
dnsapi/dns_hestiacp.sh Normal file
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
}

View File

@@ -441,18 +441,18 @@ _hostup_json_extract() {
input="${2:-$line}" input="${2:-$line}"
# First try to extract quoted values (strings) # First try to extract quoted values (strings)
quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | _head_n 1)" quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*\"[^\"]*\"" | _head_n 1)"
if [ -n "$quoted_match" ]; then if [ -n "$quoted_match" ]; then
printf "%s" "$quoted_match" | printf "%s" "$quoted_match" |
cut -d : -f2- | cut -d : -f2- |
sed 's/^[[:space:]]*"//' | sed 's/^[ ]*"//' |
sed 's/"[[:space:]]*$//' | sed 's/"[ ]*$//' |
sed 's/\\"/"/g' sed 's/\\"/"/g'
return 0 return 0
fi fi
# Fallback for unquoted values (e.g., numeric IDs) # Fallback for unquoted values (e.g., numeric IDs)
unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*[^,}]*" | _head_n 1)" unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*[^,}]*" | _head_n 1)"
if [ -n "$unquoted_match" ]; then if [ -n "$unquoted_match" ]; then
printf "%s" "$unquoted_match" | printf "%s" "$unquoted_match" |
cut -d : -f2- | cut -d : -f2- |

226
dnsapi/dns_hw.sh Normal file
View File

@@ -0,0 +1,226 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_hw_info='Huawei Cloud DNS
Site: HuaweiCloud.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hw
Options:
HW_AK Access Key
HW_SK Secret Access Key
HW_Region Region. E.g. "cn-north-4". Optional, defaults to "cn-north-4".
Issues: github.com/acmesh-official/acme.sh/issues/7221
Author: mashirozx
'
dns_hw_add() {
fulldomain=$1
txtvalue=$2
if ! _hw_init; then
return 1
fi
if ! _hw_get_zoneid "$fulldomain"; then
return 1
fi
if ! _hw_get_recordset "$fulldomain" "$_hw_zoneid"; then
return 1
fi
# Huawei Cloud stores each TXT value with its required inner quotes.
_hw_txt_record="\"\\\"${txtvalue}\\\"\""
case "$_hw_records" in
*"$txtvalue"*)
_debug "TXT record already exists"
;;
*)
if [ -z "$_hw_recordid" ]; then
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":300,\"records\":[${_hw_txt_record}]}"
# Create a TXT record set: https://support.huaweicloud.com/api-dns/dns_api_64001.html
_hw_rest "POST" "/v2/zones/${_hw_zoneid}/recordsets" "" "$_hw_body" || return 1
else
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":${_hw_recordttl},\"records\":[${_hw_records},${_hw_txt_record}]}"
# Update the existing TXT record set: https://support.huaweicloud.com/api-dns/UpdateRecordSets.html
_hw_rest "PUT" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "$_hw_body" || return 1
fi
;;
esac
_saveaccountconf_mutable HW_AK "$HW_AK"
_saveaccountconf_mutable HW_SK "$HW_SK"
if [ -n "$HW_Region" ]; then
_saveaccountconf_mutable HW_Region "$HW_Region"
fi
}
dns_hw_rm() {
fulldomain=$1
txtvalue=$2
if ! _hw_init; then
return 1
fi
if ! _hw_get_zoneid "$fulldomain" || ! _hw_get_recordset "$fulldomain" "$_hw_zoneid"; then
return 1
fi
if [ -z "$_hw_recordid" ]; then
_debug "TXT record not found"
return 0
fi
# Keep unrelated TXT values that share this record set.
_hw_txt_record="\"\\\"${txtvalue}\\\"\""
case "$_hw_records" in
*"$txtvalue"*) ;;
*)
_debug "TXT record value not found"
return 0
;;
esac
_hw_sed_txt_record=$(echo "$_hw_txt_record" | sed 's/\\/\\\\/g')
_hw_new_records=$(echo "$_hw_records" | sed "s/${_hw_sed_txt_record},//; s/,${_hw_sed_txt_record}//; s/${_hw_sed_txt_record}//")
if [ -z "$_hw_new_records" ]; then
# Delete an empty TXT record set: https://support.huaweicloud.com/api-dns/dns_api_64005.html
_hw_rest "DELETE" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "" || return 1
else
_hw_body="{\"name\":\"${fulldomain}.\",\"type\":\"TXT\",\"ttl\":${_hw_recordttl},\"records\":[${_hw_new_records}]}"
# Update the record set after removing this challenge value: https://support.huaweicloud.com/api-dns/UpdateRecordSets.html
_hw_rest "PUT" "/v2/zones/${_hw_zoneid}/recordsets/${_hw_recordid}" "" "$_hw_body" || return 1
fi
}
_hw_init() {
# Credentials from the environment override the persisted account settings.
HW_AK="${HW_AK:-$(_readaccountconf_mutable HW_AK)}"
HW_SK="${HW_SK:-$(_readaccountconf_mutable HW_SK)}"
HW_Region="${HW_Region:-$(_readaccountconf_mutable HW_Region)}"
if [ -z "$HW_AK" ] || [ -z "$HW_SK" ]; then
_err "You don't specify Huawei Cloud Access Key and Secret Access Key yet."
return 1
fi
_hw_region="${HW_Region:-cn-north-4}"
_hw_api="https://dns.${_hw_region}.myhuaweicloud.com"
_hw_host="dns.${_hw_region}.myhuaweicloud.com"
}
_hw_get_zoneid() {
_hw_domain=$1
_hw_index=1
# Try successively shorter suffixes so delegated zones are supported.
while true; do
_hw_zone_name=$(echo "$_hw_domain" | cut -d . -f "$_hw_index"-100)
if [ -z "$_hw_zone_name" ]; then
_err "Could not find Huawei Cloud DNS zone for $_hw_domain"
return 1
fi
_hw_query="name=$(printf "%s" "$_hw_zone_name" | _url_encode upper-hex)&search_mode=equal"
# List public zones to find the authoritative zone: https://support.huaweicloud.com/api-dns/dns_api_62003.html
if ! _hw_rest "GET" "/v2/zones" "$_hw_query" ""; then
return 1
fi
_hw_zoneid=$(echo "$_hw_response" | _egrep_o '"id"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_hw_returned_name=$(echo "$_hw_response" | _egrep_o '"name"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -n "$_hw_zoneid" ] && [ "$_hw_returned_name" = "${_hw_zone_name}." ]; then
return 0
fi
_hw_index=$(_math "$_hw_index" + 1)
done
}
_hw_get_recordset() {
_hw_domain=$1
_hw_zone=$2
_hw_recordid=""
_hw_records=""
_hw_recordttl=""
_hw_query="name=$(printf "%s" "$_hw_domain" | _url_encode upper-hex)&search_mode=equal&type=TXT"
# List TXT record sets to locate the existing challenge record: https://support.huaweicloud.com/api-dns/dns_api_64004.html
if ! _hw_rest "GET" "/v2/zones/${_hw_zone}/recordsets" "$_hw_query" ""; then
return 1
fi
_hw_recordid=$(echo "$_hw_response" | _egrep_o '"id"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_hw_returned_name=$(echo "$_hw_response" | _egrep_o '"name"[ ]*:[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -z "$_hw_recordid" ]; then
return 0
fi
_hw_expected_name="$(_lower_case "${_hw_domain}.")"
if [ "$(_lower_case "$_hw_returned_name")" != "$_hw_expected_name" ]; then
_err "Huawei Cloud DNS returned an unexpected record set for $_hw_domain"
return 1
fi
# A DNS record set may contain multiple TXT values for concurrent challenges.
_hw_records=$(echo "$_hw_response" | sed 's/.*"records"[ ]*:[ ]*\[//; s/\].*//' | tr -d '\r\n')
_hw_recordttl=$(echo "$_hw_response" | _egrep_o '"ttl"[ ]*:[ ]*[0-9]*' | _head_n 1 | cut -d : -f 2 | tr -d ' ')
if [ -z "$_hw_recordttl" ]; then
_err "Huawei Cloud DNS record set did not include a TTL"
return 1
fi
}
_hw_sha256() {
printf "%s" "$1" | _digest sha256 hex
}
_hw_hmac() {
_hw_key_hex=$(printf "%s" "$1" | _hex_dump | tr -d ' ')
printf "%s" "$2" | _hmac sha256 "$_hw_key_hex" hex
}
_hw_rest() {
_hw_method=$1
_hw_uri=$2
_hw_query=$3
_hw_payload=$4
_H1=""
_H2=""
_H3=""
_H4=""
_H5=""
_hw_date=$(_utc_date | tr -d ' :-')
_hw_short_date=${_hw_date%??????}
_hw_date="${_hw_short_date}T${_hw_date#????????}Z"
# Huawei's API gateway signs a trailing slash even when the published URI has none.
_hw_canonical_uri="${_hw_uri%/}/"
# SDK-HMAC-SHA256 signs the exact canonical request sent to Huawei Cloud.
_hw_payload_hash=$(_hw_sha256 "$_hw_payload")
_hw_headers="content-type:application/json
host:${_hw_host}
x-sdk-date:${_hw_date}
"
_hw_signed_headers="content-type;host;x-sdk-date"
_hw_canonical_request="${_hw_method}
${_hw_canonical_uri}
${_hw_query}
${_hw_headers}
${_hw_signed_headers}
${_hw_payload_hash}"
_hw_string_to_sign="SDK-HMAC-SHA256
${_hw_date}
$(_hw_sha256 "$_hw_canonical_request")"
_hw_signature=$(_hw_hmac "$HW_SK" "$_hw_string_to_sign")
_H1="Content-Type: application/json"
_H2="Host: ${_hw_host}"
_H3="X-Sdk-Date: ${_hw_date}"
_H4="Authorization: SDK-HMAC-SHA256 Access=${HW_AK}, SignedHeaders=${_hw_signed_headers}, Signature=${_hw_signature}"
_hw_url="${_hw_api}${_hw_uri}"
if [ -n "$_hw_query" ]; then
_hw_url="${_hw_url}?${_hw_query}"
fi
# _post sends the canonical request with each signed header exactly once.
if [ -z "$HTTP_HEADER" ]; then
_err "HTTP header file is not initialized"
return 1
fi
: >"$HTTP_HEADER" || return 1
if ! _hw_response=$(_post "$_hw_payload" "$_hw_url" "" "$_hw_method"); then
_err "Huawei Cloud DNS API request failed"
return 1
fi
_hw_code=$(grep '^HTTP' "$HTTP_HEADER" | _tail_n 1 | cut -d ' ' -f 2 | tr -d '\r\n')
if ! _startswith "$_hw_code" "2"; then
_err "Huawei Cloud DNS API error: HTTP $_hw_code"
_debug2 response "$_hw_response"
return 1
fi
}

View File

@@ -117,7 +117,7 @@ dns_infoblox_uddi_rm() {
return 0 return 0
fi fi
record_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4) record_id=$(echo "$response" | _egrep_o '"id":[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_debug "record_id" "$record_id" _debug "record_id" "$record_id"
if [ -z "$record_id" ]; then if [ -z "$record_id" ]; then
@@ -178,7 +178,7 @@ _get_root() {
# Check if response contains results (even if empty) # Check if response contains results (even if empty)
if _contains "$response" '"results"'; then if _contains "$response" '"results"'; then
# Extract zone ID - must match the pattern dns/auth_zone/... # Extract zone ID - must match the pattern dns/auth_zone/...
zone_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4) zone_id=$(echo "$response" | _egrep_o '"id":[ ]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -n "$zone_id" ]; then if [ -n "$zone_id" ]; then
# Found the zone # Found the zone
_domain="$h" _domain="$h"

View File

@@ -7,22 +7,23 @@ Options:
JD_ACCESS_KEY_ID Access key ID JD_ACCESS_KEY_ID Access key ID
JD_ACCESS_KEY_SECRET Access key secret JD_ACCESS_KEY_SECRET Access key secret
JD_REGION Region. E.g. "cn-north-1" JD_REGION Region. E.g. "cn-north-1"
Issues: github.com/acmesh-official/acme.sh/issues/2388 Issues: github.com/acmesh-official/acme.sh/issues/7202
Author: @skysaint
' '
_JD_ACCOUNT="https://uc.jdcloud.com/account/accesskey" _JD_ACCOUNT="https://uc.jdcloud.com/account/accesskey"
_JD_PROD="clouddnsservice" _JD_PROD="domainservice"
_JD_API="jdcloud-api.com" _JD_API="jdcloud-api.com"
_JD_API_VERSION="v1" _JD_API_VERSION="v2"
_JD_DEFAULT_REGION="cn-north-1" _JD_DEFAULT_REGION="cn-north-1"
_JD_HOST="$_JD_PROD.$_JD_API" _JD_HOST="$_JD_PROD.$_JD_API"
######## Public functions ##################### ######## Public functions #####################
#Usage: dns_myapi_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" #Usage: dns_jd_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_jd_add() { dns_jd_add() {
fulldomain=$1 fulldomain=$1
txtvalue=$2 txtvalue=$2
@@ -58,24 +59,14 @@ dns_jd_add() {
_debug _sub_domain "$_sub_domain" _debug _sub_domain "$_sub_domain"
_debug _domain "$_domain" _debug _domain "$_domain"
#_debug "Getting getViewTree" #_debug "Getting describeViewTree"
_debug "Adding records" _debug "Adding records"
_addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1},\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}" _addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1}}"
#_addrr='{"req":{"hostRecord":"xx","hostValue":"\"value4\"","jcloudRes":false,"mxPriority":null,"port":null,"ttl":300,"type":"TXT","weight":null,"viewValue":-1},"regionId":"cn-north-1","domainId":"8824"}' #_addrr='{"req":{"hostRecord":"_acme-challenge","hostValue":"XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs","ttl":300,"type":"TXT","viewValue":-1}}'
if jd_rest POST "domain/$_domain_id/RRAdd" "" "$_addrr"; then if jd_rest POST "domain/$_domain_id/ResourceRecord" "" "$_addrr"; then
_rid="$(echo "$response" | tr '{},' '\n' | grep '"id":' | cut -d : -f 2)"
if [ -z "$_rid" ]; then
_err "Can not find record id from the result."
return 1
fi
_info "TXT record added successfully." _info "TXT record added successfully."
_srid="$(_readdomainconf "JD_CLOUD_RIDS")"
if [ "$_srid" ]; then
_rid="$_srid,$_rid"
fi
_savedomainconf "JD_CLOUD_RIDS" "$_rid"
return 0 return 0
fi fi
@@ -97,14 +88,7 @@ dns_jd_rm() {
_JD_BASE_URI="$_JD_API_VERSION/regions/$JD_REGION" _JD_BASE_URI="$_JD_API_VERSION/regions/$JD_REGION"
_info "Getting existing records for $fulldomain" _info "Removing TXT record for $fulldomain"
_srid="$(_readdomainconf "JD_CLOUD_RIDS")"
_debug _srid "$_srid"
if [ -z "$_srid" ]; then
_err "Not rid skip"
return 0
fi
_debug "First detect the root zone" _debug "First detect the root zone"
if ! _get_root "$fulldomain"; then if ! _get_root "$fulldomain"; then
@@ -115,16 +99,37 @@ dns_jd_rm() {
_debug _sub_domain "$_sub_domain" _debug _sub_domain "$_sub_domain"
_debug _domain "$_domain" _debug _domain "$_domain"
_cleardomainconf JD_CLOUD_RIDS # List records, filter by hostRecord and use a large pageSize so it isn't missed on record-heavy zones.
if ! jd_rest GET "domain/$_domain_id/ResourceRecord" "pageSize=50&search=$_sub_domain"; then
_err "Failed to list resource records"
return 1
fi
_aws_tmpl_xml="{\"ids\":[$_srid],\"action\":\"del\",\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}" # Match record by hostRecord + type TXT + hostValue
_record_id=""
_matched="$(echo "$response" | tr '{' '\n' | grep "\"hostRecord\":\"$_sub_domain\"" | grep "\"type\":\"TXT\"" | grep "\"hostValue\":\"$txtvalue\"")"
_debug2 _matched "$_matched"
if jd_rest POST "domain/$_domain_id/RROperate" "" "$_aws_tmpl_xml" && _contains "$response" "\"code\":\"OK\""; then if [ -z "$_matched" ]; then
_info "TXT record not found, nothing to remove."
return 0
fi
_record_id="$(echo "$_matched" | tr ',' '\n' | grep "\"id\":" | cut -d : -f 2 | tr -d '"' | _head_n 1)"
_debug _record_id "$_record_id"
if [ -z "$_record_id" ]; then
_info "Could not extract record id from response, nothing to remove."
return 0
fi
if jd_rest DELETE "domain/$_domain_id/ResourceRecord/$_record_id"; then
_info "TXT record deleted successfully." _info "TXT record deleted successfully."
return 0 return 0
fi fi
return 1
_err "Failed to delete TXT record."
return 1
} }
#################### Private functions below ################################## #################### Private functions below ##################################
@@ -134,13 +139,14 @@ _get_root() {
i=1 i=1
p=1 p=1
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug2 "Checking domain: $h"
if ! jd_rest GET "domain"; then if ! jd_rest GET "domain"; then
_err "error get domain list" _err "error get domain list"
return 1 return 1
fi fi
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug2 "Checking domain: $h"
if [ -z "$h" ]; then if [ -z "$h" ]; then
#not valid #not valid
_err "Invalid domain" _err "Invalid domain"
@@ -168,6 +174,8 @@ _get_root() {
return 1 return 1
} }
# Use '%b' with printf to expand \n escapes in CanonicalRequest and StringToSign.
# Use '%s' for plain values that contain no escapes to avoid unintended expansion.
#method uri qstr data #method uri qstr data
jd_rest() { jd_rest() {
mtd="$1" mtd="$1"
@@ -220,7 +228,7 @@ jd_rest() {
CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$RequestPayloadHash" CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$RequestPayloadHash"
_debug2 CanonicalRequest "$CanonicalRequest" _debug2 CanonicalRequest "$CanonicalRequest"
HashedCanonicalRequest="$(printf "$CanonicalRequest%s" | _digest "$Hash" hex)" HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)"
_debug2 HashedCanonicalRequest "$HashedCanonicalRequest" _debug2 HashedCanonicalRequest "$HashedCanonicalRequest"
Algorithm="JDCLOUD2-HMAC-SHA256" Algorithm="JDCLOUD2-HMAC-SHA256"
@@ -246,19 +254,19 @@ jd_rest() {
kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")" kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")"
_secure_debug2 kSecretH "$kSecretH" _secure_debug2 kSecretH "$kSecretH"
kDateH="$(printf "$RequestDateOnly%s" | _hmac "$Hash" "$kSecretH" hex)" kDateH="$(printf '%s' "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)"
_debug2 kDateH "$kDateH" _debug2 kDateH "$kDateH"
kRegionH="$(printf "$Region%s" | _hmac "$Hash" "$kDateH" hex)" kRegionH="$(printf '%s' "$Region" | _hmac "$Hash" "$kDateH" hex)"
_debug2 kRegionH "$kRegionH" _debug2 kRegionH "$kRegionH"
kServiceH="$(printf "$Service%s" | _hmac "$Hash" "$kRegionH" hex)" kServiceH="$(printf '%s' "$Service" | _hmac "$Hash" "$kRegionH" hex)"
_debug2 kServiceH "$kServiceH" _debug2 kServiceH "$kServiceH"
kSigningH="$(printf "%s" "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)" kSigningH="$(printf '%s' "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)"
_debug2 kSigningH "$kSigningH" _debug2 kSigningH "$kSigningH"
signature="$(printf "$StringToSign%s" | _hmac "$Hash" "$kSigningH" hex)" signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)"
_debug2 signature "$signature" _debug2 signature "$signature"
Authorization="$Algorithm Credential=$JD_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature" Authorization="$Algorithm Credential=$JD_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature"

View File

@@ -1,189 +0,0 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_linode_info='Linode.com (Old)
Deprecated. Use dns_linode_v4
Site: Linode.com
Options:
LINODE_API_KEY API Key
Author: Philipp Grosswiler <philipp.grosswiler@swiss-design.net>
'
LINODE_API_URL="https://api.linode.com/?api_key=$LINODE_API_KEY&api_action="
######## Public functions #####################
#Usage: dns_linode_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_linode_add() {
fulldomain="${1}"
txtvalue="${2}"
if ! _Linode_API; then
return 1
fi
_info "Using Linode"
_debug "Calling: dns_linode_add() '${fulldomain}' '${txtvalue}'"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "Domain does not exist."
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_parameters="&DomainID=$_domain_id&Type=TXT&Name=$_sub_domain&Target=$txtvalue"
if _rest GET "domain.resource.create" "$_parameters" && [ -n "$response" ]; then
_resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1)
_debug _resource_id "$_resource_id"
if [ -z "$_resource_id" ]; then
_err "Error adding the domain resource."
return 1
fi
_info "Domain resource successfully added."
return 0
fi
return 1
}
#Usage: dns_linode_rm _acme-challenge.www.domain.com
dns_linode_rm() {
fulldomain="${1}"
if ! _Linode_API; then
return 1
fi
_info "Using Linode"
_debug "Calling: dns_linode_rm() '${fulldomain}'"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "Domain does not exist."
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_parameters="&DomainID=$_domain_id"
if _rest GET "domain.resource.list" "$_parameters" && [ -n "$response" ]; then
response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")"
resource="$(echo "$response" | _egrep_o "{.*\"NAME\":\s*\"$_sub_domain\".*}")"
if [ "$resource" ]; then
_resource_id=$(printf "%s\n" "$resource" | _egrep_o "\"RESOURCEID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ )
if [ "$_resource_id" ]; then
_debug _resource_id "$_resource_id"
_parameters="&DomainID=$_domain_id&ResourceID=$_resource_id"
if _rest GET "domain.resource.delete" "$_parameters" && [ -n "$response" ]; then
_resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1)
_debug _resource_id "$_resource_id"
if [ -z "$_resource_id" ]; then
_err "Error deleting the domain resource."
return 1
fi
_info "Domain resource successfully deleted."
return 0
fi
fi
return 1
fi
return 0
fi
return 1
}
#################### Private functions below ##################################
_Linode_API() {
if [ -z "$LINODE_API_KEY" ]; then
LINODE_API_KEY=""
_err "You didn't specify the Linode API key yet."
_err "Please create your key and try again."
return 1
fi
_saveaccountconf LINODE_API_KEY "$LINODE_API_KEY"
}
#################### Private functions below ##################################
#_acme-challenge.www.domain.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=domain.com
# _domain_id=12345
_get_root() {
domain=$1
i=2
p=1
if _rest GET "domain.list"; then
response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")"
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug h "$h"
if [ -z "$h" ]; then
#not valid
return 1
fi
hostedzone="$(echo "$response" | _egrep_o "{.*\"DOMAIN\":\s*\"$h\".*}")"
if [ "$hostedzone" ]; then
_domain_id=$(printf "%s\n" "$hostedzone" | _egrep_o "\"DOMAINID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ )
if [ "$_domain_id" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain=$h
return 0
fi
return 1
fi
p=$i
i=$(_math "$i" + 1)
done
fi
return 1
}
#method method action data
_rest() {
mtd="$1"
ep="$2"
data="$3"
_debug mtd "$mtd"
_debug ep "$ep"
export _H1="Accept: application/json"
export _H2="Content-Type: application/json"
if [ "$mtd" != "GET" ]; then
# both POST and DELETE.
_debug data "$data"
response="$(_post "$data" "$LINODE_API_URL$ep" "" "$mtd")"
else
response="$(_get "$LINODE_API_URL$ep$data")"
fi
if [ "$?" != "0" ]; then
_err "error $ep"
return 1
fi
_debug2 response "$response"
return 0
}

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
}

View File

@@ -104,6 +104,9 @@ _get_root_by_getList() {
return 1 return 1
fi fi
_namecheap_domain_list=$(echo "$response" | _egrep_o '<Domain [^>]*')
_debug2 domain_list "$_namecheap_domain_list"
i=2 i=2
p=1 p=1
@@ -120,7 +123,7 @@ _get_root_by_getList() {
return 1 return 1
fi fi
if ! _contains "$response" "$h"; then if ! _namecheap_is_our_dns "$h"; then
_debug "$h not found" _debug "$h not found"
else else
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -133,6 +136,29 @@ _get_root_by_getList() {
return 1 return 1
} }
#Usage: _namecheap_is_our_dns <domain>
#Succeeds only when domains.getList listed exactly <domain> AND that entry is
#served by Namecheap's own DNS. A domain parked on Namecheap's webhosting DNS
#is listed with IsOurDNS="false", and every dns.getHosts/setHosts call against
#it is refused with error 2030288 "not using proper DNS servers". Accepting
#such a domain as the root zone hides a subdomain that IS delegated to
#Namecheap DNS and that the getHosts probe below would have found.
#https://github.com/acmesh-official/acme.sh/issues/7178
_namecheap_is_our_dns() {
_namecheap_entry=$(echo "$_namecheap_domain_list" | grep -F " Name=\"$1\"" | _head_n 1)
if [ -z "$_namecheap_entry" ]; then
return 1
fi
_namecheap_ourdns=$(echo "$_namecheap_entry" | _egrep_o ' IsOurDNS="[^"]*' | cut -d '"' -f 2)
_debug2 "$1 IsOurDNS" "$_namecheap_ourdns"
if [ "$_namecheap_ourdns" = "true" ]; then
return 0
fi
return 1
}
_get_root_by_getHosts() { _get_root_by_getHosts() {
i=100 i=100
p=99 p=99

View File

@@ -33,9 +33,11 @@ dns_netcup_add() {
exit=$(echo "$fulldomain" | tr -dc '.' | wc -c) exit=$(echo "$fulldomain" | tr -dc '.' | wc -c)
exit=$(_math "$exit" + 1) exit=$(_math "$exit" + 1)
i=$exit i=$exit
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while while
[ "$exit" -gt 0 ] [ "$exit" -ge "$_nc_last" ]
do do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -51,12 +53,18 @@ dns_netcup_add() {
_err "$msg" _err "$msg"
return 1 return 1
else else
_nc_found=1
break break
fi fi
fi fi
fi fi
exit=$(_math "$exit" - 1) exit=$(_math "$exit" - 1)
done done
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
logout logout
} }
@@ -70,9 +78,11 @@ dns_netcup_rm() {
exit=$(_math "$exit" + 1) exit=$(_math "$exit" + 1)
i=$exit i=$exit
rec="" rec=""
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while while
[ "$exit" -gt 0 ] [ "$exit" -ge "$_nc_last" ]
do do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -89,12 +99,18 @@ dns_netcup_rm() {
_err "$msg" _err "$msg"
return 1 return 1
else else
_nc_found=1
break break
fi fi
fi fi
fi fi
exit=$(_math "$exit" - 1) exit=$(_math "$exit" - 1)
done done
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
ida=0000 ida=0000
idv=0001 idv=0001
@@ -125,6 +141,27 @@ dns_netcup_rm() {
logout logout
} }
# The zone is looked up by walking the challenge name from the right, one
# label at a time. The leftmost label is the challenge prefix, so the full
# name itself can never be a zone: asking netcup for it only returns 4013
# "Validation Error", which would then mask the real 5028 "zone could not be
# found". Stop one label short, unless the name is too short to have a
# challenge prefix at all (manual invocation).
# levels
_nc_lastlevel() {
if [ "$1" -ge 3 ]; then
echo 2
else
echo 1
fi
}
# fulldomain
_nc_nozone() {
_err "No DNS zone for $1 was found at netcup."
_err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup."
}
_login() { _login() {
tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4) sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4)

244
dnsapi/dns_nexdns.sh Executable file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_nexdns_info='NexDNS
Site: nexdns.tech
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_nexdns
Options:
NEXDNS_Token API token. Can be created at https://nexdns.tech/settings/api-keys
NEXDNS_Api API base url. Default "https://api.nexdns.tech/v1". Optional.
Issues: github.com/acmesh-official/acme.sh/issues/7179
Author: NexDNS <https://github.com/nexdns>
'
NEXDNS_Api_Default="https://api.nexdns.tech/v1"
######## Public functions #####################
#Usage: dns_nexdns_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_nexdns_add() {
fulldomain=$1
txtvalue=$2
if ! _nexdns_init; then
return 1
fi
_saveaccountconf_mutable NEXDNS_Token "$NEXDNS_Token"
if [ "$NEXDNS_Api" != "$NEXDNS_Api_Default" ]; then
_saveaccountconf_mutable NEXDNS_Api "$NEXDNS_Api"
else
_clearaccountconf_mutable NEXDNS_Api
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "Cannot find the zone of $fulldomain in this NexDNS account."
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug _domain_id "$_domain_id"
_info "Adding the TXT record for $fulldomain"
if ! _nexdns_rest POST "zones/$_domain_id/records" "{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}"; then
return 1
fi
_info "The TXT record has been added."
return 0
}
#Usage: dns_nexdns_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_nexdns_rm() {
fulldomain=$1
txtvalue=$2
if ! _nexdns_init; then
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "Cannot find the zone of $fulldomain in this NexDNS account."
return 1
fi
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug _domain_id "$_domain_id"
_info "Removing the TXT record for $fulldomain"
if ! _nexdns_rest GET "zones/$_domain_id/records?type=TXT&name=$_sub_domain"; then
return 1
fi
#All the challenge records share one name and one type, so the value is the
#only thing that tells them apart. A certificate covering example.com and
#*.example.com puts two of them at the same name at the same time.
_record_id="$(echo "$response" | tr '{' "\n" | grep -- "$txtvalue" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
_debug _record_id "$_record_id"
if [ -z "$_record_id" ]; then
_info "The TXT record is already gone, nothing to remove."
return 0
fi
if ! _nexdns_rest DELETE "zones/$_domain_id/records/$_record_id"; then
return 1
fi
_info "The TXT record has been removed."
return 0
}
#################### Private functions below ##################################
#Reads the token and the api url, and applies the default url.
_nexdns_init() {
NEXDNS_Token="${NEXDNS_Token:-$(_readaccountconf_mutable NEXDNS_Token)}"
NEXDNS_Api="${NEXDNS_Api:-$(_readaccountconf_mutable NEXDNS_Api)}"
if [ -z "$NEXDNS_Token" ]; then
_err "You have not set NEXDNS_Token yet."
_err "Create one at https://nexdns.tech/settings/api-keys, on a plan that includes API access, then:"
_err "export NEXDNS_Token=\"your-api-token\""
return 1
fi
if [ -z "$NEXDNS_Api" ]; then
NEXDNS_Api="$NEXDNS_Api_Default"
fi
#A trailing slash would make every request path begin with a double slash.
NEXDNS_Api="$(echo "$NEXDNS_Api" | sed 's|/*$||')"
_debug NEXDNS_Api "$NEXDNS_Api"
return 0
}
#_acme-challenge.www.example.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=example.com
# _domain_id=Zm9vYmFy
_get_root() {
domain=$1
i=1
p=1
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug h "$h"
if [ -z "$h" ]; then
#not valid
return 1
fi
if ! _nexdns_rest GET "zones?search=$h&per_page=100"; then
return 1
fi
#search matches on a substring, so the page can also hold zones that merely
#contain h. Take the id of the one whose name is exactly h.
_domain_id="$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ "$_domain_id" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain=$h
return 0
fi
p=$i
i=$(_math "$i" + 1)
done
}
#Usage: _nexdns_rest GET|POST|DELETE path [body] [attempt]
_nexdns_rest() {
m=$1
ep=$2
data=$3
attempt=${4:-1}
_debug "$ep"
export _H1="Authorization: Bearer $NEXDNS_Token"
export _H2="Content-Type: application/json"
export _H3="Accept: application/json"
if [ "$m" = "GET" ]; then
response="$(_get "$NEXDNS_Api/$ep")"
else
_debug2 data "$data"
response="$(_post "$data" "$NEXDNS_Api/$ep" "" "$m" "application/json")"
fi
if [ "$?" != "0" ]; then
_err "error $ep"
return 1
fi
#A single certificate costs a handful of requests, but a renewal sweep over
#many of them meets the account's per-minute budget, and that run is
#unattended. Retry-After is treated as a floor: an api may report the time one
#token needs at an average rate and name a second when nothing frees for a
#minute, so the wait grows on its own across attempts.
if [ "$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" = "429" ]; then
if [ "$attempt" -ge 4 ]; then
_err "$m $ep failed: rate limited, and the wait budget is spent"
return 1
fi
_retry_after="$(grep -i "^Retry-After" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d : -f 2 | tr -d " \r\n")"
_backoff="$(_math "$attempt" \* 15)"
#The header may also carry an http date. Anything but a plain count of
#seconds falls through to the backoff rather than being parsed: guessing
#wrong about a date is worse than waiting a known interval, and comparing a
#date numerically would abort the hook outright.
case "$_retry_after" in
"" | *[!0-9]*) _retry_after="$_backoff" ;;
*)
if [ "$_retry_after" -lt "$_backoff" ]; then
_retry_after="$_backoff"
fi
;;
esac
#A wait longer than this is a refusal rather than a schedule, and sleeping
#it out would hold the hook for the length of the window. Hand the run back
#instead, so the next cron pass picks it up.
if [ "$_retry_after" -gt 120 ]; then
_err "$m $ep failed: rate limited for ${_retry_after}s, longer than this hook will wait"
return 1
fi
_info "Rate limited by the NexDNS API; retrying in $_retry_after seconds."
_sleep "$_retry_after"
_nexdns_rest "$m" "$ep" "$data" "$(_math "$attempt" + 1)"
return $?
fi
#Whitespace between a key and its value would defeat every match made on the
#body, here and in the callers.
response="$(echo "$response" | _normalizeJson)"
_debug2 response "$response"
#The status line decides success, not the body: a delete answers 204 with no
#body at all, and a record whose own content contains "error": would otherwise
#turn a stored value into a reported failure. The body is read only for the
#message once the status says the request was rejected.
_code="$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug2 _code "$_code"
case "$_code" in
"" | 2*)
return 0
;;
esac
#A rejected request carries {"error":{"code":..,"message":..}}, so say what the
#api says went wrong.
_message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ -z "$_message" ]; then
_message="status $_code"
fi
_err "$m $ep failed: $_message"
return 1
}

View File

@@ -227,7 +227,7 @@ _poweradmin_rest() {
return 1 return 1
fi fi
if printf '%s' "$response" | grep -q '"success"[[:space:]]*:[[:space:]]*false'; then if printf '%s' "$response" | grep -q '"success"[ ]*:[ ]*false'; then
_err "API reported failure on $method $ep" _err "API reported failure on $method $ep"
_debug "Response: $response" _debug "Response: $response"
return 1 return 1

View File

@@ -71,7 +71,7 @@ dns_rage4_rm() {
_debug "Getting txt records" _debug "Getting txt records"
_rage4_rest "getrecords/?id=${_domain_id}" _rage4_rest "getrecords/?id=${_domain_id}"
_record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p') _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -n 's/.*"id":\([0-9][0-9]*\),.*/\1/p')
if [ -z "$_record_id" ]; then if [ -z "$_record_id" ]; then
_err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'." _err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'."
return 1 return 1

145
dnsapi/dns_rltx.sh Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_rltx_info='Realtox Media Cloudpanel DNS API
Site: realtoxmedia.de
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_rltx
Options:
RLTX_Key API Key
RLTX_OrganizationID Organization ID
'
######## Public functions #####################
#Usage: dns_rltx_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_rltx_add() {
fulldomain=$1
txtvalue=$2
_info "Using Realtox Media Cloudpanel DNS API"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _rltx_init; then
return 1
fi
if ! _get_root "$fulldomain"; then
_err "Could not find matching DNS zone for $fulldomain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _domain "$_domain"
_debug _sub_domain "$_sub_domain"
data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}"
if ! _rltx_rest POST "domains/$_domain_id/dns/acme-txt" "$data"; then
_err "Add TXT record request failed"
return 1
fi
if _contains "$response" '"status":"added"'; then
_info "Added TXT record, OK"
return 0
fi
_err "Add TXT record failed: $response"
return 1
}
#Usage: fulldomain txtvalue
#Remove the txt record after validation.
dns_rltx_rm() {
fulldomain=$1
txtvalue=$2
_info "Using Realtox Media Cloudpanel DNS API"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _rltx_init; then
return 1
fi
if ! _get_root "$fulldomain"; then
_err "Could not find matching DNS zone for $fulldomain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _domain "$_domain"
_debug _sub_domain "$_sub_domain"
data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}"
if ! _rltx_rest DELETE "domains/$_domain_id/dns/acme-txt" "$data"; then
_err "Remove TXT record request failed"
return 1
fi
if _contains "$response" '"status":"removed"'; then
_info "Removed TXT record, OK"
return 0
fi
_err "Remove TXT record failed: $response"
return 1
}
#################### Private functions below ##################################
_rltx_init() {
RLTX_Key="${RLTX_Key:-$(_readaccountconf_mutable RLTX_Key)}"
RLTX_OrganizationID="${RLTX_OrganizationID:-$(_readaccountconf_mutable RLTX_OrganizationID)}"
if [ -z "$RLTX_Key" ] || [ -z "$RLTX_OrganizationID" ]; then
RLTX_Key=""
RLTX_OrganizationID=""
_err "Please specify RLTX_Key and RLTX_OrganizationID."
_err "You can export them and retry: export RLTX_Key=... RLTX_OrganizationID=..."
return 1
fi
_saveaccountconf_mutable RLTX_Key "$RLTX_Key"
_saveaccountconf_mutable RLTX_OrganizationID "$RLTX_OrganizationID"
}
_get_root() {
domain=$1
fqdn_encoded="$(printf "%s" "$domain" | _url_encode)"
if ! _rltx_rest GET "domains/dns/acme-zone?fqdn=$fqdn_encoded"; then
return 1
fi
if ! _contains "$response" '"domain_id":"'; then
return 1
fi
_domain_id="$(printf "%s" "$response" | _egrep_o '"domain_id":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
_domain="$(printf "%s" "$response" | _egrep_o '"zone":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
_sub_domain="$(printf "%s" "$response" | _egrep_o '"record_name":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)"
if [ -z "$_domain_id" ] || [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
return 1
fi
return 0
}
_rltx_rest() {
m=$1
ep="$2"
data="$3"
_debug "$ep"
export _H1="X-API-Key: $RLTX_Key"
export _H2="X-Organization-ID: $RLTX_OrganizationID"
export _H3="Content-Type: application/json"
if [ "$m" = "GET" ]; then
response="$(_get "https://api.ccp.realtoxmedia.de/api/$ep")"
else
_debug2 data "$data"
response="$(_post "$data" "https://api.ccp.realtoxmedia.de/api/$ep" "" "$m")"
fi
if [ "$?" != "0" ]; then
_err "Realtox Media Cloudpanel API request failed: $ep"
return 1
fi
_debug2 response "$response"
return 0
}

View File

@@ -368,7 +368,7 @@ _get_auth_token() {
_data_auth="{\"auth\":{\"identity\":{\"methods\":[\"password\"],\"password\":{\"user\":{\"name\":\"${SL_Login_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"},\"password\":\"${SL_Pswd}\"}}},\"scope\":{\"project\":{\"name\":\"${SL_Project_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"}}}}}" _data_auth="{\"auth\":{\"identity\":{\"methods\":[\"password\"],\"password\":{\"user\":{\"name\":\"${SL_Login_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"},\"password\":\"${SL_Pswd}\"}}},\"scope\":{\"project\":{\"name\":\"${SL_Project_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"}}}}}"
export _H1="Content-Type: application/json" export _H1="Content-Type: application/json"
_result=$(_post "$_data_auth" "$auth_uri") _result=$(_post "$_data_auth" "$auth_uri")
_token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | sed -nE "s/[[:space:]]*x-subject-token:[[:space:]]*([[:print:]]*)(\r*)/\1/p") _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | cut -d ':' -f 2- | tr -d ' \t\r')
_dt_curr=$(date +%s) _dt_curr=$(date +%s)
SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}" SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}"
_saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2" _saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2"

View File

@@ -42,7 +42,10 @@ dns_selfhost_add() {
# only match full domains (at the beginning of the string or with a leading whitespace), # only match full domains (at the beginning of the string or with a leading whitespace),
# e.g. don't match mytest.example.com or sub.test.example.com for test.example.com # e.g. don't match mytest.example.com or sub.test.example.com for test.example.com
# if the domain is defined multiple times only the last occurance will be matched # if the domain is defined multiple times only the last occurance will be matched
mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain)(:[[:digit:]]+)([:]?[[:digit:]]*)(.*)/\2\3\4/p") # prepend a space to each line so "start of line" and "after whitespace"
# can both be matched as "after a space/tab" (portable BRE, no ERE (^|..))
_selfhost_tab="$(printf '\t')"
mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p")
_debug2 mapEntry "$mapEntry" _debug2 mapEntry "$mapEntry"
if test -z "$mapEntry"; then if test -z "$mapEntry"; then
_err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID" _err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID"
@@ -54,7 +57,7 @@ dns_selfhost_add() {
rid2=$(echo "$mapEntry" | cut -d: -f3) rid2=$(echo "$mapEntry" | cut -d: -f3)
# read last used rid domain # read last used rid domain
lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain:[[:digit:]]+)(.*)/\2/p") lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p")
_debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry" _debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry"
lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2) lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2)

View File

@@ -145,8 +145,8 @@ _udr_rest() {
_debug data "${data}" _debug data "${data}"
response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")" response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")"
_code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | tr -d ' \t\r')
_description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | tr -d '\r' | sed -e 's/^[ ]*//' -e 's/[ ]*$//')
_debug response_code "$_code" _debug response_code "$_code"
_debug response_description "$_description" _debug response_description "$_description"

View File

@@ -61,7 +61,7 @@ dns_world4you_add() {
if _contains "$res" "successfully"; then if _contains "$res" "successfully"; then
return 0 return 0
else else
msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then if [ "$msg" = '' ]; then
_err "Unable to add record: Unknown error" _err "Unable to add record: Unknown error"
echo "$ret" >'error-01.html' echo "$ret" >'error-01.html'
@@ -125,7 +125,7 @@ dns_world4you_rm() {
if _contains "$res" "successfully"; then if _contains "$res" "successfully"; then
return 0 return 0
else else
msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then if [ "$msg" = '' ]; then
_err "Unable to remove record: Unknown error" _err "Unable to remove record: Unknown error"
echo "$ret" >'error-01.html' echo "$ret" >'error-01.html'
@@ -145,6 +145,17 @@ dns_world4you_rm() {
################ Private functions ################ ################ Private functions ################
# Usage: _w4y_alert_msg <html>
# Extracts the error text out of the alert box of a DNS page.
# "grep -A" is not portable (Solaris /usr/bin/grep: "illegal option -- A"),
# so select from the alert to EOF and keep the same number of lines.
# "\s" is a GNU sed extension, use an explicit space/tab bracket instead.
_w4y_alert_msg() {
_w4y_tab=$(printf '\t')
echo "$1" | sed -n '/alert-notification/,$p' | _head_n 21 |
grep 'class="weak-title">[^<]' | sed "s/<[^>]*>//g;s/^[ $_w4y_tab]*//"
}
# Usage: _login # Usage: _login
_login() { _login() {
WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}" WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}"

View File

@@ -149,7 +149,7 @@ _check_variables() {
org_response="$(echo "$org_response" | _normalizeJson)" org_response="$(echo "$org_response" | _normalizeJson)"
YANDEX360_ORG_ID=$( YANDEX360_ORG_ID=$(
echo "$org_response" | echo "$org_response" |
_egrep_o '"id":[[:space:]]*[0-9]+' | _egrep_o '"id":[ ]*[0-9]+' |
cut -d':' -f2 cut -d':' -f2
) )
_debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID" _debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID"
@@ -216,7 +216,7 @@ _get_token() {
interval=$( interval=$(
echo "$response" | echo "$response" |
_egrep_o '"interval":[[:space:]]*[0-9]+' | _egrep_o '"interval":[ ]*[0-9]+' |
cut -d':' -f2 cut -d':' -f2
) )
_debug 'Polling interval' "$interval" _debug 'Polling interval' "$interval"

View File

@@ -22,21 +22,32 @@ dns_yc_add() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2 txtvalue=$2
# YC_SA_Key_File_PEM_b64/Path are always persisted to the domain conf below,
# so they must be recovered from there first (account conf is only a
# fallback for the YC_Folder_ID case, see the SA_ID/SA_Key_ID save below).
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
if [ "$YC_SA_Key_File_PEM_b64" ]; then if [ "$YC_SA_Key_File_PEM_b64" ]; then
echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
YC_SA_Key_File="private.key" YC_SA_Key_File="private.key"
_yc_key_is_temp=1
_savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" _savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64"
else else
YC_SA_Key_File="$YC_SA_Key_File_Path" YC_SA_Key_File="$YC_SA_Key_File_Path"
_yc_key_is_temp=""
_savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path" _savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path"
fi fi
YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then
@@ -65,11 +76,21 @@ dns_yc_add() {
return 1 return 1
fi fi
else else
# Clear both possible stores -- YC_Zone_ID/YC_Folder_ID/key material are
# persisted to the domain conf, while YC_SA_ID/YC_SA_Key_ID may have been
# saved account-wide (Folder_ID mode), so a plain _clearaccountconf alone
# would leave stale values behind in whichever store wasn't touched.
_cleardomainconf YC_Zone_ID
_clearaccountconf YC_Zone_ID _clearaccountconf YC_Zone_ID
_cleardomainconf YC_Folder_ID
_clearaccountconf YC_Folder_ID _clearaccountconf YC_Folder_ID
_clearaccountconf YC_SA_ID _cleardomainconf YC_SA_ID
_clearaccountconf YC_SA_Key_ID _clearaccountconf_mutable YC_SA_ID
_cleardomainconf YC_SA_Key_ID
_clearaccountconf_mutable YC_SA_Key_ID
_cleardomainconf YC_SA_Key_File_PEM_b64
_clearaccountconf YC_SA_Key_File_PEM_b64 _clearaccountconf YC_SA_Key_File_PEM_b64
_cleardomainconf YC_SA_Key_File_Path
_clearaccountconf YC_SA_Key_File_Path _clearaccountconf YC_SA_Key_File_Path
_err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File."
return 1 return 1
@@ -110,11 +131,30 @@ dns_yc_rm() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2 txtvalue=$2
YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
# See dns_yc_add() for why domain conf is checked before account conf.
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
if [ "$YC_SA_Key_File_PEM_b64" ]; then
echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
YC_SA_Key_File="private.key"
_yc_key_is_temp=1
else
YC_SA_Key_File="$YC_SA_Key_File_Path"
_yc_key_is_temp=""
fi
_debug "First detect the root zone" _debug "First detect the root zone"
if ! _get_root "$fulldomain"; then if ! _get_root "$fulldomain"; then
_err "invalid domain" _err "invalid domain"
@@ -124,16 +164,10 @@ dns_yc_rm() {
_debug _sub_domain "$_sub_domain" _debug _sub_domain "$_sub_domain"
_debug _domain "$_domain" _debug _domain "$_domain"
_debug "Getting txt records" # upsertRecordSets.deletions removes only the given value from the rrset,
if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then # leaving any other values at the same name (e.g. base + wildcard domain)
exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:]*$") # intact -- no need to read the current data set and recompute it.
_debug exists_txtvalue "$exists_txtvalue" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then
else
_err "Error: $response"
return 1
fi
if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then
if _contains "$response" "\"done\": true"; then if _contains "$response" "\"done\": true"; then
_info "Delete, OK" _info "Delete, OK"
return 0 return 0
@@ -194,7 +228,7 @@ _get_root() {
return 1 return 1
fi fi
if _contains "$response" "\"zone\": \"$h\""; then if _contains "$response" "\"zone\": \"$h\""; then
_domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"') _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')
_debug _domain_id "$_domain_id" _debug _domain_id "$_domain_id"
if [ "$_domain_id" ]; then if [ "$_domain_id" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -255,7 +289,9 @@ _yc_login() {
_signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace) _signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace)
_debug2 _signature "$_signature" _debug2 _signature "$_signature"
rm -rf "$YC_SA_Key_File" if [ "$_yc_key_is_temp" ]; then
rm -f "$YC_SA_Key_File"
fi
_jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature")
_debug2 _jwt "$_jwt" _debug2 _jwt "$_jwt"
@@ -264,7 +300,7 @@ _yc_login() {
_iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")"
_debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)" _debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)"
YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')"
_debug3 YC_Token _debug3 YC_Token
return 0 return 0

View File

@@ -57,9 +57,8 @@ waha_send() {
_debug "_data" "$_data" _debug "_data" "$_data"
export _H1="Content-Type: application/json"
if [ "$WAHA_API_KEY" ]; then if [ "$WAHA_API_KEY" ]; then
export _H2="X-Api-Key: $WAHA_API_KEY" export _H1="X-Api-Key: $WAHA_API_KEY"
fi fi
_waha_url="${WAHA_URL}/api/sendText" _waha_url="${WAHA_URL}/api/sendText"