Compare commits

...
88 Commits
  • Merge pull request #7275 from acmesh-official/dev
    allowed_signers: point the examples at 3.1.6
  • allowed_signers: point the examples at 3.1.6
    The comment block told the reader to run "git verify-tag 3.1.5", which is
    exactly the tag that fails: 3.1.5 was tagged by the GitHub release form as
    a lightweight ref and carries no signature. Use 3.1.6 in the examples and
    state the baseline, so the file that teaches verification does not hand out
    a command that cannot work.
    
    https://github.com/acmesh-official/acme.sh/issues/7273
  • Merge pull request #7274 from acmesh-official/dev
    Move the release signing baseline to 3.1.6 and catch lightweight tags…
  • Move the release signing baseline to 3.1.6 and catch lightweight tags in CI
    The 3.1.5 tag was created server-side by publishing the GitHub release,
    which can only produce a lightweight ref, so it carries no signature and
    git verify-tag fails on it. Rather than force-move a published tag, state
    that signing starts at 3.1.6 and cut that tag locally with git tag -s
    before the release is published.
    
    vtag.yml now fails the run when the pushed tag is not a tag object, so the
    same mistake shows up as a red check on the release instead of arriving as
    a user report. The mirror step runs first, so v<tag> is still created; its
    early "already exists" return became an else branch so the check is never
    skipped.
    
    https://github.com/acmesh-official/acme.sh/issues/7273
  • deploy/ruckus: rename _post_file to avoid clashing with the core helper
    Core acme.sh now defines a _post_file() function. Functions and variables
    live in separate namespaces in POSIX sh, so this was not a real conflict,
    but the identical name is confusing to read. Use _post_upfile, matching the
    _post_action / _post_boundary / _post_data locals already in this function.
  • Add DNSMint DNS API (#7238)
    * Add DNSMint DNS API
    
    DNSMint mints hostnames on domains it operates and serves from its own
    authoritative nameservers, so a customer has no zone of their own and the
    challenge is published through its API.
    
    The hostname is derived server-side from the challenge name, so there is
    no root zone to detect and no record id to track: the value published is
    the value removed. Wildcards work because the API keeps the two newest
    values for a name, which is what the apex and wildcard pair needs.
    
    Tested against Let's Encrypt staging for a wildcard plus its apex.
    
    * Add DNSMint DNS API
    
    DNSMint mints hostnames on domains it operates and serves from its own
    authoritative nameservers, so records are published through its API
    rather than a zone you run.
    
    An ACME challenge goes to the DNS-01 endpoint, which derives the hostname
    from the challenge name: no root zone to detect, no record id to track,
    and the value published is the value removed. Wildcards work because the
    API keeps the two newest values for a name.
    
    Any other TXT name is an ordinary record under a hostname and goes to the
    records endpoint instead, which is why the add and rm functions branch on
    the _acme-challenge label. Issuing certificates needs only the dns01:write
    scope; the record endpoint needs hostnames:read and hostnames:write.
    
    Tested against Let's Encrypt staging for a wildcard plus its apex, and
    the non-challenge TXT path against the live API.
    
    * dnsmint: honour DNSMint_Api instead of overwriting it
    
    The assignment was unconditional, so exporting DNSMint_Api did nothing - the
    plugin reset it to the default every time it was sourced. Every neighbouring
    plugin with an _Api variable treats it as an override; this one only looked
    like it did.
    
    Found while writing the dnsapi2 entry: the sentence documenting the override
    would have been false.
    
    * dnsmint: format case blocks with shfmt -i 2
    
    * dnsmint: portable record lookup on removal
    
    grep -F is not on Solaris /usr/bin/grep, and "\n" in a sed replacement is a
    GNU extension that BSD sed writes as a literal n. The second is the one that
    loses data: without the split the whole reply stays on one line, so the match
    succeeds whatever the value is and the id taken is the first record under the
    hostname rather than the one holding the challenge. Removal then deletes
    somebody else's TXT record.
    
    Literal newline in the replacement, as dns_glesys.sh does it, and a case
    pattern per line with the case outside a command substitution.
    
    Docs and Issues now point at dnsapi2 and at the tracking issue upstream.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    
    * dnsmint: echo into sed, and keep _head_n 1 on the id
    
    Both from review on #7238.
    
    The reply arrives with no trailing newline, so printf "%s" hands sed an
    incomplete final line. Solaris /usr/bin/sed discards one, and here that line
    is the whole reply: the loop then sees nothing and every removal reports the
    record already gone, leaving the challenge TXT behind. echo, as lines 154 and
    172 of this file already do.
    
    _head_n 1 was dropped in 61ef816c. A record object carrying a nested id under
    "data" makes _rid two lines and the DELETE URL is then malformed.
    
    ---------
    
    Co-authored-by: kxbnb <hello@dnsmint.com>
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
  • Add support for Private DNS zones on Azure (#7227)
    * Enhance Azure DNS script for private zones support
    
    Added support for Azure Private DNS Zones and updated API versions accordingly.
    
    * Update README.md
    
    * Update Bearer Token description in dns_azure.sh
    
    Clarified the usage of the Bearer Token in the script.
    
    * Update dns_azure.sh
    
    * Update dns_azure.sh
    
    * Update README.md
    
    * implement optimization based on input from maintainer
    
    * Update dns_azure.sh
    
    * create reusable function
    
    * optimize function usage and make less verbose
    
    ---------
    
    Co-authored-by: neil <github@neilpang.com>
    Co-authored-by: Mashiro <adadam@qq.com>
    Co-authored-by: MBWhitestone <25477219+MBWhitestone@users.noreply.github.com>
    Co-authored-by: Pablo <Pablo1@users.noreply.github.com>
    Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
  • Add JetKVM SSH deploy hook (#7254)
    * Add JetKVM SSH deploy hook
    
    Adds deploy/jetkvm.sh to deploy a certificate to a JetKVM
    (https://jetkvm.com) KVM-over-IP device over plain SSH, writing the
    cert/key via a small POSIX shell script piped to the remote "sh" (JetKVM
    has no scp/SFTP server), staged under temp names and atomically renamed
    into place so a dropped connection can't leave the device with a
    mismatched cert/key pair for its own HTTPS listener. Defaults target
    JetKVM's confirmed "Custom" TLS storage path/filenames and default the
    post-upload command to "reboot", since JetKVM has no hot-reload for a new
    certificate.
    
    This factors out the SSH upload logic originally proposed in
    opnsense/plugins#5621 (an OPNsense ACME Client plugin automation) per
    maintainer feedback there, so it can be reused as a small config instead
    of plugin-specific code: https://github.com/opnsense/plugins/pull/5621#issuecomment-5570647257
    
    * Fix restart-command exit-code handling in jetkvm.sh
    
    The default restart command ("reboot") tears down the very SSH
    connection running it, which real hardware testing shows makes ssh's
    own exit code unreliable: it can come back as either a clean 0 or a
    connection-reset 255 for the exact same successful reboot depending on
    timing. The hook previously trusted that single exit code directly, so
    a fully successful, unattended cron renewal could be reported as a
    failed deploy.
    
    Split into two SSH calls: the first uploads and stages the cert/key and
    its exit code is trusted as-is (no reboot risk there). The second runs
    the restart command and is judged by whether a marker line printed
    *before* that command shows up in the captured output -- if the marker
    is missing, the call never really ran (real failure); if it's present,
    only a clean exit or 255 (connection dropped, expected) counts as
    success, while any other exit code is treated as the restart command's
    own genuine failure (e.g. 127 = command not found).
    
    Also: a blank DEPLOY_JETKVM_RESTART_CMD now falls back to "reboot"
    rather than silently skipping the restart -- previously there was no
    way to actually configure "no restart command", since the hook coerced
    any blank value (including one the user deliberately set) back to
    "reboot" on every run. Skipping it now requires the explicit sentinel
    DEPLOY_JETKVM_RESTART_CMD="none".
    
    * Verify JetKVM HTTPS Mode is "Custom" before uploading
    
    Uploading a certificate that the device's active HTTPS Mode won't even
    serve was previously a silent no-op -- the write would succeed but never
    take effect until a human noticed and fixed the mode themselves.
    
    JetKVM's own JSON-RPC getTLSState/setTLSState calls require an
    authenticated WebRTC session (see jetkvm/kvm#1240 and the still-open
    jetkvm/kvm#1515), so there's no documented/headless way to query this.
    Its firmware (web_tls.go / config.go in jetkvm/kvm) does persist the
    mode as a plain JSON field, "tls_mode" (values "", "self-signed", or
    "custom"), in /userdata/kvm_config.json -- confirmed against a real
    device, including that its busybox grep handles the -E/[[:space:]]
    regex used here.
    
    The check runs as the first step of the existing upload SSH call (no
    extra round trip), exits a dedicated code (3) if "tls_mode" isn't
    "custom", and the hook surfaces that as a specific, actionable error
    pointing at the device's web UI setting, distinct from a generic upload
    failure. Since the underlying config file/field is just as undocumented
    as everything else this hook depends on, DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE=no
    opts out entirely in case a future firmware version changes the format.
    
    Also tightens two things noticed while adding this: the "Uploading
    certificate..." info log no longer prints before a call that might
    immediately fail the mode check, and the remote script's own error
    echo (redundant with the local hook's more detailed _err message) is
    dropped.
    
    Confirmed end-to-end against a real JetKVM device: the regex correctly
    matched the device's actual tls_mode=custom, and a full run of the
    updated hook (mode check included) succeeded.
    
    * Address maintainer review: hardcode firmware constants, drop local temp files, fix POSIX portability
    
    Per @neilpang's review (acmesh-official/acme.sh#7254):
    
    1. Drop [[:space:]]/-E from the tls_mode grep -- not portable (Solaris
       sed/grep read it as a literal bracket set); the compact and indented
       JSON forms are both covered by a plain space with '*'.
    2. Use the core _time() wrapper instead of `date +%s || echo 0` -- the
       fallback was dead code (a date binary that doesn't understand %s
       still exits 0), and _time() is the idiom every other hook/dnsapi
       script already uses for this.
    3. Drop the local temp files entirely for both the upload and restart
       SSH calls. The upload script is now built in a variable and piped
       directly into `ssh ... sh` (same pattern as deploy/windows_rdp.sh);
       $? after the pipeline is still ssh's own exit code. This keeps the
       private key off local disk and removes the _mktemp/chmod/rm dance.
    4. Refuse to deploy when the key or fullchain file is empty (e.g. a
       --signcsr-only run) instead of uploading an empty key file and
       rebooting the device.
    5. Use `printf '%s\n'`, not `echo`, for every generated script line --
       dash's echo interprets backslash escapes, so the remote script's
       content would otherwise depend on which /bin/sh happens to run
       acme.sh.
    6. Save DEPLOY_JETKVM_SSH_CMD and DEPLOY_JETKVM_RESTART_CMD with
       _savedeployconf's "base64" flag (as deploy/docker.sh does for its
       own reload command), since a value containing a single quote would
       otherwise break the saved domain.conf line.
    7. Distinguish "config file missing/unreadable" from "HTTPS Mode isn't
       Custom" with separate exit codes -- grep's own exit 2 for a missing
       file was previously funneled into the same "not custom" error,
       misdiagnosing the actual problem. Also stopped suggesting
       REQUIRE_CUSTOM_MODE=no in that error message: following it silently
       turns every future deploy into a no-op once persisted to domain.conf.
    8. Hardcode the remote path, filenames, chmod values and config file
       path as constants instead of DEPLOY_JETKVM_* variables. They're
       firmware facts on a single-purpose, single-root appliance, not user
       configuration -- and since _savedeployconf pins whatever value is
       first used into domain.conf, a firmware-side correction to one of
       these later would never reach anyone who'd already deployed once.
       Only USER/HOST/PORT/SSH_CMD/RESTART_CMD/REQUIRE_CUSTOM_MODE remain.
    9. Run the restart command detached (nohup sh -c 'sleep N; $CMD' &) so
       the ssh call returns as soon as it's launched, before the device
       actually reboots, instead of racing the connection teardown. This
       also removes the marker/case-based "0 or 255" exit-code logic
       entirely, along with the bug it had: a connection dropping after the
       marker printed but before the restart command actually ran was
       previously reported as a successful deploy. The tradeoff (also
       called out inline and in the PR description): a restart command that
       fails after being launched can no longer be detected, only a failure
       to launch it at all.
    10. Use fixed temp filenames for the staged cert/key (not one new name
        per run) plus a `trap ... EXIT` in the generated script, so any
        abort (the mode check, a write failure under `set -e`) cleans up
        instead of leaving another stray key-bearing file on the device.
    11. Trimmed the header: removed the marker/0|255 rationale (obsoleted by
        #9), corrected the "typically overnight" claim about when the
        restart actually runs, and added a wiki reference.
    
    Not yet done: a deployhooks wiki entry (acmesh-official/acme.sh#7254's
    point 12) -- flagged in the PR thread since only a repo collaborator can
    edit that wiki.
    
    Verified: shellcheck (no exclusions needed anymore) and shfmt -i 2
    clean; a local smoke-test harness (stubbed acme.sh core, a fake ssh
    that redirects the hardcoded device paths into a scratch directory)
    covering a clean deploy with byte-exact content/permissions and no
    leftover staged files, RESTART_CMD=none, HTTPS Mode not "custom",
    the config file missing entirely (now a distinct error),
    REQUIRE_CUSTOM_MODE=no, an empty key file (--signcsr case), and a
    failed restart-command launch.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01M7rTpUF3btoBXSZ95Psjh7
    
    * Fix restart-command quoting and correct the failure-detection comment
    
    Per @neilpang's second review round:
    
    1. DEPLOY_JETKVM_RESTART_CMD was interpolated unescaped inside the
       detached command's own single-quoted "sh -c '...'" wrapper. A value
       containing a single quote (e.g. "sh -c 'sync; reboot'") broke that
       quoting, splitting the string so only part of the intended command
       ran, un-detached. Escape embedded single quotes (the standard
       '\'' substitution) before nesting the value, matching how a value
       with no quotes at all still behaves identically. Verified against
       sh and dash directly, and with a new local smoke-test case that
       actually executes the generated detached command and confirms both
       halves of a quoted restart command run intact.
    
    2. The comment claiming "only a failure to launch it at all is caught
       below" was wrong: since the restart command runs as an unwaited
       background job (nohup ... &), the remote sh returns 0 as soon as
       that job is launched, regardless of whether nohup, sh, or the
       restart command itself actually exist or succeed -- measured 0 in
       both cases. Reworded so the comment describes what's actually
       caught (an outright SSH connection failure) instead of implying a
       guarantee the code doesn't provide. No behavior change from this
       half of the fix, comment-only.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01M7rTpUF3btoBXSZ95Psjh7
    
    ---------
    
    Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
  • deploy/ssh: create the backup directory only when there is a file to back up
    Follow-up to 9249c892 (#7249). The mkdir -p ran unconditionally before
    the guarded cp calls, so a first deploy left an empty backup directory
    behind. Move the mkdir into each guarded block.
  • Honor an HTTP-date Retry-After when polling an order
    HARICA answers a processing order with "Retry-After: <HTTP-date>" set
    about two minutes after finalize (discussion 7190). Skipping the date
    left the poll loop on its 2s fallback, and 30 rounds ran out 21 seconds
    before the time the CA had named, so the issue failed although the
    certificate was about to be signed.
    
    Add _httpdate2time, which converts the IMF-fixdate form in shell
    arithmetic: GNU, BSD and busybox date each want a different invocation
    for it, and %a/%b are locale lookups. _retryafter_seconds prints
    delay-seconds as before and turns a date into the seconds left until
    then; a date in the past or an unparseable value still prints nothing.
  • ci: run Pebble with PEBBLE_AUTHZREUSE=0 in PebbleStrict
    Pebble reuses a valid authorization in a new order 50% of the time by
    default, so the dns manual mode case was a coin flip: a reused
    authorization left nothing for the TXT record to answer.
  • Ignore an HTTP-date Retry-After when polling an order
    Pebble answers a processing order with "Retry-After: <HTTP-date>". The
    poll loop cut the value at the first colon and fed "Fri,11Sep202604" to
    "[ -gt 0 ]", which errored with "integer expression expected" on every
    round. Add _retryafter_seconds, which prints the header only in its
    delay-seconds form, and use it at all four Retry-After sites. The two
    sites that already filtered on digits used "[0-9]\+", which Solaris grep
    does not support.
  • Truenas 26 deploy fixes (#7205)
    * Works with TrueNAS 26.0.0-BETA3 now
    
    * Updated to work with new and old versions of TrueNAS
    
    * shfmt fix for my changes
    
    ---------
    
    Co-authored-by: Bill Weiss <github@e.billweiss.net>
  • Both confirmed and fixed in dev.
    1. The four backup cp calls (KEYFILE/CERTFILE/CAFILE/FULLCHAIN) ran
       unguarded. With USE_SCP=yes MULTI_CALL is implicit, so each cp is its
       own ssh call and a missing source aborted the deploy. In batched mode
       it was masked because the exit code is that of the last command.
       Each cp is now wrapped in a remote [ -f ] test.
    2. deploy/ssh.sh tested DEPLOY_SSH_FULLCHAIN = "yes" instead of
       DEPLOY_SSH_MULTI_CALL (since 2017). Effect was only that the
       fullchain backup got deferred to the next batch. Fixed as well.
    
    Please upgrade with acme.sh --upgrade -b dev and retest.
  • Add Opteamax DNS API (#7244)
    Adds dns_opteamax.sh, solving dns-01 challenges through the Opteamax
    customer API (api.opteam.ax). Authentication is a Bearer token created in
    the customer panel; the zone is detected by walking the name up against the
    account's zone list, so subzones and DNS alias mode both work, and rm only
    removes the value it was given so a wildcard's two TXT records survive each
    other.
  • Add Optidata Cloud DNS API (dns_optidata) (#7243)
    * Add Optidata Cloud DNS API (dns_optidata)
    
    * dns_optidata: portable TXT match, drop undocumented "internal" case
    
    Review feedback on #7243:
    
    1. grep -F is not portable: Solaris /usr/bin/grep has neither -F nor --.
       The DNS test only passed there because the CI job prepends
       /usr/gnu/bin to PATH. txtvalue is base64url ([A-Za-z0-9_-]), so it
       needs no JSON escaping and a case pattern matches it with a shell
       builtin, without depending on any grep extension.
    
    2. Dropped the undocumented "internal" exception from the zone status
       notice in _get_root. It only silenced an _info message, and a dns-01
       challenge has to resolve publicly anyway, so an internal zone can
       never validate through this hook.
    
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
  • ci: enable the dns manual mode case in PebbleStrict
    TEST_DNS_MANUAL=1 turns on le_test_dns_manual_renew, which answers the
    dns-01 challenge through the pebble-challtestsrv that the compose setup
    already runs.
  • Poll the order this run created, not the one the previous cert came from (#7237)
    A renewal in dns manual mode writes the previous certificate again. The
    second invocation resumes from the domain conf, which carries
    Le_LinkOrder and Le_LinkCert from the last successful issuance. newOrder
    saves only Le_OrderFinalize, so after finalizing the new order the
    `[ -z "$Le_LinkOrder" ]` guard keeps the stale link, the poll reads the
    old order, and its certificate URL is the old certificate.
    
    The same gap breaks a first issuance in dns manual mode outright: there
    is no stale link to fall back on, and a finalize that answers while the
    order is still processing carries no Location header, so the run dies
    with "could not get order link location header".
    
    Save the order link where the order is created, next to Le_OrderFinalize,
    and drop the certificate link that belongs to the order just replaced.
    
    Fixes #7105
  • TrueNAS deploy script witch websocat instead of Python midclt internal command (#7216)
    * Add files via upload
    
    TrueNAS deploy script for SCALE/CORE using websocket (websocat binary)
    It is recommend to use a wildcard certificate
    
    Tested with TrueNAS SCALE 25.10 (API "wss://host/api/current", JSON-RPC 2.0).
    Unlike "truenas_ws" hook, this script does NOT use midclt, the truenas_api_client Python package.
    It only depends on:
    - jq
    - websocat  (a static binary you deploy)
    
    Why: avoids installing a Python environment / TrueNAS package on OPNsense just to push a certificate.
    
    IMPORTANT: This script is written in pure POSIX sh (no coproc, no bash arrays).
    
    * Update truenas_websocat.sh
    
    Mistake on port and procotol.
    
    * Update truenas_websocat.sh
    
    Adjustment on the "Why"
    
    * Add files via upload
    
    * Update truenas_websocat.sh
    
    * Update truenas_websocat.sh
    
    Apply shellcheck disable=SC2016 to avoid false positive.
    
    * Update truenas_websocat.sh
  • Update netcup DNS API to support new API (#7214)
    * dns_netcup: add support for the new netcup REST API
    
    Domains managed by the new DNS backend can be handled through the new
    REST API at api.netcup.com. The API is selected by the length of
    NC_Apikey: new REST API keys are 64 characters long, legacy CCP API
    keys are 50.
    
    With a REST API key the domain is looked up via GET /v1/domain and the
    challenge record is managed through the dedicated ACME challenge
    endpoints. After adding a record, the script waits 20 seconds and then
    polls until the record reports the deployed status.
    
    Domains whose DNS cannot be managed via the REST API yet fall back to
    the legacy CCP API when NC_Apikey_Legacy, NC_Apipw and NC_CID are
    configured.
    
    * dns_netcup: treat non-challenge records as a no-op on the REST API
    
    The REST API can only manage _acme-challenge records, records with
    other names cannot exist behind it. The DNS-API-Test adds and removes
    a TXT record outside _acme-challenge and expects both calls to
    succeed, so treat such records as a successful no-op with an info
    message instead of failing.
    
    * dns_netcup: address review feedback for the REST API support
    
    - Only skip the synthetic DNS-API-Test record: real records without
      the _acme-challenge prefix (e.g. a challenge alias in the "=" form)
      now fail loudly, or use the legacy CCP API when legacy credentials
      are configured. The zone walk starts at the full name for them, so
      an apex alias is found.
    - Blank _H2..._H5 for REST API calls and clear all header slots before
      legacy CCP API calls so no auth headers leak between endpoints or
      dns hooks.
    - Stop walking the zone lookup when the API reports success:false and
      surface the response instead of a misleading "no zone found".
    - Split the response before extracting id/isDnsManaged so the egrep
      and sed implementations of _egrep_o cannot pick different matches.
    - Fall back to the legacy CCP API only on a literal isDnsManaged
      false; error distinctly on an unparsable value.
    - Poll the deploy status right away and sleep between retries instead
      of an unconditional 20 second sleep.
    - Use ${#NC_Apikey} for the key length and rename internal state to
      _nc_apikey/_nc_endrest.
    
    * dns_netcup: walk on when the REST API reports resourceDoesNotExist
    
    Querying /domain?fqdn= for a name that is not a domain of the account
    does not return an empty result: the API answers with success:false
    and the error code resourceDoesNotExist. Treat exactly that as "not
    found" during the zone walk and keep failing hard on everything else,
    e.g. an invalid API key.
  • fix
  • sync (#7234)
    * Merge pull request #7220 from moezx/dev
    
    Add AK & SK based Huawei Cloud DNS API
    
    * fix: dynv6 record parsing (#7197)
    
    * 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>
    
    * add trigger
    
    * minor
    
    ---------
    
    Co-authored-by: Mashiro <adadam@qq.com>
    Co-authored-by: MBWhitestone <25477219+MBWhitestone@users.noreply.github.com>
    Co-authored-by: Pablo <Pablo1@users.noreply.github.com>
    Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
  • 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>
  • Merge pull request #7220 from moezx/dev
    Add AK & SK based Huawei Cloud DNS API
  • 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.
  • 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
  • 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
  • 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>
  • Merge pull request #5194 from flesniak/myloc
    Add dnsapi script for myloc.de/webtropia.com
  • 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.
  • 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.
  • 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>
  • 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
  • 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.
  • 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
  • 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
  • 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
  • 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.
  • 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
  • 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
  • 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
  • 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
  • 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.
  • 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.
  • 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
  • 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.
  • 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>
  • 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
  • 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
  • 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
  • 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.
  • Add newline at end of waha.sh
    Fix missing newline at end of file.
70 changed files with 5549 additions and 558 deletions
+259 -15
View File
@@ -66,7 +66,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- name: Set env file
@@ -114,7 +114,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install tools
run: |
brew untap aws/tap || true
@@ -167,7 +167,7 @@ jobs:
- name: Set git to use LF
run: |
git config --global core.autocrlf false
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install cygwin base packages with chocolatey
run: |
choco config get cacheLocation
@@ -231,12 +231,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/freebsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg install -y socat curl
usesh: true
@@ -289,12 +290,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/ghostbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg install -y socat curl
usesh: true
@@ -345,12 +347,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg_add socat curl libiconv
usesh: true
@@ -401,12 +404,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/netbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: |
/usr/sbin/pkg_add curl socat
@@ -458,12 +462,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/dragonflybsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: |
pkg install -y libnghttp2
@@ -519,12 +524,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/midnightbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: mport install socat curl || true
usesh: true
@@ -576,12 +582,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/solaris-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: |
@@ -635,12 +642,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/omnios-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: pkg install socat
@@ -691,12 +699,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openindiana-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: pkg install socat
@@ -747,12 +756,13 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/tribblix-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: zap install socat
@@ -803,19 +813,20 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/haiku-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: rsync
copyback: false
prepare: |
mkdir -p /boot/home/.cache
pkgman install -y cronie
run: |
if [ "${{ secrets.TokenName1}}" ] ; then
export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}"
@@ -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"
+1
View File
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/dragonflybsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/freebsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -66,6 +66,7 @@ jobs:
- uses: vmactions/ghostbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -65,6 +65,7 @@ jobs:
- uses: vmactions/haiku-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+76
View File
@@ -0,0 +1,76 @@
name: HardenedBSD
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/HardenedBSD.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/HardenedBSD.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
HardenedBSD:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
ACME_USE_WGET: 1
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/hardenedbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
prepare: pkg install -y socat curl wget
usesh: true
sync: nfs
run: |
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+76
View File
@@ -0,0 +1,76 @@
name: Hurd
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/Hurd.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/Hurd.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
Hurd:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/hurd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN'
nat: |
"8080": "80"
# Do NOT install socat: socat's SYSTEM: address is broken on GNU Hurd
# (the child shell output goes to socat's stdout instead of the socket,
# so clients get an empty reply). Without socat, acme.sh standalone
# mode falls back to its python3 server, which works on Hurd.
prepare: |
apt-get update -y
apt-get install -y curl cron
usesh: true
sync: rsync
copyback: false
run: |
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+1
View File
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/midnightbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/netbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+86
View File
@@ -0,0 +1,86 @@
name: OPNsense
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/OPNsense.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/OPNsense.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
OPNsense:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
ACME_USE_WGET: 1
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/opnsense-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
prepare: pkg install -y socat curl wget
usesh: true
sync: nfs
run: |
#OPNsense is a firewall appliance whose web GUI holds the 80 port,
#where every --standalone case listens. configd has no "stop"
#action for it and the rc script cannot stop it either, so kill it.
#This belongs here and not in prepare: prepare runs before the
#cache-after-prepare reboot, which would bring the GUI back. And do
#NOT free the port by disabling the GUI's http redirect in
#config.xml: pf's automatic pass rule for the 80 port is generated
#from the web GUI settings, so dropping the redirect also drops the
#rule on the next boot, and the inbound challenge is filtered.
pkill lighttpd || true
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/omnios-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+78
View File
@@ -0,0 +1,78 @@
name: OpenEuler
on:
push:
branches:
- '*'
paths:
- '*.sh'
- '.github/workflows/OpenEuler.yml'
pull_request:
branches:
- dev
paths:
- '*.sh'
- '.github/workflows/OpenEuler.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
OpenEuler:
strategy:
matrix:
include:
- TEST_ACME_Server: "LetsEncrypt.org_test"
CA_ECDSA: ""
CA: ""
CA_EMAIL: ""
TEST_PREFERRED_CHAIN: (STAGING)
runs-on: ubuntu-latest
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
CA_ECDSA: ${{ matrix.CA_ECDSA }}
CA: ${{ matrix.CA }}
CA_EMAIL: ${{ matrix.CA_EMAIL }}
TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
steps:
- uses: actions/checkout@v7
- uses: anyvm-org/cf-tunnel@v0
id: tunnel
with:
protocol: http
port: 8080
- name: Set envs
run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openeuler-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN'
nat: |
"8080": "80"
prepare: |
# openEuler ships every repo with both a baseurl and a metalink.
# The metalink mirror pool is split-brain (dl-cdn.openeuler.openatom.cn
# froze at the 2026-08-20 snapshot while repo.openeuler.org moved on),
# so dnf takes repomd.xml from the stale mirror and then 404s fetching
# the checksummed metadata it names from the fresh ones. Keep only the
# vendor baseurl, which is self-consistent.
sed -i '/^metalink=/d' /etc/yum.repos.d/*.repo
dnf install -y curl socat cronie tar gzip
usesh: true
sync: rsync
copyback: false
run: |
cd ../acmetest \
&& ./letest.sh
- name: DebugOnError
if: ${{ failure() }}
run: |
echo "See how to debug in VM:"
echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openindiana-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+9 -1
View File
@@ -31,13 +31,21 @@ jobs:
Le_HTTPPort: 5002
TEST_LOCAL: 1
TEST_CA: "Pebble Intermediate CA"
TEST_DNS_MANUAL: 1
steps:
- uses: actions/checkout@v6
- name: Install tools
run: sudo apt-get install -y socat
- name: Run Pebble
run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d
run: |
cd ..
curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml
# Pebble reuses a valid authorization in a new order 50% of the time
# by default, which makes the dns manual mode case a coin flip: a
# reused authorization leaves nothing for the TXT record to answer.
printf 'services:\n pebble:\n environment:\n PEBBLE_AUTHZREUSE: "0"\n' >docker-compose.override.yml
docker compose up -d
- name: Set up Pebble
run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4
- name: Clone acmetest
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/solaris-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+1
View File
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/tribblix-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
+45 -3
View File
@@ -10,9 +10,16 @@ on:
- '**.sh'
- "Dockerfile"
- '.github/workflows/dockerhub.yml'
# A dispatch on a tag ref rebuilds that tag's own image; the weekly
# schedule (default branch only) dispatches the latest release tag so a
# pinned version tag picks up Alpine package security updates (issue 7209).
# master never publishes anything but latest.
schedule:
- cron: '17 3 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -40,13 +47,13 @@ jobs:
build:
runs-on: ubuntu-latest
needs: CheckToken
if: "contains(needs.CheckToken.outputs.hasToken, 'true')"
if: "github.event_name != 'schedule' && contains(needs.CheckToken.outputs.hasToken, 'true')"
permissions:
contents: read
packages: write
steps:
- name: checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up QEMU
@@ -98,3 +105,38 @@ jobs:
--tag ghcr.io/${{ github.repository }}:${DOCKER_IMAGE_TAG} \
${DOCKER_IMAGE}:${DOCKER_IMAGE_TAG} \
|| echo "::warning::GHCR mirror failed; Docker Hub publish unaffected"
rebuild:
# weekly: dispatch this workflow on the latest release tag so the tag
# rebuilds its own image from its own commit and its own workflow file
runs-on: ubuntu-latest
if: github.event_name == 'schedule'
permissions:
contents: read
actions: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: dispatch a rebuild of the latest release tag
run: |
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
if [ -z "$tag" ]; then
echo "::error::cannot resolve the latest release tag"
exit 1
fi
echo "dispatching a rebuild of ${tag}"
# A tag cut before this job existed carries a workflow file with no
# workflow_dispatch trigger; the API rejects the dispatch with 422.
# That is expected (nothing to rebuild there), so only a different
# error fails the job.
if ! out="$(gh workflow run dockerhub.yml --repo "${GITHUB_REPOSITORY}" --ref "${tag}" 2>&1)"; then
echo "$out"
case "$out" in
*"does not have 'workflow_dispatch' trigger"*)
echo "::warning::${tag} predates the dispatch trigger; skipping the rebuild"
;;
*)
exit 1
;;
esac
fi
+67
View File
@@ -0,0 +1,67 @@
name: Mirror version tag
# Historical release tags are plain version numbers ("3.1.3") and cannot be
# renamed. When a plain version tag is pushed (including the tag created by
# publishing a GitHub release), mirror it as a "v"-prefixed tag ("v3.1.3")
# pointing to the same object, so both forms exist.
# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and
# refs created with GITHUB_TOKEN do not fire workflows anyway.
# The job mirrors first and then fails when the pushed tag is lightweight,
# which is what the release form produces: that tag can never carry a
# signature, so the failure has to be loud.
on:
push:
tags:
- '[0-9]*'
permissions:
contents: write
jobs:
vtag:
if: github.repository == 'acmesh-official/acme.sh'
runs-on: ubuntu-latest
steps:
- name: Create the v-prefixed tag
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# Mirror the object the pushed tag actually points at: the commit
# for a lightweight tag, the tag object itself for an annotated or
# signed one. Pointing the mirror at the commit would strip the
# signature, so "git verify-tag v3.1.3" would fail while
# "git verify-tag 3.1.3" succeeds.
_ref="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq '.object.sha + " " + .object.type')"
sha="${_ref%% *}"
objtype="${_ref##* }"
if [ -z "$sha" ] || [ "$sha" = "null" ]; then
echo "Could not resolve refs/tags/$TAG"
exit 1
fi
if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v$TAG already exists, nothing to do."
else
gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
echo "Created tag v$TAG -> $sha"
fi
- name: Check that the tag is signable
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# A release published from the GitHub UI creates the tag server-side
# as a lightweight ref, which points straight at a commit and can
# never carry a signature (3.1.5 shipped that way, see issue 7273).
# The tag has to be created locally with "git tag -s" and pushed
# BEFORE the release is published, then selected on the release form.
objtype="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.type)"
if [ "$objtype" != "tag" ]; then
echo "::error::refs/tags/$TAG points at a $objtype, not a tag object. It is a lightweight tag and carries no signature. Recreate it locally with 'git tag -s $TAG' and push it before publishing the release."
exit 1
fi
echo "refs/tags/$TAG is a tag object."
+33
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/Tribblix.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg" alt="Tribblix"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg" alt="Haiku"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg" alt="Hurd"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg" alt="OpenEuler"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg" alt="HardenedBSD"></a>
<a href="https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml"><img src="https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg" alt="OPNsense"></a>
</p>
<p align="center">
@@ -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
|26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix
|27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD
|28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd
|29|[![OpenEuler](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler
|30|[![HardenedBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml)|HardenedBSD
|31|[![OPNsense](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml)|OPNsense
> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
@@ -223,6 +231,31 @@ Cron entry example:
acme.sh -h
```
#### 🔏 Verify a Release
Release tags from `3.1.6` on are signed with the maintainer's SSH key. The
signing happens on the maintainer's machine, so the private key is never
available to CI. The public half is [`allowed_signers`](allowed_signers) in
this repository. From a clone:
```bash
git config gpg.ssh.allowedSignersFile allowed_signers
```
```bash
git verify-tag 3.1.6
```
The signature covers the tag object, which pins the commit and therefore the
whole tree, so a good signature verifies every file at that release and no
separate tarball checksum is needed. Build a tarball from the verified tag:
```bash
git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
```
> ⚠️ Tags up to `3.1.5` are unsigned.
---
### 2️⃣ Issue a Certificate
+702 -140
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# acme.sh release signing key.
#
# Release tags are signed with this key. Its private half is held by the
# maintainer and is never available to CI, so a compromise of the build
# pipeline cannot produce a tag that verifies against this file.
#
# Signing starts at 3.1.6; tags up to 3.1.5 are unsigned.
#
# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE
#
# To verify a release tag, from a clone of this repository:
#
# git config gpg.ssh.allowedSignersFile allowed_signers
# git verify-tag 3.1.6
#
# A good signature covers the tag object, which pins the commit, which pins
# the whole tree -- so verifying the tag verifies every file at that
# release. Build a tarball from the verified tag with:
#
# git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
#
github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB
+2 -2
View File
@@ -163,8 +163,8 @@ byteplus_alb_deploy() {
# ── 3. Read cert and key ─────────────────────────────────────────────────────
# BytePlus requires NO blank lines between PEM blocks in the certificate chain
_public_key=$(sed '/^[[:space:]]*$/d' "$_cfullchain" | tr -d '\r')
_private_key=$(sed '/^[[:space:]]*$/d' "$_ckey" | tr -d '\r')
_public_key=$(_strip_blank_lines <"$_cfullchain" | tr -d '\r')
_private_key=$(_strip_blank_lines <"$_ckey" | tr -d '\r')
if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then
_err "Failed to read certificate or key file."
+5 -1
View File
@@ -87,7 +87,11 @@ cpanel_uapi_deploy() {
# Auto mode
if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then
# call API for site config
_response=$(uapi DomainInfo list_domains)
if [ -n "$_uapi_user" ]; then
_response=$(uapi --user="$_uapi_user" DomainInfo list_domains)
else
_response=$(uapi DomainInfo list_domains)
fi
# exit if error in response
if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then
_err "Error in deploying certificate - cannot retrieve sitelist:"
+1 -4
View File
@@ -173,10 +173,7 @@ haproxy_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_haproxy_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env sh
# Here is a script to deploy cert to ikuai using curl
#
# it requires following environment variables:
#
# IKUAI_SCHEME="http" - http or https , defaults to "http"
# IKUAI_HOSTNAME="localhost" - host , defaults to "192.168.9.1"
# IKUAI_PORT="80" - port , defaults to "80"
# IKUAI_USERNAME="admin" - username , defaults to "admin"
# IKUAI_PASSWORD="yourPassword" - password
# IKUAI_CERT_ID=1 - ikuai cert id , defaults to 1, and only 1 is supported for now !!!
#
#returns 0 means success, otherwise error.
#
######## Public functions #####################
#
#domain keyfile certfile cafile fullchain
ikuai_deploy() {
_cdomain="$1"
_ckey="$2"
_ccert="$3"
_cca="$4"
_cfullchain="$5"
_debug _cdomain "$_cdomain"
_debug _ckey "$_ckey"
_debug _ccert "$_ccert"
_debug _cca "$_cca"
_debug _cfullchain "$_cfullchain"
# Get deploy conf
_getdeployconf IKUAI_SCHEME
_getdeployconf IKUAI_HOSTNAME
_getdeployconf IKUAI_PORT
_getdeployconf IKUAI_USERNAME
_getdeployconf IKUAI_PASSWORD
_getdeployconf IKUAI_CERT_ID
# Use default if not provided
[ -n "$IKUAI_SCHEME" ] || IKUAI_SCHEME="http"
[ -n "$IKUAI_HOSTNAME" ] || IKUAI_HOSTNAME="192.168.9.1"
[ -n "$IKUAI_PORT" ] || IKUAI_PORT=80
[ -n "$IKUAI_USERNAME" ] || IKUAI_USERNAME="admin"
[ -n "$IKUAI_CERT_ID" ] || IKUAI_CERT_ID=1
if [ -z "$IKUAI_PASSWORD" ]; then
_err "please define IKUAI_PASSWORD."
return 1
fi
_debug2 IKUAI_SCHEME "$IKUAI_SCHEME"
_debug2 IKUAI_HOSTNAME "$IKUAI_HOSTNAME"
_debug2 IKUAI_PORT "$IKUAI_PORT"
_debug2 IKUAI_USERNAME "$IKUAI_USERNAME"
_secure_debug2 IKUAI_PASSWORD "$IKUAI_PASSWORD"
_info "Login to ikuai ..."
_ikuai_url="$IKUAI_SCHEME://$IKUAI_HOSTNAME:$IKUAI_PORT"
_pass_md5="$(printf "%s" "$IKUAI_PASSWORD" | _digest md5 hex | _lower_case)"
_pass_salt="$(printf "salt_11%s" "$IKUAI_PASSWORD" | _base64)"
_debug2 _ikuai_url "$_ikuai_url"
_login_req="{\"username\":\"$IKUAI_USERNAME\",\"passwd\":\"$_pass_md5\",\"pass\":\"$_pass_salt\",\"remember_password\":\"\"}"
_response=$(_post "$_login_req" "$_ikuai_url/Action/login" "" "POST" "application/json")
_err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)"
# check ErrMsg
if [ "$_err_msg" != "Success" ]; then
_err "Failed to login to ikuai: $_err_msg"
return 1
fi
# check cookie
_cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2 | sed 's/;.*//')"
if [ -z "$_cookie" ]; then
_err "Fail to get the cookie."
return 1
fi
# Set cookie header
_H1="Cookie: $_cookie; username=$IKUAI_USERNAME; login=1"
_info "Deploy the cert to ikuai ... "
# Should replace \n to @ ," " to #
_cert_content_single_line="$(tr <"$_cfullchain" '\n' '@' | tr ' ' '#')"
_key_content_single_line="$(tr <"$_ckey" '\n' '@' | tr ' ' '#')"
_debug2 _cert_content_single_line "$_cert_content_single_line"
_secure_debug2 _key_content_single_line "$_key_content_single_line"
_key_manager_req="{\"func_name\":\"key_manager\",\"action\":\"save\",\"param\":{\"ca\":\"$_cert_content_single_line\",\"key\":\"$_key_content_single_line\",\"id\":$IKUAI_CERT_ID,\"enabled\":\"yes\",\"comment\":\"\"}}"
_response=$(_post "$_key_manager_req" "$_ikuai_url/Action/call" "" "POST" "application/json")
_err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)"
# check ErrMsg
if [ "$_err_msg" != "Success" ]; then
_err "Failed to deploy the cert to ikuai: $_err_msg"
return 1
fi
_info "Save the deploy config ... "
# Save the config
_savedeployconf IKUAI_SCHEME "$IKUAI_SCHEME"
_savedeployconf IKUAI_HOSTNAME "$IKUAI_HOSTNAME"
_savedeployconf IKUAI_PORT "$IKUAI_PORT"
_savedeployconf IKUAI_USERNAME "$IKUAI_USERNAME"
_savedeployconf IKUAI_PASSWORD "$IKUAI_PASSWORD"
_savedeployconf IKUAI_CERT_ID "$IKUAI_CERT_ID"
_info "Successfully deployed certificate to ikuai. Enjoy! :>"
return 0
}
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env sh
# Script to deploy a certificate to a JetKVM (https://jetkvm.com) KVM-over-IP
# device over SSH. See also:
# https://github.com/acmesh-official/acme.sh/wiki/deployhooks
#
# JetKVM only supports key-based SSH authentication (root@<device>, password
# logins are disabled) once "Developer Mode" is enabled and a public key is
# pasted into its web UI (Settings > Advanced). SSH keys must already be
# exchanged and a passwordless login confirmed working (e.g. `ssh
# root@jetkvm.example.com true`) before using this hook.
#
# JetKVM's minimal userspace does not ship an scp binary or SFTP server, so
# unlike deploy/ssh.sh this hook has no "use scp" option: it always writes
# the certificate and key by piping a small POSIX shell script to the
# remote "sh" over stdin (only depends on "sh", "cat", "chmod", "mkdir",
# "mv" and "rm" on the device side). The remote path, filenames and file
# permissions are firmware constants on this single-purpose, single-root
# appliance, so they are not configurable here.
#
# JetKVM's "Custom" TLS mode (device web UI: Settings > Network > HTTPS
# Mode, must already be set to "Custom" before this hook's uploads take
# effect) reads the certificate/key from that fixed location and does not
# hot-reload: a device reboot is required to pick up a new certificate.
# This hook's restart command therefore defaults to "reboot" -- a blank
# DEPLOY_JETKVM_RESTART_CMD is treated the same as unset (falls back to
# "reboot") rather than silently skipping it, since a renewed certificate
# that's never actually applied defeats the point of automating this; set
# it to the literal value "none" to opt out and apply/verify manually.
# The restart command is run detached on the device (nohup ... &) so this
# hook's ssh call can return before the reboot itself lands, rather than
# racing the connection teardown.
#
# The certificate and key are staged under fixed temporary names on the
# device and only renamed into their final names (an atomic "mv", on the
# same filesystem) once both have been fully written and chmod'ed. This
# keeps a dropped connection or a failed write from ever leaving the
# device with a truncated or mismatched certificate/key pair for its own
# HTTPS listener, and a "trap ... EXIT" in the generated script removes
# any leftover staged file however that script exits.
#
# Before writing anything, this hook also checks that the device's HTTPS
# Mode is already "Custom" -- uploading a certificate that mode won't
# even serve would otherwise be a silent no-op. There is currently no
# documented/headless way to read this back (JetKVM's own JSON-RPC
# getTLSState/setTLSState calls require an authenticated WebRTC session,
# see https://github.com/jetkvm/kvm/issues/1240 and the still-open
# https://github.com/jetkvm/kvm/pull/1515), so this greps the device's
# own config file instead: JetKVM's firmware (see web_tls.go / config.go
# in https://github.com/jetkvm/kvm) persists the mode as the plain-JSON
# field "tls_mode" (values "", "self-signed", or "custom") in
# /userdata/kvm_config.json.
#
# None of the above (storage path, filenames, config file, reboot-to-apply
# behavior) is part of JetKVM's stable/documented API; it was confirmed
# against real JetKVM hardware, but is worth a spot-check after a JetKVM
# firmware upgrade -- set DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE=no to skip the
# HTTPS-mode check entirely if a future firmware version changes that
# file's format out from under it.
#
# The following variables exported from environment will be used. If not
# set then values previously saved in the domain.conf file are used. All
# of them are optional.
#
# export DEPLOY_JETKVM_USER="root" # defaults to "root"
# export DEPLOY_JETKVM_HOST="jetkvm.example.com" # defaults to the cert's domain
# export DEPLOY_JETKVM_PORT="22" # defaults to 22
# export DEPLOY_JETKVM_SSH_CMD="ssh -T" # defaults to "ssh -T"
# export DEPLOY_JETKVM_RESTART_CMD="reboot" # defaults to "reboot"; set to "none" to skip it
# export DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE="yes" # defaults to "yes" (verify tls_mode=custom before upload); set to "no" to skip
#
# Example:
# ```sh
# export DEPLOY_JETKVM_HOST="192.168.1.50"
# acme.sh --deploy -d jetkvm.example.com --deploy-hook jetkvm
# ```
#
# returns 0 means success, otherwise error.
######## Public functions #####################
#domain keyfile certfile cafile fullchain
jetkvm_deploy() {
_cdomain="$1"
_ckey="$2"
_ccert="$3"
_cca="$4"
_cfullchain="$5"
_debug _cdomain "$_cdomain"
_debug _ckey "$_ckey"
_debug _ccert "$_ccert"
_debug _cca "$_cca"
_debug _cfullchain "$_cfullchain"
if [ ! -s "$_ckey" ] || [ ! -s "$_cfullchain" ]; then
_err "JetKVM deploy needs both a private key and a fullchain certificate (not available, e.g., after --signcsr)."
return 1
fi
_getdeployconf DEPLOY_JETKVM_USER
if [ -z "$DEPLOY_JETKVM_USER" ]; then
DEPLOY_JETKVM_USER="root"
fi
_savedeployconf DEPLOY_JETKVM_USER "$DEPLOY_JETKVM_USER"
_getdeployconf DEPLOY_JETKVM_HOST
if [ -z "$DEPLOY_JETKVM_HOST" ]; then
_debug "Using _cdomain as DEPLOY_JETKVM_HOST, please set if not correct."
DEPLOY_JETKVM_HOST="$_cdomain"
fi
_savedeployconf DEPLOY_JETKVM_HOST "$DEPLOY_JETKVM_HOST"
_getdeployconf DEPLOY_JETKVM_PORT
if [ -z "$DEPLOY_JETKVM_PORT" ]; then
DEPLOY_JETKVM_PORT="22"
fi
_savedeployconf DEPLOY_JETKVM_PORT "$DEPLOY_JETKVM_PORT"
_getdeployconf DEPLOY_JETKVM_SSH_CMD
if [ -z "$DEPLOY_JETKVM_SSH_CMD" ]; then
DEPLOY_JETKVM_SSH_CMD="ssh -T"
fi
_savedeployconf DEPLOY_JETKVM_SSH_CMD "$DEPLOY_JETKVM_SSH_CMD" "base64"
_getdeployconf DEPLOY_JETKVM_RESTART_CMD
if [ -z "$DEPLOY_JETKVM_RESTART_CMD" ]; then
DEPLOY_JETKVM_RESTART_CMD="reboot"
fi
_savedeployconf DEPLOY_JETKVM_RESTART_CMD "$DEPLOY_JETKVM_RESTART_CMD" "base64"
_getdeployconf DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE
if [ -z "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE" ]; then
DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE="yes"
fi
_savedeployconf DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE"
_info "Deploying certificate to JetKVM device $DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST:$DEPLOY_JETKVM_PORT"
# Firmware constants on a single-purpose, single-root appliance -- not
# user configuration. If JetKVM ever moves these, that's a hook update,
# not a setting (a saved-per-domain override would just as easily hide
# the fix from anyone already using this hook).
_jetkvm_remote_path="/userdata/jetkvm/tls"
_jetkvm_cert_name="user-defined.crt"
_jetkvm_key_name="user-defined.key"
_jetkvm_config_file="/userdata/kvm_config.json"
_jetkvm_mode_exitcode=3
_jetkvm_config_missing_exitcode=4
_jetkvm_run_id="$$.$(_time)"
_jetkvm_cert_marker="ACME_JETKVM_CERT_$_jetkvm_run_id"
_jetkvm_key_marker="ACME_JETKVM_KEY_$_jetkvm_run_id"
_jetkvm_cert_tmp="$_jetkvm_remote_path/.$_jetkvm_cert_name.tmp"
_jetkvm_key_tmp="$_jetkvm_remote_path/.$_jetkvm_key_name.tmp"
_jetkvm_cert_target="$_jetkvm_remote_path/$_jetkvm_cert_name"
_jetkvm_key_target="$_jetkvm_remote_path/$_jetkvm_key_name"
# Command substitution strips all trailing newlines, so the printf below
# always emits the content with exactly one trailing newline before the
# heredoc terminator -- regardless of whether the source file already
# ended with one -- so the terminator is guaranteed to start its own line.
_jetkvm_cert_content="$(cat "$_cfullchain")"
_jetkvm_key_content="$(cat "$_ckey")"
_jetkvm_upload_script="$(
echo "#!/bin/sh"
echo "set -e"
echo "umask 077"
printf "trap \"rm -f '%s' '%s'\" EXIT\n" "$_jetkvm_cert_tmp" "$_jetkvm_key_tmp"
if [ "$DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE" != "no" ]; then
# Uploading a certificate that HTTPS Mode won't even serve would
# otherwise fail silently -- see the header comment for why this
# greps the device's own config file rather than querying it
# through a documented API (there isn't one for reading this
# headlessly yet). The config file is checked for readability
# separately so a missing/renamed file isn't misreported as
# HTTPS Mode being wrong.
printf "if [ ! -r '%s' ]; then exit %s; fi\n" "$_jetkvm_config_file" "$_jetkvm_config_missing_exitcode"
printf 'if ! grep -q '\''"tls_mode" *: *"custom"'\'' '\''%s'\''; then exit %s; fi\n' "$_jetkvm_config_file" "$_jetkvm_mode_exitcode"
fi
printf "mkdir -p '%s'\n" "$_jetkvm_remote_path"
printf "cat > '%s' <<'%s'\n" "$_jetkvm_cert_tmp" "$_jetkvm_cert_marker"
printf '%s\n' "$_jetkvm_cert_content"
echo "$_jetkvm_cert_marker"
printf "chmod 0644 '%s'\n" "$_jetkvm_cert_tmp"
printf "cat > '%s' <<'%s'\n" "$_jetkvm_key_tmp" "$_jetkvm_key_marker"
printf '%s\n' "$_jetkvm_key_content"
echo "$_jetkvm_key_marker"
printf "chmod 0600 '%s'\n" "$_jetkvm_key_tmp"
printf "mv '%s' '%s'\n" "$_jetkvm_cert_tmp" "$_jetkvm_cert_target"
printf "mv '%s' '%s'\n" "$_jetkvm_key_tmp" "$_jetkvm_key_target"
)"
_secure_debug "Generated upload script" "$_jetkvm_upload_script"
_info "Connecting to JetKVM device $DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST:$DEPLOY_JETKVM_PORT to deploy certificate"
# shellcheck disable=SC2086
printf '%s\n' "$_jetkvm_upload_script" | $DEPLOY_JETKVM_SSH_CMD -p "$DEPLOY_JETKVM_PORT" "$DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST" sh
_ret=$?
if [ "$_ret" = "$_jetkvm_config_missing_exitcode" ]; then
_err "JetKVM config file ($_jetkvm_config_file) was not found or not readable on the device -- this hook's assumptions may be out of date after a firmware upgrade. Certificate was NOT uploaded."
return "$_ret"
fi
if [ "$_ret" = "$_jetkvm_mode_exitcode" ]; then
_err "JetKVM HTTPS Mode is not set to \"Custom\" (checked \"tls_mode\" in $_jetkvm_config_file on the device). Set it in the device's web UI (Settings > Network > HTTPS Mode) before this hook can take effect. Certificate was NOT uploaded."
return "$_ret"
fi
if [ "$_ret" != "0" ]; then
_err "Error code $_ret returned uploading certificate to JetKVM device"
return "$_ret"
fi
_info "Certificate and key uploaded to $_jetkvm_remote_path on the device"
if [ "$DEPLOY_JETKVM_RESTART_CMD" = "none" ]; then
_info "Certificate successfully deployed to JetKVM device. DEPLOY_JETKVM_RESTART_CMD=none, skipping restart command."
return 0
fi
# Run the restart command detached (nohup ... &) so this ssh call
# returns as soon as it's launched, before the device actually reboots,
# rather than racing the connection teardown -- observed, against real
# hardware, that a reboot racing the SSH session's own exit can make
# ssh itself exit anywhere from a clean 0 to a connection-reset 255.
# Since the restart command then runs as an unwaited background job on
# the device, this ssh call reports success as soon as that job is
# launched -- it does NOT confirm nohup, sh, or the restart command
# itself actually exist or succeed (measured: a nonexistent restart
# command, and even a missing nohup binary, both still return 0 here).
# Only an outright SSH connection failure (unreachable host, auth
# failure, etc.) is caught below. "sleep" runs on the device's own
# shell, not acme.sh's, so acme.sh's _sleep wrapper does not apply.
_info "Running post-upload command on JetKVM device: $DEPLOY_JETKVM_RESTART_CMD"
# Escape any single quotes in the (user-configurable, free-text)
# restart command before nesting it inside the outer 'sleep N; ...'
# single-quoted string -- otherwise a value like "sh -c 'sync; reboot'"
# breaks the quoting and only part of it ends up inside the detached
# background job.
_jetkvm_restart_cmd_escaped=$(printf '%s' "$DEPLOY_JETKVM_RESTART_CMD" | sed "s/'/'\\\\''/g")
_jetkvm_detached_cmd="nohup sh -c 'sleep 2; $_jetkvm_restart_cmd_escaped' >/dev/null 2>&1 &"
# shellcheck disable=SC2086
if ! $DEPLOY_JETKVM_SSH_CMD -p "$DEPLOY_JETKVM_PORT" "$DEPLOY_JETKVM_USER@$DEPLOY_JETKVM_HOST" "$_jetkvm_detached_cmd"; then
_err "Certificate was uploaded, but connecting to the JetKVM device to launch the restart command failed."
return 1
fi
_info "Certificate deployed to JetKVM device; it will restart shortly to apply it."
return 0
}
+2 -2
View File
@@ -83,7 +83,7 @@ keyhelp_deploy() {
_request_body="submit=1&certificate_name=$certificate_name&add_type=upload&text_private_key=$encoded_key&text_certificate=$encoded_ccert&text_ca_certificate=$encoded_cca"
_H1="Cookie: $_cookie"
_response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=ssl_certificates&action=add" "" "POST")
_message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/<div class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_message=$(echo "$_response" | sed -n '/<div class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_info "_message" "$_message"
if [ -z "$_message" ]; then
_err "Fail to upload certificate."
@@ -118,7 +118,7 @@ keyhelp_deploy() {
_request_body="submit=1&id=$DOMAIN_ID&target_type=$target_type&path=$path&is_prefer_https=$is_prefer_https&hsts_enabled=$hsts_enabled&certificate_type=custom&certificate_id=$cert_value&enforce_https=$DEPLOY_KEYHELP_ENFORCE_HTTPS"
_response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit" "" "POST")
_message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/<div class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_message=$(echo "$_response" | sed -n '/<div class="message-body ">/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//')
_info "_message" "$_message"
if [ -z "$_message" ]; then
_err "Fail to apply certificate."
+1 -4
View File
@@ -121,10 +121,7 @@ lighttpd_deploy() {
# Set the suffix depending if we are creating a bundle or not
if [ "${Le_Deploy_lighttpd_bundle}" = "yes" ]; then
_info "Bundle creation requested"
# Initialise $Le_Keylength if its not already set
if [ -z "${Le_Keylength}" ]; then
Le_Keylength=""
fi
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_info "ECC key type detected"
_suffix=".ecdsa"
+19 -8
View File
@@ -10,6 +10,10 @@
# Usage (shown values are the examples):
# 1. Set optional environment variables
# - export MULTIDEPLOY_FILENAME="multideploy.yaml" - "multideploy.yml" will be automatically used if not set"
# A name without a leading '/' is looked up in the certificate directory
# of the domain. An absolute path is used as is, so a single deploy file
# can be shared by all domains, e.g.
# - export MULTIDEPLOY_FILENAME="/etc/acme/multideploy.yml"
#
# 2. Run command:
# acme.sh --deploy --deploy-hook multideploy -d example.com
@@ -49,7 +53,7 @@ multideploy_deploy() {
_debug _cfullchain "$_cfullchain"
_debug _cpfx "$_cpfx"
MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf MULTIDEPLOY_FILENAME)}"
_getdeployconf MULTIDEPLOY_FILENAME
if [ -z "$MULTIDEPLOY_FILENAME" ]; then
MULTIDEPLOY_FILENAME="multideploy.yml"
_info "MULTIDEPLOY_FILENAME is not set, so I will use 'multideploy.yml'."
@@ -75,7 +79,8 @@ multideploy_deploy() {
# This function preprocesses the deploy file by checking if 'yq' is installed,
# verifying the existence of the deploy file, and ensuring only one deploy file is present.
# Arguments:
# $@ - Posible deploy file names.
# $@ - Posible deploy file names. A name starting with '/' is treated as an
# absolute path, any other name is relative to the domain directory.
# Usage:
# _preprocess_deployfile "<deploy_file1>" "<deploy_file2>?"
_preprocess_deployfile() {
@@ -87,15 +92,21 @@ _preprocess_deployfile() {
_debug3 "yq is installed."
# Check if deploy file exists
found_file=""
for file in "$@"; do
_debug3 "Checking file" "$DOMAIN_PATH/$file"
if [ -f "$DOMAIN_PATH/$file" ]; then
if _startswith "$file" "/"; then
_multideploy_path="$file"
else
_multideploy_path="$DOMAIN_PATH/$file"
fi
_debug3 "Checking file" "$_multideploy_path"
if [ -f "$_multideploy_path" ]; then
_debug3 "File found"
if [ -n "$found_file" ]; then
_err "Multiple deploy files found. Please keep only one deploy file."
return 1
fi
found_file="$file"
found_file="$_multideploy_path"
else
_debug3 "File not found"
fi
@@ -105,12 +116,12 @@ _preprocess_deployfile() {
_err "Deploy file not found. Go to https://github.com/acmesh-official/acme.sh/wiki/deployhooks#36-deploying-to-multiple-services-with-the-same-hooks to see how to create one."
return 1
fi
if ! _check_deployfile "$DOMAIN_PATH/$found_file"; then
_err "Deploy file is not valid: $DOMAIN_PATH/$found_file"
if ! _check_deployfile "$found_file"; then
_err "Deploy file is not valid: $found_file"
return 1
fi
echo "$DOMAIN_PATH/$found_file"
echo "$found_file"
}
# Description:
+17 -10
View File
@@ -116,17 +116,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
_retval=$?
# The API errors out with a non-2xx HTTP status and an empty body,
# so the status line is checked too, not only the response body.
_status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
_retval=$?
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response"
_info "Certificate successfully deployed"
return 0
else
_err "Certificate deployment failed: $message"
_debug "Response" "$response"
return 1
fi
case "$_status_code" in
2[0-9][0-9])
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response"
_info "Certificate successfully deployed"
return 0
fi
;;
esac
_err "Certificate deployment failed (HTTP status $_status_code). $message"
_debug "Response" "$response"
return 1
}
+17 -10
View File
@@ -128,17 +128,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
_retval=$?
# The API errors out with a non-2xx HTTP status and an empty body,
# so the status line is checked too, not only the response body.
_status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
_debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
_retval=$?
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response"
_info "Certificate successfully deployed"
return 0
else
_err "Certificate deployment failed: $message"
_debug "Response" "$response"
return 1
fi
case "$_status_code" in
2[0-9][0-9])
if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
_debug3 response "$response"
_info "Certificate successfully deployed"
return 0
fi
;;
esac
_err "Certificate deployment failed (HTTP status $_status_code). $message"
_debug "Response" "$response"
return 1
}
+2 -2
View File
@@ -175,7 +175,7 @@ _get_unleashed_version() {
_post_upload() {
_post_action="$1"
_post_file="$2"
_post_upfile="$2"
_post_boundary="----FormBoundary$(date "+%s%N")"
@@ -183,7 +183,7 @@ _post_upload() {
printf -- "--%s\r\n" "$_post_boundary"
printf -- "Content-Disposition: form-data; name=\"u\"; filename=\"%s\"\r\n" "$_post_action"
printf -- "Content-Type: application/octet-stream\r\n\r\n"
printf -- "%s\r\n" "$(cat "$_post_file")"
printf -- "%s\r\n" "$(cat "$_post_upfile")"
printf -- "--%s\r\n" "$_post_boundary"
printf -- "Content-Disposition: form-data; name=\"action\"\r\n\r\n"
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env sh
# Here is a script to deploy cert to a Shelly Gen3+ device.
# Deploy the HTTPS server certificate to a Shelly device on the local network.
#
# ```sh
# export SHELLY_HOST=192.168.1.100
# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device
# acme.sh --deploy -d shelly.example.com --deploy-hook shelly
# ```
#
# Environment variables:
# SHELLY_HOST (required) IP or hostname of the Shelly device
# SHELLY_PASSWORD (optional) Admin password for digest authentication.
# Omit if auth is disabled on the device.
# SHELLY_USER (optional) Username for auth. Default: admin
# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot.
# Default: 1 (reboot after upload)
#
# Requirements:
# - Shelly Gen3+ device (Gen4 recommended)
# - Firmware 2.0.0+ for HTTPS server certificate support
# - curl or wget
# - openssl (for SHA-256 digest and random cnonce)
#
# The device must be reachable via HTTP on the local network.
# The hook uploads the fullchain.pem and private key,
# then reboots the device to apply the new certificate.
#
# Authentication uses standard RFC 7616 HTTP Digest (SHA-256) since
# firmware 2.0.0. The JSON-RPC auth object is not used for HTTP transport.
#
# returns 0 means success, otherwise error.
######## Public functions #####################
#domain keyfile certfile cafile fullchain
shelly_deploy() {
_cdomain="$1"
_ckey="$2"
_ccert="$3"
_cca="$4"
_cfullchain="$5"
_debug _cdomain "$_cdomain"
_debug _ckey "$_ckey"
_debug _ccert "$_ccert"
_debug _cca "$_cca"
_debug _cfullchain "$_cfullchain"
_getdeployconf SHELLY_HOST
_getdeployconf SHELLY_PASSWORD
_getdeployconf SHELLY_USER
_getdeployconf SHELLY_REBOOT
_debug SHELLY_HOST "$SHELLY_HOST"
_debug SHELLY_USER "$SHELLY_USER"
_secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD"
_debug SHELLY_REBOOT "$SHELLY_REBOOT"
if [ -z "$SHELLY_HOST" ]; then
_err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device."
return 1
fi
SHELLY_USER="${SHELLY_USER:-admin}"
SHELLY_REBOOT="${SHELLY_REBOOT:-1}"
_savedeployconf SHELLY_HOST "$SHELLY_HOST"
_savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD"
_savedeployconf SHELLY_USER "$SHELLY_USER"
_savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT"
# --- Auth handshake (only if password is set) ---
_shelly_auth_header=""
if [ -n "$SHELLY_PASSWORD" ]; then
_info "Authenticating to Shelly device at $SHELLY_HOST"
if ! _shelly_handshake; then
_err "Authentication handshake failed. Check SHELLY_PASSWORD and device accessibility."
return 1
fi
_info "Authentication successful"
fi
# --- Upload certificate ---
_info "Uploading certificate to Shelly device at $SHELLY_HOST"
if ! _shelly_upload_cert; then
_err "Certificate upload failed"
return 1
fi
# --- Upload key ---
_info "Uploading private key to Shelly device"
if ! _shelly_upload_key; then
_err "Private key upload failed"
return 1
fi
_info "Certificate and key uploaded successfully"
# --- Reboot ---
if [ "$SHELLY_REBOOT" != "0" ]; then
_info "Rebooting Shelly device to apply certificate"
# Reboot may close the connection before sending a response
_shelly_rpc "Shelly.Reboot" '{}' || _debug "Reboot may have closed connection (expected)"
_info "Reboot command sent. Device will restart shortly."
else
_info "Skipping reboot (SHELLY_REBOOT=0). Certificate will apply on next restart."
fi
# Clear auth header so it does not leak to other hooks
export _H1=""
return 0
}
# --- Helper functions ---
# Perform RFC 7616 HTTP Digest auth handshake.
# Sets _shelly_auth_header on success (the Authorization header value).
_shelly_handshake() {
_inithttp
_debug "Probing device for auth challenge"
# Use a protected method (Shelly.GetStatus) to trigger 401.
# Shelly.GetDeviceInfo is excluded from auth and would miss the challenge.
_post '{"id":1,"method":"Shelly.GetStatus"}' \
"http://${SHELLY_HOST}/rpc" "" "" "application/json"
# Detect auth from HTTP status line rather than response body
if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then
# No auth challenge — device accepted the request without credentials
_debug "Device responded without auth challenge. Proceeding without auth."
return 0
fi
_shelly_realm="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*realm="//;s/".*//')"
_shelly_nonce="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*nonce="//;s/".*//')"
_shelly_qop="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*qop="//;s/".*//')"
if [ -z "$_shelly_nonce" ]; then
_err "Failed to extract nonce from WWW-Authenticate header. Is SHELLY_PASSWORD correct?"
return 1
fi
_shelly_qop="${_shelly_qop:-auth}"
_debug "Shelly realm: $_shelly_realm"
_debug "Shelly qop: $_shelly_qop"
_secure_debug "Shelly nonce" "$_shelly_nonce"
# ha1 = SHA256(username:realm:password)
_shelly_ha1="$(printf '%s' "${SHELLY_USER}:${_shelly_realm}:${SHELLY_PASSWORD}" | _digest sha256 hex)"
_secure_debug "Shelly ha1" "$_shelly_ha1"
# Generate client nonce (openssl is required for _digest, so always available)
_shelly_cnonce="$(${ACME_OPENSSL_BIN:-openssl} rand -hex 8 2>/dev/null)"
_debug "Shelly cnonce: $_shelly_cnonce"
# Build the digest Authorization header value (stored for reuse)
_shelly_nc=1
_shelly_build_auth_header
return 0
}
# Check whether the HTTP response headers contain a digest auth challenge.
# Returns 0 (true) if a 401 with WWW-Authenticate is present.
_shelly_has_auth_challenge() {
_shelly_headers_file="$1"
_shelly_status="$(grep -i '^HTTP/' "$_shelly_headers_file" | _tail_n 1 | awk '{print $2}')"
[ "$_shelly_status" = "401" ] && grep -qi '^WWW-Authenticate:' "$_shelly_headers_file"
}
# Build or rebuild the RFC 7616 Authorization header.
# Uses: _shelly_ha1, _shelly_nonce, _shelly_cnonce, _shelly_qop, _shelly_realm, _shelly_nc
# Sets: _shelly_auth_header
_shelly_build_auth_header() {
_shelly_nc_hex="$(printf '%08x' "$_shelly_nc")"
# ha2 = SHA256(POST:/rpc)
_shelly_ha2="$(printf '%s' "POST:/rpc" | _digest sha256 hex)"
# response = SHA256(ha1:nonce:nc:cnonce:qop:ha2)
_shelly_digest_response="$(printf '%s' "${_shelly_ha1}:${_shelly_nonce}:${_shelly_nc_hex}:${_shelly_cnonce}:${_shelly_qop}:${_shelly_ha2}" | _digest sha256 hex)"
# Build the Authorization header value (without the "Authorization: " prefix)
_shelly_auth_header="Digest username=\"${SHELLY_USER}\", realm=\"${_shelly_realm}\", nonce=\"${_shelly_nonce}\", uri=\"/rpc\", qop=${_shelly_qop}, nc=${_shelly_nc_hex}, cnonce=\"${_shelly_cnonce}\", response=\"${_shelly_digest_response}\", algorithm=SHA-256"
_secure_debug "Authorization header" "$_shelly_auth_header"
}
# Make a Shelly JSON-RPC call.
# Usage: _shelly_rpc <method> <params_json>
# Returns 0 on success, 1 on error.
_shelly_rpc() {
_shelly_method="$1"
_shelly_params="$2"
_shelly_body='{"id":1,"method":"'"$_shelly_method"'","params":'"$_shelly_params"'}'
_debug "RPC method: $_shelly_method"
_debug2 "RPC body: $_shelly_body"
# shellcheck disable=SC2090
if [ -n "$_shelly_auth_header" ]; then
export _H1="Authorization: $_shelly_auth_header"
else
export _H1=""
fi
_post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json"
_shelly_ret=$?
if [ "$_shelly_ret" != "0" ]; then
_err "HTTP request failed for $_shelly_method (curl/wget error $_shelly_ret)"
return 1
fi
# Empty response means something went wrong (auth required but not provided, etc.)
if [ -z "$response" ]; then
_err "Empty response from Shelly device. If authentication is enabled on the device, set SHELLY_PASSWORD."
return 1
fi
# Validate response looks like a Shelly JSON-RPC response.
# Catches non-JSON responses such as HTTP 429 "Too Many Requests" which
# would otherwise pass the empty and "error" checks below.
if ! _startswith "$response" '{' || ! _contains "$response" '"id"'; then
_err "Invalid response from Shelly device: $response"
return 1
fi
# Check for JSON-RPC error in response
if _contains "$response" '"error"'; then
_err "RPC error from Shelly: $response"
return 1
fi
_debug "RPC response: $response"
# Increment nonce counter and rebuild auth header for next request
if [ -n "$_shelly_auth_header" ]; then
_shelly_nc=$((_shelly_nc + 1))
_shelly_build_auth_header
fi
return 0
}
# Upload the certificate to the device.
# Note: We do NOT clear the existing certificate first, because the Shelly
# auto-removes all three files (cert, key, CA) when any one is cleared.
# Uploading overwrites in place — no clearing needed.
_shelly_upload_cert() {
_shelly_cert_data="$(_json_encode <"$_cfullchain")"
_debug "Uploading certificate"
if ! _shelly_rpc "Shelly.PutHTTPServerCert" '{"data":"'"$_shelly_cert_data"'"}'; then
_err "Failed to upload certificate to device"
return 1
fi
return 0
}
# Upload the private key to the device.
# Note: Do not clear first — see _shelly_upload_cert for rationale.
_shelly_upload_key() {
_shelly_key_data="$(_json_encode <"$_ckey")"
_debug "Uploading key"
if ! _shelly_rpc "Shelly.PutHTTPServerKey" '{"data":"'"$_shelly_key_data"'"}'; then
_err "Failed to upload key to device"
return 1
fi
return 0
}
+5 -7
View File
@@ -232,8 +232,6 @@ _ssh_deploy() {
do if [ -d \"\$fn\" ] && [ \"\$(expr \$now - \$(date -ur \$fn +%s) )\" -ge \"15552000\" ]; \
then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; done; }; $_cmdstr"
# Alternate version of above... _cmdstr="find $_backupprefix* -type d -mtime +180 2>/dev/null | xargs rm -rf; $_cmdstr"
# Create our backup directory for overwritten cert files.
_cmdstr="mkdir -p $_backupdir; $_cmdstr"
_info "Backup of old certificate files will be placed in remote directory $_backupdir"
_info "Backup directories erased after 180 days."
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
@@ -247,7 +245,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
if [ -n "$DEPLOY_SSH_KEYFILE" ]; then
if [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_KEYFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_KEYFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_KEYFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -284,7 +282,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_CERTFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_CERTFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CERTFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -325,7 +323,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_CAFILE $_backupdir >/dev/null;"
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_CAFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CAFILE $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
@@ -370,8 +368,8 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d
_pipe=">>"
elif [ "$DEPLOY_SSH_BACKUP" = "yes" ]; then
# backup file we are about to overwrite.
_cmdstr="$_cmdstr cp $DEPLOY_SSH_FULLCHAIN $_backupdir >/dev/null;"
if [ "$DEPLOY_SSH_FULLCHAIN" = "yes" ]; then
_cmdstr="$_cmdstr if [ -f $DEPLOY_SSH_FULLCHAIN ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_FULLCHAIN $_backupdir >/dev/null; fi;"
if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then
if ! _ssh_remote_cmd "$_cmdstr"; then
return $_err_code
fi
+6 -2
View File
@@ -344,6 +344,7 @@ synology_dsm_deploy() {
else
_err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more."
fi
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
@@ -354,6 +355,7 @@ synology_dsm_deploy() {
if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then
_err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set."
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
@@ -389,13 +391,13 @@ synology_dsm_deploy() {
else
_info "Restart HTTP services not necessary."
fi
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 0
else
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
_err "Unable to update certificate, got error response: $response."
_logout
_temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME"
return 1
fi
}
@@ -403,6 +405,8 @@ synology_dsm_deploy() {
#################### Private functions below ##################################
_logout() {
# Logout CERT user only to not occupy a permanent session, e.g. in DSM's "Connected Users" widget (based on previous variables)
# Must be called before _temp_admin_cleanup: once the temp admin is deleted, its session can no longer be logged out.
# Note: this overwrites $response, so print any error message that needs it before calling.
response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=logout&_sid=$sid")
_debug3 response "$response"
}
+518
View File
@@ -0,0 +1,518 @@
#!/usr/bin/env sh
# shellcheck disable=SC2016
# TrueNAS deploy script for SCALE/CORE using websocket (websocat binary)
# It is recommend to use a wildcard certificate
#
# Tested with TrueNAS SCALE 25.10 (API "wss://host/api/current", JSON-RPC 2.0).
#
# Unlike "truenas_ws" hook, this script does NOT use midclt, the truenas_api_client Python package.
# It only depends on:
# - jq
# - websocat (a static binary you deploy)
#
# Why: avoids installing a Python environment / TrueNAS package on remote machine just to push a certificate.
#
# IMPORTANT: This script is written in pure POSIX sh (no coproc, no bash arrays).
#
#
# ---------------------------------------------------------------------------
# Environment variables
# ---------------------------------------------------------------------------
#
# # Use the folowing URL to create a new API token: <TRUENAS_HOSTNAME OR IP>/ui/apikeys
#
# Required:
# export DEPLOY_TRUENAS_APIKEY="<API_KEY_GENERATED_IN_THE_WEB_UI>"
#
# Optional:
# export DEPLOY_TRUENAS_HOSTNAME="<TRUENAS_HOSTNAME_OR_IP>" (required on first run)
# export DEPLOY_TRUENAS_PROTOCOL="ws" # ws or wss (default: ws)
# export DEPLOY_TRUENAS_PORT="80" # 80, 443, 8443 (default: 80)
# NOTE: defaults are intentionally "ws"/80, not "wss"/443: a freshly
# installed TrueNAS serves its Web UI over plain HTTP on port 80 out
# of the box, and port 443 is not listening until HTTPS is configured.
# Port 80 stays reachable even after HTTPS is enabled, so this keeps
# the hook working on first run without extra setup.
# export DEPLOY_TRUENAS_UPDATE_FTP="no" # yes or no (default: no) also updates the FTP certificate
# export DEPLOY_TRUENAS_UPDATE_APPS="no" # yes or no (default: no) also updates the certificate for any
# iX app exposing a "certificate_id" option.
# WARNING: this redeploys (restarts) every matching app.
# ---------------------------------------------------------------------------
########################
### Public functions ###
########################
# truenas_websocat_deploy
#
# Deploy new certificate to TrueNAS services with websocat binary
#
# Arguments
# 1: Domain
# 2: Key-File
# 3: Certificate-File
# 4: CA-File
# 5: FullChain-File
# Returns:
# 0: Success
# 1: Missing or invalid API Key
# 2: TrueNAS not ready (health check failed)
# 3: (reserved)
# 4: FTP & iX App cert error
# 5: WebUI cert error
# 6: Certificate creation job error
# 7: Websocat / transport call error (socket write/read failed)
# 8: Missing binary or invalid configuration
# 9: TrueNAS API returned an explicit error (JSON-RPC .error field)
truenas_websocat_deploy() {
_jq_bin=$(command -v jq 2>/dev/null)
if [ -z "$_jq_bin" ]; then
_err "jq binary not found in PATH. Install it using your system's package manager."
return 8
fi
_websocat_bin=$(command -v websocat 2>/dev/null)
if [ -z "$_websocat_bin" ]; then
_err "websocat binary not found in PATH. Install it using your system's package manager, or download a static binary from https://github.com/vi/websocat/releases."
return 8
fi
_domain="$1"
_file_key="$2"
_file_cert="$3"
_file_cca="$4"
_file_fullchain="$5"
_debug _domain "$_domain"
_debug _file_key "$_file_key"
_debug _file_cert "$_file_cert"
_debug _file_ca "$_file_cca"
_debug _file_fullchain "$_file_fullchain"
if [ ! -x "$_jq_bin" ]; then
_err "Binary not found or not executable: $_jq_bin"
return 8
fi
if [ ! -x "$_websocat_bin" ]; then
_err "Binary not found or not executable: $_websocat_bin"
return 8
fi
### ---- Configuration ----
_info "Checking environment variables..."
_getdeployconf DEPLOY_TRUENAS_APIKEY
_getdeployconf DEPLOY_TRUENAS_HOSTNAME
_getdeployconf DEPLOY_TRUENAS_PROTOCOL
_getdeployconf DEPLOY_TRUENAS_PORT
_getdeployconf DEPLOY_TRUENAS_UPDATE_FTP
_getdeployconf DEPLOY_TRUENAS_UPDATE_APPS
# Check API Key
if [ -z "$DEPLOY_TRUENAS_APIKEY" ]; then
_err "TrueNAS API key not found, please set the DEPLOY_TRUENAS_APIKEY environment variable."
return 1
fi
# Check Hostname, default to localhost if not set
if [ -z "$DEPLOY_TRUENAS_HOSTNAME" ]; then
_info "TrueNAS hostname not set. Using 'localhost'."
DEPLOY_TRUENAS_HOSTNAME="localhost"
fi
# Check protocol, default to ws if not set: a freshly installed TrueNAS serves its Web UI over plain HTTP, so wss/443 is not available out of the box.
# Use DEPLOY_TRUENAS_PROTOCOL="wss" once HTTPS is configured, since the payload otherwise carries the API key and private key in plain text.
if [ -z "$DEPLOY_TRUENAS_PROTOCOL" ]; then
_info "TrueNAS protocol not set. Using 'ws'."
DEPLOY_TRUENAS_PROTOCOL="ws"
fi
# Check port, default to 80 if not set (see protocol comment above)
if [ -z "$DEPLOY_TRUENAS_PORT" ]; then
_info "TrueNAS port not set. Using '80'."
DEPLOY_TRUENAS_PORT="80"
fi
case "$DEPLOY_TRUENAS_PORT" in
'' | *[!0-9]*)
_err "Invalid TrueNAS port '$DEPLOY_TRUENAS_PORT'. DEPLOY_TRUENAS_PORT must be numeric."
return 8
;;
esac
_truenas_websocat_uri="$DEPLOY_TRUENAS_PROTOCOL://$DEPLOY_TRUENAS_HOSTNAME:$DEPLOY_TRUENAS_PORT/api/current"
# Check FTP update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_FTP" ]; then
_info "Certificate update for FTP is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_FTP="no"
fi
# Check Apps update, default to no if not set
if [ -z "$DEPLOY_TRUENAS_UPDATE_APPS" ]; then
_info "Certificate update for Apps is not set. Using 'no'."
DEPLOY_TRUENAS_UPDATE_APPS="no"
fi
_debug2 DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_debug2 DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_debug2 DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_debug2 DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_debug _truenas_websocat_uri "$_truenas_websocat_uri"
_secure_debug2 DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_info "Environment variables: OK"
### ---- Persistent WebSocket connection (FIFOs, sh/dash compatible) ----
#
# Authentication is tied to the WebSocket connection:
# the SAME connection must stay open from login until the end, otherwise every subsequent call comes back unauthenticated.
# We use two FIFOs + `exec` to talk to a background websocat process, without relying on bash-only extensions.
_websocat_tmpdir=$(mktemp -d /tmp/truenas_websocat.XXXXXX) || {
_err "mktemp failed"
return 3
}
_websocat_fifo_in="${_websocat_tmpdir}/in"
_websocat_fifo_out="${_websocat_tmpdir}/out"
mkfifo "$_websocat_fifo_in" "$_websocat_fifo_out" || {
_err "mkfifo failed"
rm -rf "$_websocat_tmpdir"
return 3
}
"$_websocat_bin" -n -k "$_truenas_websocat_uri" <"$_websocat_fifo_in" >"$_websocat_fifo_out" 2>"${_websocat_tmpdir}/err.log" &
_websocat_pid=$!
# Opening "in" for read+write avoids a deadlock if websocat hasn't opened the fifo for reading yet at the time we write to it.
exec 3<>"$_websocat_fifo_in"
exec 4<"$_websocat_fifo_out"
sleep 1
if ! kill -0 "$_websocat_pid" 2>/dev/null; then
_err "websocat exited prematurely."
_err "$(cat "${_websocat_tmpdir}/err.log" 2>/dev/null)"
exec 3>&- 4<&-
rm -rf "$_websocat_tmpdir"
return 3
fi
_websocat_req_counter=0
_truenas_websocat_cleanup() {
exec 3>&- 2>/dev/null
exec 4<&- 2>/dev/null
[ -n "$_websocat_pid" ] && kill "$_websocat_pid" 2>/dev/null
rm -rf "$_websocat_tmpdir" 2>/dev/null
}
# _truenas_websocat_rpc_call <method> <json_params>
# Does NOT log the payload/response: some calls (certificate.create, core.get_jobs) contain the certificate and private key in plain text, which would massively bloat the logs.
_truenas_websocat_rpc_call() {
_truenas_websocat_method="$1"
_truenas_websocat_params="$2"
_websocat_req_counter=$((_websocat_req_counter + 1))
_req_id="$_websocat_req_counter"
_truenas_websocat_payload=$("$_jq_bin" -c -n \
--arg jsonrpc "2.0" \
--arg id "$_req_id" \
--arg method "$_truenas_websocat_method" \
--argjson params "$_truenas_websocat_params" \
'{jsonrpc: $jsonrpc, id: $id, method: $method, params: $params}')
printf '%s\n' "$_truenas_websocat_payload" >&3 || {
_err "Socket write failed (method: $_truenas_websocat_method)"
return 7
}
IFS= read -r _truenas_websocat_response <&4 || {
_err "Socket read failed (method: $_truenas_websocat_method)"
return 7
}
printf '%s' "$_truenas_websocat_response"
}
# _truenas_websocat_rpc_has_error <response> <label> -> returns 0 (and prints) if an error was found, 1 otherwise
_truenas_websocat_rpc_has_error() {
_msg=$(printf '%s' "$1" | "$_jq_bin" -r '.error.message // empty' 2>/dev/null)
if [ -n "$_msg" ]; then
_err "RPC error ($2): $_msg"
return 0
fi
return 1
}
# Polls once per second and gives up after _TRUENAS_WEBSOCAT_JOB_TIMEOUT seconds (default 60s)
# if the job never leaves RUNNING/WAITING, so a stuck TrueNAS job cannot hang the deploy hook forever.
# NOTE: this same timeout also bounds app.update jobs when DEPLOY_TRUENAS_UPDATE_APPS=yes (iX App redeploy)
# raise _TRUENAS_WEBSOCAT_JOB_TIMEOUT if an app takes longer than that to redeploy (e.g. image pull, migrations).
_truenas_websocat_wait_for_job() {
_jobid="$1"
_truenas_websocat_job_elapsed=0
_truenas_websocat_job_timeout="${_TRUENAS_WEBSOCAT_JOB_TIMEOUT:-60}"
while true; do
if [ "$_truenas_websocat_job_elapsed" -ge "$_truenas_websocat_job_timeout" ]; then
_err "Job $_jobid: timed out after ${_truenas_websocat_job_timeout}s."
return 6
fi
sleep 1
_truenas_websocat_job_elapsed=$((_truenas_websocat_job_elapsed + 1))
_job_resp=$(_truenas_websocat_rpc_call "core.get_jobs" "[[[\"id\",\"=\",${_jobid}]]]") || return 6
if _truenas_websocat_rpc_has_error "$_job_resp" "core.get_jobs"; then return 6; fi
_state=$(printf '%s' "$_job_resp" | "$_jq_bin" -r '.result[0].state // empty')
case "$_state" in
SUCCESS)
printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].result'
return 0
;;
FAILED | ABORTED)
_err "Job $_jobid failed: $(printf '%s' "$_job_resp" | "$_jq_bin" -c '.result[0].error')"
return 6
;;
"")
_err "Job $_jobid: unexpected response."
return 6
;;
esac
done
}
### ---- 1. Health check ----
_info "Testing connection to TrueNAS WebSocket at $_truenas_websocat_uri..."
_ping_resp=$(_truenas_websocat_rpc_call "core.ping" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ping_resp" "core.ping"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_ping_resp" | "$_jq_bin" -r '.result // empty')" != "pong" ]; then
_err "Health check failed (no pong received)."
_truenas_websocat_cleanup
return 2
fi
_info "Health check OK (pong received)."
### ---- 2. Authentication & Check ----
_info "Authenticating with API Key..."
_key_params=$("$_jq_bin" -c -n --arg k "$DEPLOY_TRUENAS_APIKEY" '[$k]')
_auth_resp=$(_truenas_websocat_rpc_call "auth.login_with_api_key" "$_key_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_auth_resp" "auth.login_with_api_key"; then
_truenas_websocat_cleanup
return 9
fi
if [ "$(printf '%s' "$_auth_resp" | "$_jq_bin" -r '.result // empty')" != "true" ]; then
_err "Authentication failed (invalid API key?)."
_truenas_websocat_cleanup
return 1
fi
_info "Connected to TrueNAS ($_truenas_websocat_uri)."
_savedeployconf DEPLOY_TRUENAS_APIKEY "$DEPLOY_TRUENAS_APIKEY"
_savedeployconf DEPLOY_TRUENAS_HOSTNAME "$DEPLOY_TRUENAS_HOSTNAME"
_savedeployconf DEPLOY_TRUENAS_PROTOCOL "$DEPLOY_TRUENAS_PROTOCOL"
_savedeployconf DEPLOY_TRUENAS_PORT "$DEPLOY_TRUENAS_PORT"
_savedeployconf DEPLOY_TRUENAS_UPDATE_FTP "$DEPLOY_TRUENAS_UPDATE_FTP"
_savedeployconf DEPLOY_TRUENAS_UPDATE_APPS "$DEPLOY_TRUENAS_UPDATE_APPS"
_info "Checking TrueNAS system version..."
_ver_resp=$(_truenas_websocat_rpc_call "system.info" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_ver_resp" "system.info"; then
_truenas_websocat_cleanup
return 9
fi
_sys_version=$(printf '%s' "$_ver_resp" | "$_jq_bin" -r '.result.version // .result // "Unknown"')
_info "TrueNAS System Version: $_sys_version"
### ---- 3. Read certificate files ----
_info "Reading certificate files for $_domain..."
if [ ! -f "$_file_fullchain" ] || [ ! -f "$_file_key" ]; then
_err "Certificate or key file not found."
_truenas_websocat_cleanup
return 5
fi
_cert_content=$("$_jq_bin" -sR . "$_file_fullchain")
_key_content=$("$_jq_bin" -sR . "$_file_key")
_safe_domain=$(echo "$_domain" | tr '*.' '_')
_cert_name="acme_${_safe_domain}_$(date +%Y%m%d_%H%M%S)"
_debug _certname "$_cert_name"
### ---- 4. Current Web UI certificate ----
_info "Retrieving current Web UI configuration..."
_config_resp=$(_truenas_websocat_rpc_call "system.general.config" "[]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_config_resp" "system.general.config"; then
_truenas_websocat_cleanup
return 9
fi
_old_cert_id=$(printf '%s' "$_config_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
_info "Current Web UI Certificate ID: ${_old_cert_id:-None}"
### ---- 5. Import the new certificate (asynchronous job) ----
_info "Importing new certificate '$_cert_name'..."
_create_params=$("$_jq_bin" -n \
--arg name "$_cert_name" \
--argjson cert "$_cert_content" \
--argjson key "$_key_content" \
'[{create_type: "CERTIFICATE_CREATE_IMPORTED", name: $name, certificate: $cert, privatekey: $key}]')
_new_cert_resp=$(_truenas_websocat_rpc_call "certificate.create" "$_create_params") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_new_cert_resp" "certificate.create"; then
_truenas_websocat_cleanup
return 9
fi
_new_cert_jobid=$(printf '%s' "$_new_cert_resp" | "$_jq_bin" -r '.result // empty')
case "$_new_cert_jobid" in
'' | *[!0-9]*)
_err "Unexpected response from certificate.create (expected a job ID)."
_truenas_websocat_cleanup
return 6
;;
esac
_info "Import job certificate started (job ID: $_new_cert_jobid), waiting..."
_job_result=$(_truenas_websocat_wait_for_job "$_new_cert_jobid") || {
_truenas_websocat_cleanup
return 6
}
_new_cert_id=$(printf '%s' "$_job_result" | "$_jq_bin" -r '.id // empty')
if [ -z "$_new_cert_id" ]; then
_err "Could not retrieve the imported certificate's ID."
_truenas_websocat_cleanup
return 6
fi
_info "Certificate imported: '$_cert_name' (ID $_new_cert_id)."
### ---- 6. Assign to the Web UI ----
_info "Assigning certificate ID $_new_cert_id to Web UI..."
_update_resp=$(_truenas_websocat_rpc_call "system.general.update" "[{\"ui_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 7
}
if _truenas_websocat_rpc_has_error "$_update_resp" "system.general.update"; then
_truenas_websocat_cleanup
return 9
fi
_assigned_id=$(printf '%s' "$_update_resp" | "$_jq_bin" -r '.result.ui_certificate.id // .result.ui_certificate // empty')
if [ "$_assigned_id" != "$_new_cert_id" ]; then
_err "Failed to assign the certificate to the Web UI."
_truenas_websocat_cleanup
return 5
fi
_info "Restarting TrueNAS Web UI service..."
_restart_resp=$(_truenas_websocat_rpc_call "system.general.ui_restart" "[]")
_truenas_websocat_rpc_has_error "$_restart_resp" "system.general.ui_restart"
_info "Web UI certificate updated and UI restarted."
# Need TrueNas to sleep before perform other actions
_info "Waiting for Web UI restart."
sleep 5
### ---- 7. FTP (optional) ----
if [ "$DEPLOY_TRUENAS_UPDATE_FTP" = "yes" ]; then
_info "Sending certicate for FTP service..."
_ftp_resp=$(_truenas_websocat_rpc_call "ftp.update" "[{\"ssltls_certificate\": ${_new_cert_id}}]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_ftp_resp" "ftp.update"; then
_err "Failed to update the FTP certificate."
_truenas_websocat_cleanup
return 4
else
_ftp_certid=$(printf '%s' "$_ftp_resp" | "$_jq_bin" -r '.result.ssltls_certificate // empty')
if [ "$_ftp_certid" = "$_new_cert_id" ]; then
_info "FTP certificate updated."
else
_err "FTP certificate: unexpected response."
_truenas_websocat_cleanup
return 4
fi
fi
fi
### ---- 8. iX Apps (optional - redeploys every matching app) ----
if [ "$DEPLOY_TRUENAS_UPDATE_APPS" = "yes" ]; then
_info "Sending certicate for iX Apps..."
_apps_resp=$(_truenas_websocat_rpc_call "app.query" "[]") || {
_truenas_websocat_cleanup
return 4
}
if _truenas_websocat_rpc_has_error "$_apps_resp" "app.query"; then
_err "Could not list apps."
_truenas_websocat_cleanup
return 4
else
for _app_name in $(printf '%s' "$_apps_resp" | "$_jq_bin" -r '.result[].name'); do
_app_cfg=$(_truenas_websocat_rpc_call "app.config" "[\"${_app_name}\"]") || {
_truenas_websocat_cleanup
return 4
}
_has_cert_opt=$(printf '%s' "$_app_cfg" | "$_jq_bin" -r '.result.network // {} | has("certificate_id")' 2>/dev/null)
if [ "$_has_cert_opt" = "true" ]; then
_info "Updating certificate for app '$_app_name' (this will redeploy it)..."
_app_update_resp=$(_truenas_websocat_rpc_call "app.update" "[\"${_app_name}\", {\"values\": {\"network\": {\"certificate_id\": ${_new_cert_id}}}}]") || {
_truenas_websocat_cleanup
return 4
}
_app_jobid=$(printf '%s' "$_app_update_resp" | "$_jq_bin" -r '.result // empty')
case "$_app_jobid" in
'' | *[!0-9]*)
_err "App '$_app_name': no job ID returned."
_truenas_websocat_cleanup
return 4
;;
*)
if ! _truenas_websocat_wait_for_job "$_app_jobid" >/dev/null; then
_err "App '$_app_name': update not confirmed."
_truenas_websocat_cleanup
return 4
fi
;;
esac
fi
done
fi
fi
### ---- 9. Delete the old certificate (non blocking) ----
if [ -n "$_old_cert_id" ] && [ "$_old_cert_id" != "$_new_cert_id" ] && [ "$_old_cert_id" != "null" ]; then
_del_resp=$(_truenas_websocat_rpc_call "certificate.delete" "[${_old_cert_id}]")
if ! _truenas_websocat_rpc_has_error "$_del_resp" "certificate.delete"; then
_del_jobid=$(printf '%s' "$_del_resp" | "$_jq_bin" -r '.result // empty')
case "$_del_jobid" in
'' | *[!0-9]*) : ;;
*)
_truenas_websocat_wait_for_job "$_del_jobid" >/dev/null || _info "Old certificate: deletion not confirmed ."
;;
esac
fi
fi
_truenas_websocat_cleanup
_info "TrueNAS deployment completed successfully."
return 0
}
+12 -12
View File
@@ -43,15 +43,15 @@ _ws_call() {
_debug "_ws_call arg1" "$1"
_debug "_ws_call arg2" "$2"
_debug "_ws_call arg3" "$3"
if [ $# -eq 3 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1" "$2" "$3")
fi
if [ $# -eq 2 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1" "$2")
fi
if [ $# -eq 1 ]; then
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" call "$1")
fi
# TrueNAS 26.0.0-BETA3 and later need a --plain option for midclt call, detect if it is available
_midclt_plain=""
case "$(midclt --help 2>/dev/null)" in
*--plain*) _midclt_plain="--plain" ;;
esac
_ws_response=$(midclt --uri "$_ws_uri" -K "$DEPLOY_TRUENAS_APIKEY" $_midclt_plain call "$@")
_debug "_ws_response" "$_ws_response"
printf "%s" "$_ws_response"
return 0
@@ -256,8 +256,8 @@ truenas_ws_deploy() {
_info "Gather current WebUI certificate..."
_ws_response="$(_ws_call "system.general.config")"
_ui_certificate_id=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."id"')
_ui_certificate_name=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."name"')
_ui_certificate_id=$(printf "%s" "$_ws_response" | jq -r '.ui_certificate | if type == "object" then .id else . end')
_ui_certificate_name=$(printf "%s" "$_ws_response" | jq -r 'if (.ui_certificate | type) == "object" then .ui_certificate.name else .ui_certificate_name end')
_info "Current WebUI certificate ID: $_ui_certificate_id"
_info "Current WebUI certificate name: $_ui_certificate_name"
@@ -332,7 +332,7 @@ truenas_ws_deploy() {
_info "Replace WebUI certificate..."
_ws_response=$(_ws_call "system.general.update" "{\"ui_certificate\": $_new_certid}")
_changed_certid=$(printf "%s" "$_ws_response" | jq -r '."ui_certificate"."id"')
_changed_certid=$(printf "%s" "$_ws_response" | jq -r '.ui_certificate | if type == "object" then .id else . end')
if [ "$_changed_certid" != "$_new_certid" ]; then
_err "WebUI certificate change error.."
return 5
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env sh
# Deploy hook for UniFi OS, via the certificate REST API.
#
# Works against any UniFi OS whose management UI exposes
# /api/userCertificates. Confirmed on:
# - UniFi OS Server (the separately-installed, self-hosted application)
# on macOS and on Linux. Windows should also work (it runs under
# WSL2), but has not been tested.
# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on
# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916).
# No lower version bound is claimed -- if the UI has a certificate
# manager, this hook should work.
#
# `unifios` vs `unifi`: the split is the access method, not the product
# line. `unifi` writes files / a Java keystore and needs local or SSH
# access on the device; this hook drives the same REST API the web UI
# uses and works remotely. Use `unifi` where acme.sh runs on the device
# itself, this hook where it does not.
#
# The API is served on the management port, which differs per install:
# UniFi OS Server listens on 11443 (hence the default below), while
# UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to
# "https://<host>" there.
#
# Endpoints used, all as the web UI itself calls them:
# POST /api/auth/login - session login (cookie + JWT)
# GET /api/userCertificates - list uploaded certificates
# POST /api/userCertificates - upload a new certificate
# DELETE /api/userCertificates/{id} - remove a certificate
# PUT /api/userCertificates/{id}/status - activate/deactivate a certificate
#
# This was reverse-engineered from the browser's Network tab while using the
# real GUI upload/activate/delete flow -- it is undocumented but is the same
# code path the UI uses, so it's far more robust than editing settings.yaml,
# http/local-certs.conf, or the underlying Postgres user_certificates table
# directly (all of which are also touched by this API, but only as a result
# of the app's own internal logic, which handles cert parsing, active-cert
# bookkeeping, and nginx config regeneration correctly on its own).
#
# Auth: POST /api/auth/login returns a `TOKEN` cookie containing a JWT whose
# payload has a `csrfToken` claim. That value must be echoed back as the
# `x-csrf-token` header on every subsequent state-changing request (a classic
# double-submit CSRF pattern). No other cookies were found to be necessary.
#
# Uses core acme.sh helpers throughout (_post/_get, _json_encode,
# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or
# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all
# honored the same as every other hook. The management API's cert may be
# self-signed -- it always is on a fresh install, and there is no reliable
# way to tell in advance whether an earlier run has already replaced it --
# so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see
# acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS
# verification for the rest of the acme.sh run, e.g. the connection to the
# ACME CA.
#
# Design: This hook does not save a certificate ID between renewals. Each
# upload gets a name unique to that run: the domain name plus a timestamp.
# This name never collides with an entry from a previous deploy. This is
# true even if that entry is still active. The hook uploads and activates
# the new certificate before it removes any old entries. If a failure
# occurs during this process, the server still has a valid, active
# certificate. The hook removes old entries only after activation is
# complete. It removes only entries whose name starts with the domain name,
# because this is the hook's own naming convention. As a result, this step
# can only affect entries that this hook created for this domain. It can
# never affect a certificate that a user uploaded manually, and it can
# never affect a self-signed certificate.
#
# Settings:
# DEPLOY_UNIFIOS_HOST - base URL of the management API
# (default: "https://localhost:11443", i.e. a UniFi OS Server on the
# same machine as acme.sh; set it to "https://<host>" for UniFi OS
# hardware or any remote target)
# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required)
# DEPLOY_UNIFIOS_PASSWORD - UniFi OS admin password (required)
#
# Example:
# export DEPLOY_UNIFIOS_USERNAME="acmeuser"
# export DEPLOY_UNIFIOS_PASSWORD="xxxxx"
# acme.sh --deploy -d example.com --deploy-hook unifios
#
# Please report bugs to https://github.com/acmesh-official/acme.sh/issues/7182
_uos_response_code() {
# tr strips the trailing newline along with form feeds; re-terminate
# before the second _egrep_o, whose sed fallback (used wherever egrep -o
# is unavailable) drops an unterminated final line on some platforms.
_uos_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")"
printf '%s\n' "$_uos_code" | _egrep_o "^[0-9][0-9]*"
}
_uos_response_cookie() {
# $1 = cookie name
grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1
}
_uos_split_json() {
# $1 = raw JSON list response
#
# _normalizeJson collapses the response to one line. This removes extra
# space around colons. It also removes any CR or LF characters that the
# server can add. However, _normalizeJson also removes the newline
# character at the end of the line. If the line has no ending newline
# character, some sed programs drop the last line of input. This code
# adds the newline back before the split below, to prevent that problem.
_uos_normalized="$(echo "$1" | _normalizeJson)"
# A literal newline character splits the JSON into one object per line.
# Grep can then match a single certificate entry at a time. This is not
# the two-character "\n" sequence: GNU sed reads "\n" in the replacement
# text as a newline character. POSIX does not define this behavior, and
# BSD sed prints "\n" as two literal characters, not as a newline.
printf '%s\n' "$_uos_normalized" | sed 's/},{/},\
{/g'
}
_uos_grep_literal() {
# $1 = literal text to find, matched without a regex -- portable to
# grep implementations with no -F flag (e.g. Solaris), and avoids "*"
# or "." in a domain name being read as a regex metacharacter.
while IFS= read -r _uos_line || [ -n "$_uos_line" ]; do
case "$_uos_line" in
*"$1"*) printf '%s\n' "$_uos_line" ;;
esac
done
}
unifios_deploy() {
_cdomain="$1"
_ckey="$2"
_ccert="$3"
_cca="$4"
_cfullchain="$5"
_debug _cdomain "$_cdomain"
_debug _ckey "$_ckey"
_debug _ccert "$_ccert"
_debug _cca "$_cca"
_debug _cfullchain "$_cfullchain"
# Scoped to this hook's own subshell -- does not affect the rest of the
# acme.sh run (e.g. the connection to the ACME CA).
export HTTPS_INSECURE=1
_getdeployconf DEPLOY_UNIFIOS_HOST
DEPLOY_UNIFIOS_HOST="${DEPLOY_UNIFIOS_HOST:-https://localhost:11443}"
_savedeployconf DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST"
_debug DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST"
_getdeployconf DEPLOY_UNIFIOS_USERNAME
_getdeployconf DEPLOY_UNIFIOS_PASSWORD
if [ -z "$DEPLOY_UNIFIOS_USERNAME" ] || [ -z "$DEPLOY_UNIFIOS_PASSWORD" ]; then
_err "DEPLOY_UNIFIOS_USERNAME and DEPLOY_UNIFIOS_PASSWORD must be set."
return 1
fi
_debug DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME"
_secure_debug DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD"
_info "Logging in to UniFi OS Server API at $DEPLOY_UNIFIOS_HOST..."
# _json_encode always appends a trailing "\n" escape, even to input with
# no trailing newline (it normalizes via `echo`, unconditionally adding
# one). That's harmless for the key/cert file content below, which
# legitimately ends in a real newline anyway, but wrong for these plain
# strings -- strip the spurious escape it leaves behind.
_uos_user_json="$(printf '%s' "$DEPLOY_UNIFIOS_USERNAME" | _json_encode)"
_uos_user_json="${_uos_user_json%\\n}"
_uos_pass_json="$(printf '%s' "$DEPLOY_UNIFIOS_PASSWORD" | _json_encode)"
_uos_pass_json="${_uos_pass_json%\\n}"
_login_body="{\"username\":\"$_uos_user_json\",\"password\":\"$_uos_pass_json\",\"token\":\"\",\"rememberMe\":false}"
_login_json="$(_post "$_login_body" "$DEPLOY_UNIFIOS_HOST/api/auth/login" "" "POST" "application/json")"
_login_code="$(_uos_response_code)"
if [ "$_login_code" != "200" ]; then
_err "Login failed (HTTP $_login_code)."
_err "Response: $_login_json"
return 1
fi
# Credentials are proven correct now -- save them, rather than only at the
# very end, so a later step failing doesn't discard a working login.
# base64-encoded: _save_conf wraps values in single quotes with no
# escaping, so a literal "'" in the password would otherwise corrupt the
# domain conf (see deploy/synology_dsm.sh for the same pattern).
_savedeployconf DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" "base64"
_savedeployconf DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" "base64"
_uos_token="$(_uos_response_cookie TOKEN)"
if [ -z "$_uos_token" ]; then
_err "Login succeeded but no TOKEN cookie was returned."
return 1
fi
_H1="Cookie: $_uos_token"
export _H1
_uos_jwt_payload="$(echo "$_uos_token" | cut -d '=' -f 2- | cut -d '.' -f 2)"
_uos_csrf="$(_durl_replace_base64 "$_uos_jwt_payload" | _dbase64 | _egrep_o '"csrfToken":"[^"]*"' | cut -d '"' -f 4)"
if [ -z "$_uos_csrf" ]; then
_err "Could not extract csrfToken from session token."
return 1
fi
_H2="x-csrf-token: $_uos_csrf"
export _H2
_info "Uploading new certificate..."
# "name" is a purely cosmetic label -- the server never validates it
# against the certificate's actual CN/SAN, and accepts arbitrary text
# including spaces (confirmed: a cert for example.com served correctly
# after being uploaded under the unrelated name "totally unrelated label").
# The only constraint that matters here is uniqueness: the server rejects
# a second entry with a name it already has, so a bare domain name would
# collide with the previous deploy's entry on every renewal after the
# first. A full human-readable timestamp would make that obvious in the
# UI, but the certificate list's name column is fixed-width and doesn't
# wrap (confirmed against the real UI: a long name overlaps the Expires
# column and makes both unreadable), so keep the suffix short instead --
# Unix epoch seconds are still unique enough for this purpose.
#
# This name includes the key type (rsa or ecdsa), to keep an RSA
# deploy and an ECC deploy of the same domain from sharing this
# prefix. Without the key type, the cleanup step for each deploy
# removes the entry that the other deploy creates. `deploy/haproxy.sh`
# and `deploy/lighttpd.sh` use the same `_isEccKey` check, for the same
# reason.
# shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook
if _isEccKey "${Le_Keylength}"; then
_uos_keytype="ecdsa"
else
_uos_keytype="rsa"
fi
_uos_name="$_cdomain $_uos_keytype $(_time)"
_uos_key_json="$(_json_encode <"$_ckey")"
_uos_cert_json="$(_json_encode <"$_cfullchain")"
_create_body="{\"name\":\"$_uos_name\",\"key\":\"$_uos_key_json\",\"cert\":\"$_uos_cert_json\"}"
_create_json="$(_post "$_create_body" "$DEPLOY_UNIFIOS_HOST/api/userCertificates" "" "POST" "application/json")"
_create_code="$(_uos_response_code)"
if [ "$_create_code" = "201" ]; then
_new_id="$(echo "$_create_json" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ -z "$_new_id" ]; then
_err "Could not determine new certificate ID from upload response."
return 1
fi
elif [ "$_create_code" = "400" ] && echo "$_create_json" | grep -q "USER_CERTIFICATE_DUPLICATE"; then
# HTTP 400 alone just means "bad request" -- it's the USER_CERTIFICATE_DUPLICATE
# code in the response body, checked above, that actually confirms this.
# The name above is unique to this run, so a duplicate here can only be
# the server's other uniqueness constraint: this exact certificate (by
# fingerprint) already exists as some other entry -- most likely a retry
# after a prior run already uploaded it (a real renewal always produces a
# new fingerprint, so this shouldn't happen in normal cron use). The
# response body doesn't include the existing entry's id, so look it up
# by fingerprint instead.
# The API's own fingerprint field is SHA-1 (20 bytes), not SHA-256 --
# confirmed against a real response, e.g.
# "fingerprint":"FC:02:50:9C:3B:3F:B7:79:9D:CA:4D:7C:AC:92:E7:D5:EA:F1:3A:29"
# (20 colon-separated groups). _fingerprint (core helper) strips the
# colons that field has, so re-insert them rather than stripping the
# JSON's own colons, which would also remove the ones separating every
# key from its value.
_uos_fingerprint="$(_fingerprint "$_cfullchain" sha1)"
if [ -z "$_uos_fingerprint" ]; then
_err "Could not compute the certificate's fingerprint."
return 1
fi
_uos_fingerprint="$(echo "$_uos_fingerprint" | sed 's/\(..\)/\1:/g; s/:$//')"
_list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")"
_list_code="$(_uos_response_code)"
if [ "$_list_code" != "200" ]; then
_err "Failed to list existing certificates (HTTP $_list_code)."
_err "Response: $_list_json"
return 1
fi
_list_json="$(_uos_split_json "$_list_json")"
_new_id="$(echo "$_list_json" | _uos_grep_literal "\"fingerprint\":\"$_uos_fingerprint\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
if [ -z "$_new_id" ]; then
_err "Certificate upload rejected as a duplicate (server reported USER_CERTIFICATE_DUPLICATE), but no existing entry matching this fingerprint was found."
_err "Response: $_create_json"
return 1
fi
# Reusing the existing entry rather than deleting it and re-uploading
# under today's name+timestamp: the served content is identical either
# way, so replacing it would only cost an extra delete+create round trip
# for no functional benefit. The tradeoff is cosmetic -- this entry keeps
# whatever name it was given whenever it was originally uploaded, so it
# won't reflect today's date in the UI.
_info "Certificate already present as entry $_new_id; reusing it."
else
_err "Certificate upload failed (HTTP $_create_code)."
_err "Response: $_create_json"
return 1
fi
_info "Activating certificate $_new_id..."
_activate_json="$(_post '{"active":true}' "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_new_id/status" "" "PUT" "application/json")"
_activate_code="$(_uos_response_code)"
if [ "$_activate_code" != "200" ]; then
_err "Failed to activate new certificate (HTTP $_activate_code)."
_err "Response: $_activate_json"
return 1
fi
# UniFi OS Server activation is exclusive server-wide. Tests against the
# real API confirm this: activation of one entry deactivates whichever
# other entry was active before, no matter its name or domain. As a
# result, the server serves the certificate that this hook just activated.
# This certificate is already live. If the removal of old entries below
# fails, the hook logs the failure. The deploy does not fail because of
# this.
_info "Checking for old certificate entries to remove..."
_list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")"
_list_code="$(_uos_response_code)"
if [ "$_list_code" != "200" ]; then
_err "Failed to list certificates for cleanup (HTTP $_list_code) -- leaving old entries in place."
else
_list_json="$(_uos_split_json "$_list_json")"
# The pattern below matches the domain name and key type, followed by
# a space. If the space is missing, the pattern can also match a
# different domain that starts with the same text as this domain.
_old_ids="$(echo "$_list_json" | _uos_grep_literal "\"name\":\"$_cdomain $_uos_keytype " | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4 | grep -v "^$_new_id$")"
for _old_id in $_old_ids; do
_info "Removing old certificate entry $_old_id..."
_del_json="$(_post "" "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_old_id" "" "DELETE")"
_del_code="$(_uos_response_code)"
if [ "$_del_code" != "204" ] && [ "$_del_code" != "200" ]; then
_err "Failed to delete old certificate $_old_id (HTTP $_del_code) -- leaving it in place."
_err "Response: $_del_json"
fi
done
fi
_info "UniFi OS Server certificate deployed and activated successfully."
return 0
}
+43 -10
View File
@@ -9,7 +9,8 @@ Options:
AZUREDNS_APPID App ID. App ID of the service principal
AZUREDNS_CLIENTSECRET Client Secret. Secret from creating the service principal
AZUREDNS_MANAGEDIDENTITY Use Managed Identity. Use Managed Identity assigned to a resource instead of a service principal. "true"/"false"
AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional.
AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Not saved, provide it on every run. Optional.
AZUREDNS_PRIVATEZONE Use Azure Private DNS Zones instead of Public DNS Zones. "true"/"false"
'
wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS
@@ -39,6 +40,12 @@ dns_azure_add() {
#save subscription id to account conf file.
_saveaccountconf_mutable AZUREDNS_SUBSCRIPTIONID "$AZUREDNS_SUBSCRIPTIONID"
AZUREDNS_PRIVATEZONE="${AZUREDNS_PRIVATEZONE:-$(_readaccountconf_mutable AZUREDNS_PRIVATEZONE)}"
if [ -n "$AZUREDNS_PRIVATEZONE" ]; then
#save public/private dns to account conf file.
_saveaccountconf_mutable AZUREDNS_PRIVATEZONE "$AZUREDNS_PRIVATEZONE"
fi
AZUREDNS_MANAGEDIDENTITY="${AZUREDNS_MANAGEDIDENTITY:-$(_readaccountconf_mutable AZUREDNS_MANAGEDIDENTITY)}"
if [ "$AZUREDNS_MANAGEDIDENTITY" = true ]; then
_info "Using Azure managed identity"
@@ -47,13 +54,15 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID ""
_saveaccountconf_mutable AZUREDNS_APPID ""
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET ""
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN ""
_clearaccountconf_mutable AZUREDNS_BEARERTOKEN
else
_info "You didn't ask to use Azure managed identity, checking service principal credentials or provided bearer token"
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
#AZUREDNS_BEARERTOKEN is short-lived, so it is taken from the environment only and never
#read from or saved to the account conf. Versions up to 3.0.9 cached their internal access
#token under the same name, which must not be replayed as a user token (#7218).
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
@@ -93,7 +102,7 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID"
_saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID"
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET"
_saveaccountconf_mutable AZUREDNS_BEARERTOKEN "$AZUREDNS_BEARERTOKEN"
_clearaccountconf_mutable AZUREDNS_BEARERTOKEN
fi
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
@@ -110,7 +119,9 @@ dns_azure_add() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=2017-09-01"
_azure_set_zone_vars
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=$_azure_api_version"
_debug "$acmeRecordURI"
# Get existing TXT record
_azure_rest GET "$acmeRecordURI" "" "$accesstoken"
@@ -136,7 +147,7 @@ dns_azure_add() {
fi
fi
# Add the txtvalue TXT Record
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"TTL\":10, \"TXTRecords\":[$values]}}"
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"$_azure_ttl_key\":10, \"$_azure_txt_key\":[$values]}}"
_azure_rest PUT "$acmeRecordURI" "$body" "$accesstoken"
if [ "$_code" = "200" ] || [ "$_code" = '201' ]; then
_info "validation value added"
@@ -167,6 +178,8 @@ dns_azure_rm() {
return 1
fi
AZUREDNS_PRIVATEZONE="${AZUREDNS_PRIVATEZONE:-$(_readaccountconf_mutable AZUREDNS_PRIVATEZONE)}"
AZUREDNS_MANAGEDIDENTITY="${AZUREDNS_MANAGEDIDENTITY:-$(_readaccountconf_mutable AZUREDNS_MANAGEDIDENTITY)}"
if [ "$AZUREDNS_MANAGEDIDENTITY" = true ]; then
_info "Using Azure managed identity"
@@ -175,7 +188,7 @@ dns_azure_rm() {
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
#AZUREDNS_BEARERTOKEN comes from the environment only, see the note in dns_azure_add
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
@@ -225,8 +238,11 @@ dns_azure_rm() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=2017-09-01"
_azure_set_zone_vars
acmeRecordURI="https://management.azure.com$(printf '%s' "$_domain_id" | sed 's/\\//g')/TXT/$_sub_domain?api-version=$_azure_api_version"
_debug "$acmeRecordURI"
# Get existing TXT record
_azure_rest GET "$acmeRecordURI" "" "$accesstoken"
timestamp="$(_time)"
@@ -250,7 +266,7 @@ dns_azure_rm() {
fi
else
# Remove only txtvalue from the TXT Record
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"TTL\":10, \"TXTRecords\":[$values]}}"
body="{\"properties\":{\"metadata\":{\"acmetscheck\":\"$timestamp\"},\"$_azure_ttl_key\":10, \"$_azure_txt_key\":[$values]}}"
_azure_rest PUT "$acmeRecordURI" "$body" "$accesstoken"
if [ "$_code" = "200" ] || [ "$_code" = '201' ]; then
_info "validation value removed"
@@ -381,6 +397,21 @@ _azure_getaccess_token() {
return 0
}
_azure_set_zone_vars() {
if [ "$AZUREDNS_PRIVATEZONE" = "true" ]; then
_azure_zone_type="privateDnsZones"
_azure_api_version="2024-06-01"
_azure_ttl_key="ttl"
_azure_txt_key="txtRecords"
_debug "Querying private DNS zone"
else
_azure_zone_type="dnszones"
_azure_api_version="2017-09-01"
_azure_ttl_key="TTL"
_azure_txt_key="TXTRecords"
fi
}
_get_root() {
domain=$1
subscriptionId=$2
@@ -388,6 +419,8 @@ _get_root() {
i=1
p=1
_azure_set_zone_vars
## Ref: https://learn.microsoft.com/en-us/rest/api/dns/zones/list?view=rest-dns-2018-05-01&tabs=HTTP
## returns up to 100 zones in one response. Handling more results is not implemented
## (ZoneListResult with continuation token for the next page of results)
@@ -396,7 +429,7 @@ _get_root() {
## https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#azure-dns-limits
## The new limit is 250 Public DNS zones per subscription, while the old limit was only 100
##
_azure_rest GET "https://management.azure.com/subscriptions/$subscriptionId/providers/Microsoft.Network/dnszones?\$top=500&api-version=2017-09-01" "" "$accesstoken"
_azure_rest GET "https://management.azure.com/subscriptions/$subscriptionId/providers/Microsoft.Network/$_azure_zone_type?\$top=500&api-version=$_azure_api_version" "" "$accesstoken"
# Find matching domain name in Json response
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+3 -3
View File
@@ -323,21 +323,21 @@ _bhosted_extract_id() {
fi
# JSON: "id":12345
_id="$(printf "%s" "$_resp" | _egrep_o '"id"[[:space:]]*:[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '"id"[ ]*:[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# key=value: id=12345
_id="$(printf "%s" "$_resp" | _egrep_o '(^|[[:space:][:punct:]])id[[:space:]]*=[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '(^|[^0-9a-zA-Z])id[ ]*=[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# "record id 12345" / "recordid 12345"
_id="$(printf "%s" "$_resp" | _egrep_o '(record[[:space:]]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
_id="$(printf "%s" "$_resp" | _egrep_o '(record[ ]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
+4 -4
View File
@@ -75,7 +75,7 @@ dns_creoline_rm() {
return 1
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"
if [ -z "$record_id" ]; then
@@ -108,10 +108,10 @@ _get_root() {
return 1
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"
_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"
if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
@@ -171,7 +171,7 @@ _creoline_rest() {
_err "URI:$uri"
return 1
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 "URI:$uri"
return 1
+3 -3
View File
@@ -285,15 +285,15 @@ _cyon_delete_txt() {
list_txt_url="https://my.cyon.ch/domain/dnseditor/list-async"
list_txt_response="$(_get "${list_txt_url}" | sed -e 's/data-hash/\\ndata-hash/g')"
list_txt_response="$(_get "${list_txt_url}")"
_debug list_txt_response "${list_txt_response}"
if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi
# Find and delete all acme challenge entries for the $fulldomain.
_dns_entries="$(printf "%b\n" "${list_txt_response}" | sed -n 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\".*/\1 \2/p')"
_dns_entries="$(printf "%s\n" "${list_txt_response}" | _egrep_o 'data-hash=\\"[^"]*\\" data-identifier=\\"[^"]*\\"' | sed 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\"/\1 \2/')"
printf "%s" "${_dns_entries}" | while read -r _hash _identifier; do
printf "%s\n" "${_dns_entries}" | while read -r _hash _identifier; do
dns_type="$(printf "%s" "$_identifier" | cut -d'|' -f1)"
dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)"
+8 -5
View File
@@ -30,8 +30,9 @@ dns_czechia_add() {
return 1
fi
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
_czechia_tab="$(printf '\t')"
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -108,8 +109,9 @@ dns_czechia_rm() {
return 1
fi
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
_czechia_tab="$(printf '\t')"
_cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
_tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -180,12 +182,13 @@ _czechia_load_conf() {
}
_czechia_pick_zone() {
_czechia_pz_tab="$(printf '\t')"
_fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//')
_best_zone=""
_zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g')
for _z in $_zones_space; do
_clean_z=$(printf "%s" "$_z" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
_clean_z=$(printf "%s" "$_z" | _lower_case | sed "s/[ $_czechia_pz_tab]//g; s/\.\$//")
[ -z "$_clean_z" ] && continue
case "$_fd" in
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_dnsmint_info='DNSMint.com
DNSMint mints hostnames on domains it operates and serves from its own
authoritative nameservers, so records are published through its API rather
than a zone you run.
Site: dnsmint.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsmint
Options:
DNSMINT_API_KEY API key. dns01:write is enough to issue certificates.
Issues: github.com/acmesh-official/acme.sh/issues/7251
Author: DNSMint
'
DNSMint_Api="${DNSMint_Api:-https://dnsmint.com/api}"
######## Public functions #####################
#Usage: dns_dnsmint_add _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_dnsmint_add() {
fulldomain=$1
txtvalue=$2
_info "Using DNSMint"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _dnsmint_key; then
return 1
fi
# An ACME challenge goes to the DNS-01 endpoint, which derives the hostname
# itself and needs only dns01:write. Any other name is an ordinary record
# under a hostname, which is a different endpoint and a wider scope.
if _startswith "$fulldomain" "_acme-challenge."; then
if _dnsmint_challenge present "$fulldomain" "$txtvalue"; then
_info "Added, OK"
return 0
fi
return 1
fi
if _dnsmint_record_add "$fulldomain" "$txtvalue"; then
_info "Added, OK"
return 0
fi
return 1
}
#Usage: dns_dnsmint_rm _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_dnsmint_rm() {
fulldomain=$1
txtvalue=$2
_info "Using DNSMint"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _dnsmint_key; then
return 1
fi
if _startswith "$fulldomain" "_acme-challenge."; then
if _dnsmint_challenge cleanup "$fulldomain" "$txtvalue"; then
_info "Removed, OK"
return 0
fi
return 1
fi
if _dnsmint_record_rm "$fulldomain" "$txtvalue"; then
_info "Removed, OK"
return 0
fi
return 1
}
#################### Private functions below ##################################
_dnsmint_key() {
DNSMINT_API_KEY="${DNSMINT_API_KEY:-$(_readaccountconf_mutable DNSMINT_API_KEY)}"
if [ -z "$DNSMINT_API_KEY" ]; then
DNSMINT_API_KEY=""
_err "You did not specify DNSMINT_API_KEY yet."
_err "Create a key with the dns01:write scope at https://dnsmint.com/dashboard"
_err "e.g."
_err "export DNSMINT_API_KEY=dnsm_xxxxxxxxxxxx_xxxxxxxx"
return 1
fi
_saveaccountconf_mutable DNSMINT_API_KEY "$DNSMINT_API_KEY"
return 0
}
_dnsmint_headers() {
export _H1="Authorization: Bearer $DNSMINT_API_KEY"
export _H2="Accept: application/json"
export _H3="Content-Type: application/json"
}
# One request. Sets $response and $_code; returns non-zero on a transport error.
_dnsmint_rest() {
_m="$1"
_ep="$2"
_data="$3"
_dnsmint_headers
if [ "$_m" = "GET" ]; then
response="$(_get "$DNSMint_Api$_ep")"
else
_secure_debug2 _data "$_data"
response="$(_post "$_data" "$DNSMint_Api$_ep" "" "$_m")"
fi
_ret="$?"
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
_debug "http response code $_code"
_debug2 response "$response"
if [ "$_ret" != "0" ]; then
_err "error $_ep"
return 1
fi
case "$_code" in
2*) return 0 ;;
*)
# The API says why in the body - a key narrowed to another hostname, a
# name that is not live - and that is more use than the status alone.
_err "error $_ep: HTTP $_code $response"
return 1
;;
esac
}
# The DNS-01 endpoint. It derives the hostname from the challenge name, so
# there is no zone to look up and no record id to track: the value published
# is the value removed.
_dnsmint_challenge() {
_action="$1"
_fqdn="$2"
_value="$3"
_dnsmint_rest POST "/httpreq/$_action" "{\"fqdn\":\"$_fqdn\",\"value\":\"$_value\"}"
}
# Everything below here is for names that are not ACME challenges. A record
# under a hostname is addressed by the hostname's id and a name relative to
# it, so the hostname has to be found first.
_dnsmint_host() {
_name="$1"
_host_id=""
_host_sub=""
if ! _dnsmint_rest GET "/v1/hostnames?limit=500"; then
return 1
fi
for _h in $(echo "$response" | _egrep_o '"hostname":"[^"]*"' | cut -d'"' -f4); do
case "$_name" in
*".$_h")
# Longest suffix wins, so a.b.example.dev prefers b.example.dev over
# example.dev when both are hostnames on the account.
if [ "${#_h}" -gt "${#_host_sub}" ]; then
_host_sub="$_h"
fi
;;
esac
done
if [ -z "$_host_sub" ]; then
_err "$_name is not under a hostname on this account"
return 1
fi
# The id sits next to the hostname in the same object.
_host_id="$(echo "$response" | _egrep_o "\"id\":\"[^\"]*\",\"hostname\":\"$_host_sub\"" | cut -d'"' -f4)"
if [ -z "$_host_id" ]; then
_err "could not read the id for $_host_sub"
return 1
fi
_record_name="${_name%".$_host_sub"}"
_debug _host_sub "$_host_sub"
_debug _record_name "$_record_name"
return 0
}
_dnsmint_record_add() {
_name="$1"
_value="$2"
if ! _dnsmint_host "$_name"; then
return 1
fi
_dnsmint_rest POST "/v1/hostnames/$_host_id/records" \
"{\"name\":\"$_record_name\",\"type\":\"TXT\",\"text\":\"$_value\"}"
}
_dnsmint_record_rm() {
_name="$1"
_value="$2"
if ! _dnsmint_host "$_name"; then
return 1
fi
if ! _dnsmint_rest GET "/v1/hostnames/$_host_id/records"; then
return 1
fi
# Records come back as {"id":...,"name":"<fqdn>","type":"TXT","ttl":...,
# "data":{...,"text":["<value>"]}}. Match on the value so a name holding
# several TXT records loses only the one that was added.
#
# The replacement carries a literal newline: "\n" there is a GNU extension
# and BSD sed inserts the letter n, which would leave the whole reply on one
# line and match the first record under the hostname whatever its value.
#
# echo rather than printf "%s": the reply arrives with no trailing newline,
# and Solaris /usr/bin/sed discards an incomplete final line. Here that line
# is the entire reply, so every removal would report the record already gone.
_records="$(
echo "$response" | sed 's/},{/}\
{/g'
)"
_rid=""
while IFS= read -r _line; do
case "$_line" in
*"\"$_value\""*)
# _head_n 1 because a record object may carry a nested id under "data",
# and two lines in _rid would break the DELETE URL.
_rid="$(echo "$_line" | _egrep_o '"id":"[^"]*"' | cut -d'"' -f4 | _head_n 1)"
if [ -n "$_rid" ]; then
break
fi
;;
esac
done <<EOF
$_records
EOF
if [ -z "$_rid" ]; then
_info "Record already gone, nothing to remove"
return 0
fi
_dnsmint_rest DELETE "/v1/hostnames/$_host_id/records/$_rid" ""
}
+6
View File
@@ -150,6 +150,9 @@ _dns_dynv6_add_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_set_record TXT "$record" "$txtvalue"
if _contains "$response" "$txtvalue"; then
_info "Successfully added record"
@@ -168,6 +171,9 @@ _dns_dynv6_rm_http() {
fi
_get_zone_name "$_zone_id"
record=${fulldomain%%."$_zone_name"}
if [ "$fulldomain" = "$_zone_name" ]; then
record=""
fi
_get_record_id "$_zone_id" "$record" "$txtvalue"
_del_record "$_zone_id" "$_record_id"
if [ -z "$response" ]; then
+17 -15
View File
@@ -75,6 +75,11 @@ dns_easydns_rm() {
EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}"
EASYDNS_Key="${EASYDNS_Key:-$(_readaccountconf_mutable EASYDNS_Key)}"
if [ -z "$EASYDNS_Token" ] || [ -z "$EASYDNS_Key" ]; then
_err "You didn't specify an easydns.net token or api key. Signup at https://cp.easydns.com/manage/security/api/signup.php"
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
@@ -91,24 +96,21 @@ dns_easydns_rm() {
return 1
fi
count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
_debug count "$count"
if [ "$count" = "0" ]; then
record_id=$(printf "%s\n" "$response" | tr '{' '\n' | grep "\"rdata\":\"$txtvalue\"" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \")
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
_info "Don't need to remove."
else
record_id=$(printf "%s\n" "$response" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | head -n 1)
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
_err "Can not get record id to remove."
return 1
fi
if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
_err "Delete record error."
return 1
fi
_contains "$response" "\"status\":200"
return 0
fi
if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
_err "Delete record error."
return 1
fi
_contains "$response" "\"status\":200"
}
#################### Private functions below ##################################
+20 -9
View File
@@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_freemyip
Options:
FREEMYIP_Token API Token
Issues: github.com/acmesh-official/acme.sh/issues/6247
Author: Recolic Keghart <root@recolic.net>, @Giova96
Author: Recolic Keghart <root@recolic.net>, @Giova96, ExtremeFiretop
'
FREEMYIP_DNS_API="https://freemyip.com/update?"
@@ -68,22 +68,30 @@ dns_freemyip_rm() {
return $?
}
################ Private functions below ################
################ Private functions below ################
_get_root() {
_fmi_d="$1"
echo "$_fmi_d" | rev | cut -d '.' -f 1-3 | rev
echo "$_fmi_d" | sed 's/.*\.\([^.]*\.[^.]*\.[^.]*\)$/\1/'
}
# There is random failure while calling freemyip API too fast. This function automatically retry until success.
_freemyip_get_until_ok() {
_fmi_url="$1"
for i in $(seq 1 8); do
_debug "HTTP GET freemyip.com API '$_fmi_url', retry $i/8..."
_get "$_fmi_url" | tee /dev/fd/2 | grep OK && return 0
_fmi_i=1
while [ "$_fmi_i" -le 8 ]; do
_debug "HTTP GET freemyip.com API '$_fmi_url', retry $_fmi_i/8..."
_fmi_response="$(_get "$_fmi_url")"
printf '%s\n' "$_fmi_response" >&2
if _contains "$_fmi_response" "OK"; then
return 0
fi
_sleep 1 # DO NOT send the request too fast
_fmi_i=$((_fmi_i + 1))
done
_err "Failed to request freemyip API: $_fmi_url . Server does not say 'OK'"
_err "Failed to request freemyip API. Server does not say 'OK'"
return 1
}
@@ -93,13 +101,16 @@ _is_root_domain_published() {
_webroot="$(_get_root "$_fmi_d")"
_info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet"
for i in $(seq 1 3); do
_debug "'$_webroot' ns lookup, retry $i/3..."
_fmi_i=1
while [ "$_fmi_i" -le 3 ]; do
_debug "'$_webroot' ns lookup, retry $_fmi_i/3..."
if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then
_debug "'$_webroot' already has a TXT record published!"
return 0
fi
_sleep 10 # Give it some time to propagate the TXT record
_fmi_i=$((_fmi_i + 1))
done
return 1
}
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_hestiacp_info='HestiaCP Server API
Site: hestiacp.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hestiacp
Options:
HESTIA_HOST Panel URL. E.g. "https://panel.example.com:8083"
HESTIA_ACCESS API access key
HESTIA_SECRET API secret key
HESTIA_USER Username owning the DNS zones. Default "admin". Optional.
Issues: github.com/acmesh-official/acme.sh/issues/6251
Author: Radu Malica <radu.malica@gmail.com>
'
######## Public functions #####################
# Usage: dns_hestiacp_add fulldomain txtvalue
dns_hestiacp_add() {
fulldomain=$1
txtvalue=$2
if ! _hestia_init; then
return 1
fi
_debug "Detecting the root zone for $fulldomain"
if ! _hestia_get_root "$fulldomain"; then
_err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER"
return 1
fi
_debug _hestia_domain "$_hestia_domain"
_debug _hestia_sub "$_hestia_sub"
# _hestia_get_root left the zone record listing in _hestia_response
if _hestia_find_records "$_hestia_sub" "TXT" | grep -F -- "$txtvalue" >/dev/null; then
_info "The TXT record already exists, skipping"
return 0
fi
_info "Adding TXT record for $fulldomain"
if ! _hestia_rest "v-add-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_sub" "TXT" "$txtvalue" "" "" "yes" "600"; then
_err "Error adding TXT record: $_hestia_response"
return 1
fi
_info "TXT record added successfully"
return 0
}
# Usage: dns_hestiacp_rm fulldomain txtvalue
dns_hestiacp_rm() {
fulldomain=$1
txtvalue=$2
if ! _hestia_init; then
return 1
fi
_debug "Detecting the root zone for $fulldomain"
if ! _hestia_get_root "$fulldomain"; then
_err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER"
return 1
fi
_debug _hestia_domain "$_hestia_domain"
_debug _hestia_sub "$_hestia_sub"
_hestia_removed=0
_hestia_failed=0
while IFS='|' read -r _hestia_id _hestia_value || [ -n "$_hestia_id" ]; do
if [ -z "$_hestia_id" ]; then
continue
fi
if ! _contains "$_hestia_value" "$txtvalue"; then
continue
fi
_info "Deleting TXT record $_hestia_id"
if ! _hestia_rest "v-delete-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_id" "yes"; then
_err "Error deleting TXT record $_hestia_id: $_hestia_response"
_hestia_failed=$(_math "$_hestia_failed" + 1)
continue
fi
_hestia_removed=$(_math "$_hestia_removed" + 1)
done <<EOF
$(_hestia_find_records "$_hestia_sub" "TXT")
EOF
if [ "$_hestia_removed" = "0" ] && [ "$_hestia_failed" = "0" ]; then
_info "No matching TXT record found to remove"
else
_info "Removed $_hestia_removed TXT record(s)"
fi
if [ "$_hestia_failed" != "0" ]; then
return 1
fi
return 0
}
#################### Private functions below ##################################
_hestia_init() {
HESTIA_HOST="${HESTIA_HOST:-$(_readaccountconf_mutable HESTIA_HOST)}"
HESTIA_ACCESS="${HESTIA_ACCESS:-$(_readaccountconf_mutable HESTIA_ACCESS)}"
HESTIA_SECRET="${HESTIA_SECRET:-$(_readaccountconf_mutable HESTIA_SECRET)}"
HESTIA_USER="${HESTIA_USER:-$(_readaccountconf_mutable HESTIA_USER)}"
if [ -z "$HESTIA_HOST" ] || [ -z "$HESTIA_ACCESS" ] || [ -z "$HESTIA_SECRET" ]; then
HESTIA_HOST=""
HESTIA_ACCESS=""
HESTIA_SECRET=""
_err "You must export HESTIA_HOST, HESTIA_ACCESS and HESTIA_SECRET first"
return 1
fi
HESTIA_HOST="${HESTIA_HOST%/}"
if ! echo "$HESTIA_HOST" | grep -qE '^https?://[^/]+$'; then
_err "HESTIA_HOST must be a valid URL (e.g. https://panel.example.com:8083)"
return 1
fi
if [ -z "$HESTIA_USER" ]; then
HESTIA_USER="admin"
fi
_saveaccountconf_mutable HESTIA_HOST "$HESTIA_HOST"
_saveaccountconf_mutable HESTIA_ACCESS "$HESTIA_ACCESS"
_saveaccountconf_mutable HESTIA_SECRET "$HESTIA_SECRET"
_saveaccountconf_mutable HESTIA_USER "$HESTIA_USER"
return 0
}
# Walk up the domain labels until the API returns a DNS zone.
# Sets _hestia_domain to the zone and _hestia_sub to the record name
# relative to the zone. The zone record listing stays in _hestia_response.
_hestia_get_root() {
_hestia_fqdn="${1%.}"
_hestia_i=1
while true; do
_hestia_h=$(printf "%s" "$_hestia_fqdn" | cut -d . -f "$_hestia_i"-100)
_debug2 _hestia_h "$_hestia_h"
if [ -z "$_hestia_h" ]; then
return 1
fi
if _hestia_rest "v-list-dns-records" "$HESTIA_USER" "$_hestia_h" "json"; then
_hestia_domain="$_hestia_h"
if [ "$_hestia_h" = "$_hestia_fqdn" ]; then
_hestia_sub="@"
else
_hestia_sub=$(printf "%s" "$_hestia_fqdn" | cut -d . -f 1-"$(_math "$_hestia_i" - 1)")
fi
return 0
fi
_hestia_i=$(_math "$_hestia_i" + 1)
done
}
# Call the HestiaCP API. Args: cmd [arg1 arg2 ...]
# The response body is stored in _hestia_response.
_hestia_rest() {
_hestia_cmd=$1
shift
_hestia_data="{\"access_key\":\"$HESTIA_ACCESS\",\"secret_key\":\"$HESTIA_SECRET\",\"cmd\":\"$_hestia_cmd\""
_hestia_argn=1
for _hestia_arg in "$@"; do
_hestia_data="$_hestia_data,\"arg$_hestia_argn\":\"$_hestia_arg\""
_hestia_argn=$(_math "$_hestia_argn" + 1)
done
_hestia_data="$_hestia_data}"
_debug2 "Calling $_hestia_cmd"
_hestia_response=$(_post "$_hestia_data" "$HESTIA_HOST/api/" "" "POST" "application/json")
_hestia_ret=$?
_debug2 _hestia_response "$_hestia_response"
if [ "$_hestia_ret" != "0" ]; then
_err "Error connecting to the HestiaCP API"
return 1
fi
if _contains "$_hestia_response" "Error:"; then
return 1
fi
return 0
}
# Extract records matching name and type from the v-list-dns-records
# response in _hestia_response. Prints one "id|value" line per match.
_hestia_find_records() {
_hestia_fname=$1
_hestia_ftype=$2
echo "$_hestia_response" | tr -d '\n' | sed 's/},/}\
/g' | grep -F -- "\"RECORD\": \"$_hestia_fname\"" | grep -F -- "\"TYPE\": \"$_hestia_ftype\"" | while read -r _hestia_line; do
_hestia_id=$(echo "$_hestia_line" | _egrep_o '"ID": "[^"]*' | cut -d '"' -f 4)
_hestia_value=$(echo "$_hestia_line" | _egrep_o '"VALUE": "[^"]*' | cut -d '"' -f 4)
if [ -n "$_hestia_id" ]; then
echo "$_hestia_id|$_hestia_value"
fi
done
}
+4 -4
View File
@@ -441,18 +441,18 @@ _hostup_json_extract() {
input="${2:-$line}"
# 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
printf "%s" "$quoted_match" |
cut -d : -f2- |
sed 's/^[[:space:]]*"//' |
sed 's/"[[:space:]]*$//' |
sed 's/^[ ]*"//' |
sed 's/"[ ]*$//' |
sed 's/\\"/"/g'
return 0
fi
# Fallback for unquoted values (e.g., numeric IDs)
unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*[^,}]*" | _head_n 1)"
unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*[^,}]*" | _head_n 1)"
if [ -n "$unquoted_match" ]; then
printf "%s" "$unquoted_match" |
cut -d : -f2- |
+227
View File
@@ -0,0 +1,227 @@
#!/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="limit=1&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=$(echo "${_hw_domain}." | _lower_case)
_hw_returned_name=$(echo "$_hw_returned_name" | _lower_case)
if [ "$_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
}
+2 -2
View File
@@ -117,7 +117,7 @@ dns_infoblox_uddi_rm() {
return 0
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"
if [ -z "$record_id" ]; then
@@ -178,7 +178,7 @@ _get_root() {
# Check if response contains results (even if empty)
if _contains "$response" '"results"'; then
# 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
# Found the zone
_domain="$h"
+48 -40
View File
@@ -7,22 +7,23 @@ Options:
JD_ACCESS_KEY_ID Access key ID
JD_ACCESS_KEY_SECRET Access key secret
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_PROD="clouddnsservice"
_JD_PROD="domainservice"
_JD_API="jdcloud-api.com"
_JD_API_VERSION="v1"
_JD_API_VERSION="v2"
_JD_DEFAULT_REGION="cn-north-1"
_JD_HOST="$_JD_PROD.$_JD_API"
######## 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() {
fulldomain=$1
txtvalue=$2
@@ -58,24 +59,14 @@ dns_jd_add() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
#_debug "Getting getViewTree"
#_debug "Getting describeViewTree"
_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":"xx","hostValue":"\"value4\"","jcloudRes":false,"mxPriority":null,"port":null,"ttl":300,"type":"TXT","weight":null,"viewValue":-1},"regionId":"cn-north-1","domainId":"8824"}'
if jd_rest POST "domain/$_domain_id/RRAdd" "" "$_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
_addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1}}"
#_addrr='{"req":{"hostRecord":"_acme-challenge","hostValue":"XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs","ttl":300,"type":"TXT","viewValue":-1}}'
if jd_rest POST "domain/$_domain_id/ResourceRecord" "" "$_addrr"; then
_info "TXT record added successfully."
_srid="$(_readdomainconf "JD_CLOUD_RIDS")"
if [ "$_srid" ]; then
_rid="$_srid,$_rid"
fi
_savedomainconf "JD_CLOUD_RIDS" "$_rid"
return 0
fi
@@ -97,14 +88,7 @@ dns_jd_rm() {
_JD_BASE_URI="$_JD_API_VERSION/regions/$JD_REGION"
_info "Getting existing records for $fulldomain"
_srid="$(_readdomainconf "JD_CLOUD_RIDS")"
_debug _srid "$_srid"
if [ -z "$_srid" ]; then
_err "Not rid skip"
return 0
fi
_info "Removing TXT record for $fulldomain"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
@@ -115,16 +99,37 @@ dns_jd_rm() {
_debug _sub_domain "$_sub_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."
return 0
fi
return 1
_err "Failed to delete TXT record."
return 1
}
#################### Private functions below ##################################
@@ -134,13 +139,14 @@ _get_root() {
i=1
p=1
if ! jd_rest GET "domain"; then
_err "error get domain list"
return 1
fi
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug2 "Checking domain: $h"
if ! jd_rest GET "domain"; then
_err "error get domain list"
return 1
fi
if [ -z "$h" ]; then
#not valid
_err "Invalid domain"
@@ -168,6 +174,8 @@ _get_root() {
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
jd_rest() {
mtd="$1"
@@ -220,7 +228,7 @@ jd_rest() {
CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$RequestPayloadHash"
_debug2 CanonicalRequest "$CanonicalRequest"
HashedCanonicalRequest="$(printf "$CanonicalRequest%s" | _digest "$Hash" hex)"
HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)"
_debug2 HashedCanonicalRequest "$HashedCanonicalRequest"
Algorithm="JDCLOUD2-HMAC-SHA256"
@@ -246,19 +254,19 @@ jd_rest() {
kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")"
_secure_debug2 kSecretH "$kSecretH"
kDateH="$(printf "$RequestDateOnly%s" | _hmac "$Hash" "$kSecretH" hex)"
kDateH="$(printf '%s' "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)"
_debug2 kDateH "$kDateH"
kRegionH="$(printf "$Region%s" | _hmac "$Hash" "$kDateH" hex)"
kRegionH="$(printf '%s' "$Region" | _hmac "$Hash" "$kDateH" hex)"
_debug2 kRegionH "$kRegionH"
kServiceH="$(printf "$Service%s" | _hmac "$Hash" "$kRegionH" hex)"
kServiceH="$(printf '%s' "$Service" | _hmac "$Hash" "$kRegionH" hex)"
_debug2 kServiceH "$kServiceH"
kSigningH="$(printf "%s" "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)"
kSigningH="$(printf '%s' "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)"
_debug2 kSigningH "$kSigningH"
signature="$(printf "$StringToSign%s" | _hmac "$Hash" "$kSigningH" hex)"
signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)"
_debug2 signature "$signature"
Authorization="$Algorithm Credential=$JD_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature"
-189
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
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
}
+27 -1
View File
@@ -104,6 +104,9 @@ _get_root_by_getList() {
return 1
fi
_namecheap_domain_list=$(echo "$response" | _egrep_o '<Domain [^>]*')
_debug2 domain_list "$_namecheap_domain_list"
i=2
p=1
@@ -120,7 +123,7 @@ _get_root_by_getList() {
return 1
fi
if ! _contains "$response" "$h"; then
if ! _namecheap_is_our_dns "$h"; then
_debug "$h not found"
else
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -133,6 +136,29 @@ _get_root_by_getList() {
return 1
}
#Usage: _namecheap_is_our_dns <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() {
i=100
p=99
+377 -22
View File
@@ -5,37 +5,324 @@ Domains: netcup.de netcup.net
Site: netcup.eu/
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_netcup
Options:
NC_Apikey API Key
NC_Apipw API Password
NC_CID Customer Number
NC_Apikey API Key. The new netcup REST API key (64 characters) or the legacy CCP API key
NC_Apipw API Password. Only used for the legacy CCP API
NC_CID Customer Number. Only used for the legacy CCP API
NC_Apikey_Legacy Legacy CCP API Key. Only used for domains not manageable via the REST API when NC_Apikey holds a new netcup REST API key. Optional.
Author: linux-insideDE
'
NC_Apikey="${NC_Apikey:-$(_readaccountconf_mutable NC_Apikey)}"
NC_Apipw="${NC_Apipw:-$(_readaccountconf_mutable NC_Apipw)}"
NC_CID="${NC_CID:-$(_readaccountconf_mutable NC_CID)}"
NC_Apikey_Legacy="${NC_Apikey_Legacy:-$(_readaccountconf_mutable NC_Apikey_Legacy)}"
end="https://ccp.netcup.net/run/webservice/servers/endpoint.php?JSON"
_nc_endrest="https://api.netcup.com/v1"
client=""
dns_netcup_add() {
_debug NC_Apikey "$NC_Apikey"
_login
if [ "$NC_Apikey" = "" ] || [ "$NC_Apipw" = "" ] || [ "$NC_CID" = "" ]; then
_err "No Credentials given"
fulldomain=$1
txtvalue=$2
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _nc_check_credentials; then
return 1
fi
_saveaccountconf_mutable NC_Apikey "$NC_Apikey"
_saveaccountconf_mutable NC_Apipw "$NC_Apipw"
_saveaccountconf_mutable NC_CID "$NC_CID"
if [ -n "$NC_Apipw" ]; then
_saveaccountconf_mutable NC_Apipw "$NC_Apipw"
fi
if [ -n "$NC_CID" ]; then
_saveaccountconf_mutable NC_CID "$NC_CID"
fi
if [ -n "$NC_Apikey_Legacy" ]; then
_saveaccountconf_mutable NC_Apikey_Legacy "$NC_Apikey_Legacy"
fi
if _nc_is_rest_key; then
_nc_rest_add "$fulldomain" "$txtvalue"
else
_nc_apikey="$NC_Apikey"
_nc_legacy_add "$fulldomain" "$txtvalue"
fi
}
dns_netcup_rm() {
fulldomain=$1
txtvalue=$2
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _nc_check_credentials; then
return 1
fi
if _nc_is_rest_key; then
_nc_rest_rm "$fulldomain" "$txtvalue"
else
_nc_apikey="$NC_Apikey"
_nc_legacy_rm "$fulldomain" "$txtvalue"
fi
}
#################### New netcup REST API (api.netcup.com) ####################
_nc_rest_add() {
fulldomain=$1
txtvalue=$2
if ! _nc_rest_get_domain "$fulldomain"; then
return 1
fi
_debug _domain_id "$_domain_id"
_debug _dns_managed "$_dns_managed"
if [ "$_dns_managed" = "false" ]; then
_nc_rest_use_legacy "$_domain" || return 1
_nc_legacy_add "$fulldomain" "$txtvalue"
return
fi
if [ "$_dns_managed" != "true" ]; then
_err "Unable to read isDnsManaged for $_domain from the netcup REST API response: $response"
return 1
fi
case "$fulldomain" in
_acme-challenge.*) ;;
acmetestXyzRandomName.*)
# The synthetic record of the DNS-API-Test, which expects add and
# rm to succeed. The REST API can only manage _acme-challenge
# records, so skip it. Real records are never treated as a no-op.
_info "Skipping the DNS-API-Test record $fulldomain, the netcup REST API can only manage _acme-challenge records."
return 0
;;
*)
# e.g. a challenge alias given in the "=" form without the prefix
if [ -n "$NC_Apikey_Legacy" ] && [ -n "$NC_Apipw" ] && [ -n "$NC_CID" ]; then
_debug "The netcup REST API can only create _acme-challenge records, using the legacy CCP API for $fulldomain"
_nc_apikey="$NC_Apikey_Legacy"
_nc_legacy_add "$fulldomain" "$txtvalue"
return
fi
_err "The netcup REST API can only create _acme-challenge records, unable to create $fulldomain."
_err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to manage it via the legacy CCP API."
return 1
;;
esac
_nc_rest_get_scope "$fulldomain" "$_domain"
_debug _scope "$_scope"
if ! _nc_rest POST "domain/$_domain_id/acme/challenge" "{\"scope\": \"$_scope\", \"value\": \"$txtvalue\"}" ||
! _contains "$response" '"success": *true'; then
_err "Unable to add the challenge record: $response"
return 1
fi
# The challenge record is added to the zone right away, but deploying
# the zone to the nameservers happens in the background, so poll until
# the record has actually been deployed (up to about 60 seconds).
_nc_tries=0
while true; do
if _nc_rest GET "domain/$_domain_id/acme/challenge/$_scope/$txtvalue" &&
_contains "$response" '"status": *"deployed"'; then
_info "The challenge record has been deployed"
return 0
fi
_nc_tries=$(_math "$_nc_tries" + 1)
if [ "$_nc_tries" -ge 12 ]; then
break
fi
_debug "The challenge record has not been deployed yet, waiting 5 more seconds"
_sleep 5
done
_info "The challenge record has still not been deployed after 60 seconds, continuing anyway"
return 0
}
_nc_rest_rm() {
fulldomain=$1
txtvalue=$2
if ! _nc_rest_get_domain "$fulldomain"; then
return 1
fi
if [ "$_dns_managed" = "false" ]; then
_nc_rest_use_legacy "$_domain" || return 1
_nc_legacy_rm "$fulldomain" "$txtvalue"
return
fi
if [ "$_dns_managed" != "true" ]; then
_err "Unable to read isDnsManaged for $_domain from the netcup REST API response: $response"
return 1
fi
case "$fulldomain" in
_acme-challenge.*) ;;
acmetestXyzRandomName.*)
# See _nc_rest_add.
_info "Skipping the DNS-API-Test record $fulldomain, the netcup REST API can only manage _acme-challenge records."
return 0
;;
*)
if [ -n "$NC_Apikey_Legacy" ] && [ -n "$NC_Apipw" ] && [ -n "$NC_CID" ]; then
_debug "The netcup REST API can only remove _acme-challenge records, using the legacy CCP API for $fulldomain"
_nc_apikey="$NC_Apikey_Legacy"
_nc_legacy_rm "$fulldomain" "$txtvalue"
return
fi
_err "The netcup REST API can only remove _acme-challenge records, unable to remove $fulldomain."
_err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to manage it via the legacy CCP API."
return 1
;;
esac
_nc_rest_get_scope "$fulldomain" "$_domain"
if ! _nc_rest DELETE "domain/$_domain_id/acme/challenge/$_scope/$txtvalue"; then
_err "Unable to remove the challenge record: $response"
return 1
fi
_nc_status=$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n')
_debug _nc_status "$_nc_status"
case "$_nc_status" in
204)
return 0
;;
404)
_info "The challenge record was not found, nothing to remove"
return 0
;;
*)
_err "Unable to remove the challenge record: $response"
return 1
;;
esac
}
# fulldomain
# Sets _domain_id, _domain and _dns_managed of the domain the record
# belongs to, walking up the name, longest match first. For a challenge
# record the leftmost label is the prefix and can never be a zone, so
# the walk starts one label in. Other names (e.g. a challenge alias in
# the "=" form) may be a zone apex themselves.
_nc_rest_get_domain() {
case "$1" in
_acme-challenge.*) i=2 ;;
*) i=1 ;;
esac
while true; do
h=$(printf "%s" "$1" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
_nc_nozone "$1"
return 1
fi
_debug h "$h"
if ! _nc_rest GET "domain?fqdn=$h"; then
return 1
fi
if _contains "$response" '"success": *true'; then
if _contains "$response" '"fqdn"'; then
# split the response so that first/last match cannot differ
# between the egrep and sed implementations of _egrep_o
_domain_id=$(printf "%s" "$response" | tr '{,' '\n' | _egrep_o '"id": *[0-9][0-9]*' | _head_n 1 | tr -dc '0-9')
_dns_managed=$(printf "%s" "$response" | tr '{,' '\n' | _egrep_o '"isDnsManaged": *[a-z][a-z]*' | _head_n 1 | sed 's/.*: *//')
_domain="$h"
if [ -n "$_domain_id" ]; then
return 0
fi
_err "Unable to parse the domain id from the netcup REST API response: $response"
return 1
fi
# an empty result, $h is not a domain of this account: walk on
elif _contains "$response" '"code": *"resourceDoesNotExist"'; then
# the API reports a domain that is not in this account with
# success:false and this error code: walk on
_debug "$h is not a domain of this account"
else
# e.g. an invalid API key; do not walk on, it would end in a
# misleading "no zone found" error
_err "The netcup REST API request failed: $response"
_err "Note: NC_Apikey was detected as a netcup REST API key because it is 64 characters long."
return 1
fi
i=$(_math "$i" + 1)
done
}
# fulldomain domain
# Sets _scope to the host part of the challenge relative to the domain.
# The REST API prepends _acme-challenge. to the scope itself, so the
# prefix is stripped from the record name (the callers guarantee it is
# present).
_nc_rest_get_scope() {
_scope="${1#_acme-challenge.}"
if [ "$_scope" = "$2" ]; then
_scope="@"
else
_scope="${_scope%".$2"}"
fi
}
# domain
# Selects the legacy credentials for a domain whose DNS cannot be
# managed via the new netcup REST API.
_nc_rest_use_legacy() {
_debug "The DNS of $1 cannot be managed via the REST API, using the legacy CCP API"
if [ -z "$NC_Apikey_Legacy" ] || [ -z "$NC_Apipw" ] || [ -z "$NC_CID" ]; then
_err "The DNS of the domain $1 cannot be managed via the new netcup REST API."
_err "Set NC_Apikey_Legacy, NC_Apipw and NC_CID to your legacy CCP API credentials to manage it."
return 1
fi
_nc_apikey="$NC_Apikey_Legacy"
}
# method endpoint [data]
# The response is returned in the global variable $response.
_nc_rest() {
m=$1
ep=$2
data=$3
_debug2 "REST $m $ep"
export _H1="Authorization: Bearer $NC_Apikey"
# blank the remaining header slots so that auth headers of another
# dns hook cannot ride into the netcup REST API in a multi-provider
# issuance
export _H2=""
export _H3=""
export _H4=""
export _H5=""
if [ "$m" = "GET" ]; then
response=$(_get "$_nc_endrest/$ep")
else
_debug2 data "$data"
response=$(_post "$data" "$_nc_endrest/$ep" "" "$m" "application/json")
fi
_nc_ret="$?"
_debug2 response "$response"
return "$_nc_ret"
}
#################### Legacy CCP API (ccp.netcup.net) ####################
_nc_legacy_add() {
fulldomain=$1
txtvalue=$2
if ! _nc_legacy_login; then
return 1
fi
domain=""
exit=$(echo "$fulldomain" | tr -dc '.' | wc -c)
exit=$(_math "$exit" + 1)
i=$exit
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while
[ "$exit" -gt 0 ]
[ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -44,35 +331,45 @@ dns_netcup_add() {
domain="$tmp.$domain"
fi
if [ "$(_math "$i" - "$exit")" -ge 1 ]; then
msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"false\", \"state\": \"yes\"} ]}}}" "$end" "" "POST")
msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"false\", \"state\": \"yes\"} ]}}}" "$end" "" "POST")
_debug "$msg"
if [ "$(_getfield "$msg" "5" | sed 's/"statuscode"://g')" != 5028 ]; then
if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$msg"
return 1
else
_nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
logout
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
_nc_legacy_logout
}
dns_netcup_rm() {
_login
_nc_legacy_rm() {
fulldomain=$1
txtvalue=$2
if ! _nc_legacy_login; then
return 1
fi
domain=""
exit=$(echo "$fulldomain" | tr -dc '.' | wc -c)
exit=$(_math "$exit" + 1)
i=$exit
rec=""
_nc_last=$(_nc_lastlevel "$i")
_nc_found=""
while
[ "$exit" -gt 0 ]
[ "$exit" -ge "$_nc_last" ]
do
tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit")
if [ "$(_math "$i" - "$exit")" -eq 0 ]; then
@@ -81,7 +378,7 @@ dns_netcup_rm() {
domain="$tmp.$domain"
fi
if [ "$(_math "$i" - "$exit")" -ge 1 ]; then
msg=$(_post "{\"action\": \"infoDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\", \"domainname\": \"$domain\"}}" "$end" "" "POST")
msg=$(_post "{\"action\": \"infoDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\", \"domainname\": \"$domain\"}}" "$end" "" "POST")
rec=$(echo "$msg" | sed 's/\[//g' | sed 's/\]//g' | sed 's/{\"serverrequestid\".*\"dnsrecords\"://g' | sed 's/},{/};{/g' | sed 's/{//g' | sed 's/}//g')
_debug "$msg"
if [ "$(_getfield "$msg" "5" | sed 's/"statuscode"://g')" != 5028 ]; then
@@ -89,12 +386,18 @@ dns_netcup_rm() {
_err "$msg"
return 1
else
_nc_found=1
break
fi
fi
fi
exit=$(_math "$exit" - 1)
done
if [ -z "$_nc_found" ]; then
_err "$msg"
_nc_nozone "$fulldomain"
return 1
fi
ida=0000
idv=0001
@@ -116,17 +419,24 @@ dns_netcup_rm() {
i=0
fi
done
msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"$ids\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"TRUE\", \"state\": \"yes\"} ]}}}" "$end" "" "POST")
msg=$(_post "{\"action\": \"updateDnsRecords\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\",\"clientrequestid\": \"$client\" , \"domainname\": \"$domain\", \"dnsrecordset\": { \"dnsrecords\": [ {\"id\": \"$ids\", \"hostname\": \"$fulldomain.\", \"type\": \"TXT\", \"priority\": \"\", \"destination\": \"$txtvalue\", \"deleterecord\": \"TRUE\", \"state\": \"yes\"} ]}}}" "$end" "" "POST")
_debug "$msg"
if [ "$(_getfield "$msg" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$msg"
return 1
fi
logout
_nc_legacy_logout
}
_login() {
tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
_nc_legacy_login() {
# never send the REST API Bearer header (or auth headers of another
# dns hook) to the legacy CCP API
export _H1=""
export _H2=""
export _H3=""
export _H4=""
export _H5=""
tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4)
_debug "$tmp"
if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
@@ -134,11 +444,56 @@ _login() {
return 1
fi
}
logout() {
tmp=$(_post "{\"action\": \"logout\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
_nc_legacy_logout() {
tmp=$(_post "{\"action\": \"logout\", \"param\": {\"apikey\": \"$_nc_apikey\", \"apisessionid\": \"$sid\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
_debug "$tmp"
if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$tmp"
return 1
fi
}
#################### Shared helpers ####################
_nc_check_credentials() {
if [ -z "$NC_Apikey" ]; then
_err "No Credentials given"
_err "Set NC_Apikey to your netcup REST API key (64 characters) or your legacy CCP API key."
return 1
fi
if ! _nc_is_rest_key; then
if [ -z "$NC_Apipw" ] || [ -z "$NC_CID" ]; then
_err "No Credentials given"
_err "The legacy CCP API needs NC_Apikey, NC_Apipw and NC_CID."
return 1
fi
fi
}
# New netcup REST API keys are 64 characters long, legacy CCP API keys
# are 50, so the key length selects the API.
_nc_is_rest_key() {
[ "${#NC_Apikey}" -eq 64 ]
}
# The legacy zone lookup walks 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 credentials and that its DNS is hosted at netcup."
}
+244
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
}
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_opteamax_info='Opteamax.de
Site: opteamax.de
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_opteamax
Options:
OPTEAMAX_Token API token. Create it in the customer panel under "API-Tokens"; it starts with "oxt_".
OPTEAMAX_Api API endpoint. Default "https://api.opteam.ax/api/v2". Optional.
Issues: github.com/acmesh-official/acme.sh/issues/7245
Author: Jens Ott <jo@opteamax.de>
'
OPTEAMAX_Api_Default="https://api.opteam.ax/api/v2"
######## Public functions #####################
#Usage: dns_opteamax_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_opteamax_add() {
fulldomain=$1
txtvalue=$2
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _opteamax_init; then
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
return 1
fi
_debug _domain "$_domain"
_debug _domain_id "$_domain_id"
_info "Adding the TXT record"
_opteamax_body="{\"type\":\"TXT\",\"name\":\"$fulldomain.\",\"content\":\"$txtvalue\",\"ttl\":300}"
if ! _opteamax_rest POST "/dns/domains/$_domain_id/records/" "$_opteamax_body"; then
return 1
fi
if ! _contains "$response" "data_id"; then
_err "Could not add the TXT record: $response"
return 1
fi
_info "TXT record added"
return 0
}
#Usage: dns_opteamax_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_opteamax_rm() {
fulldomain=$1
txtvalue=$2
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _opteamax_init; then
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
return 1
fi
_debug _domain "$_domain"
_debug _domain_id "$_domain_id"
_debug "Getting the record id"
if ! _opteamax_rest GET "/dns/domains/$_domain_id/records/"; then
return 1
fi
# Only this challenge's record may go: a wildcard certificate puts two TXT
# values on the same name, and acme.sh removes them one call at a time.
# PowerDNS hands TXT content back in wire format, so the value arrives inside
# escaped quotes ("content": "\"<value>\""); matching from the field name up
# to the next field keeps the value pinned to the content field without
# spelling out those backslashes. _head_n 1 guarantees a single id even if an
# aborted earlier run left the same value behind twice.
_record_id=$(
echo "$response" | _opteamax_split |
grep '"type": *"TXT"' |
grep "\"name\": *\"$(_opteamax_re "$fulldomain")\.\"" |
grep "\"content\":[^,]*$txtvalue" |
_egrep_o '"data_id": *"[^"]*"' |
sed 's/.*"data_id": *"//;s/"$//' |
_head_n 1
)
_debug _record_id "$_record_id"
if [ -z "$_record_id" ]; then
_info "No such TXT record, nothing to remove."
return 0
fi
_info "Removing the TXT record"
if ! _opteamax_rest DELETE "/dns/domains/$_domain_id/records/$_record_id/"; then
return 1
fi
_info "TXT record removed"
return 0
}
#################### Private functions below ##################################
# Read and check the credentials, and remember them for the renewal.
_opteamax_init() {
OPTEAMAX_Token="${OPTEAMAX_Token:-$(_readaccountconf_mutable OPTEAMAX_Token)}"
OPTEAMAX_Api="${OPTEAMAX_Api:-$(_readaccountconf_mutable OPTEAMAX_Api)}"
if [ -z "$OPTEAMAX_Token" ]; then
_err "You have not set OPTEAMAX_Token yet."
_err "Create an API token in the customer panel under \"API-Tokens\" and export it:"
_err "export OPTEAMAX_Token=\"oxt_...\""
return 1
fi
if [ -z "$OPTEAMAX_Api" ]; then
OPTEAMAX_Api="$OPTEAMAX_Api_Default"
else
# A trailing slash would make every request path a double slash, which
# Django answers with a redirect that drops the request body.
OPTEAMAX_Api=$(echo "$OPTEAMAX_Api" | sed 's|/*$||')
_saveaccountconf_mutable OPTEAMAX_Api "$OPTEAMAX_Api"
fi
_saveaccountconf_mutable OPTEAMAX_Token "$OPTEAMAX_Token"
return 0
}
#_acme-challenge.www.domain.com
#returns
# _domain=domain.com
# _domain_id=1234
_get_root() {
domain=$1
# One request for the account's zones, then the name is walked up against
# them locally: the longest match wins, so a delegated subzone beats its
# parent.
if ! _opteamax_rest GET "/dns/domains/"; then
return 1
fi
_zones=$(echo "$response" | _opteamax_split)
i=1
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
break
fi
_domain_id=$(
echo "$_zones" |
grep "\"domain\": *\"$(_opteamax_re "$h")\.\{0,1\}\"" |
_egrep_o '"domain_id": *[0-9]*' |
tr -d ' ' | cut -d : -f 2 | _head_n 1
)
if [ "$_domain_id" ]; then
_domain="$h"
return 0
fi
i=$(_math "$i" + 1)
done
# Only reached when the walk ran out of labels -- a failed request returns
# above, so this really does mean the account holds no zone for the name.
_err "Could not find a zone for $domain in your Opteamax account."
return 1
}
# Put one JSON object per line so a record's id can be read off the same line
# as its name and content.
_opteamax_split() {
sed 's/}, *{/}#{/g' | tr '#' '\n'
}
# Escape a domain name for use in a grep pattern: the dots are literal.
_opteamax_re() {
echo "$1" | sed 's/\./\\./g'
}
# method endpoint [body]
_opteamax_rest() {
m="$1"
ep="$2"
data="$3"
_debug "$ep"
export _H1="Authorization: Bearer $OPTEAMAX_Token"
export _H2="Content-Type: application/json"
export _H3="Accept: application/json"
if [ "$m" = "GET" ]; then
response="$(_get "$OPTEAMAX_Api$ep")"
else
_debug data "$data"
response="$(_post "$data" "$OPTEAMAX_Api$ep" "" "$m")"
fi
if [ "$?" != "0" ]; then
_err "Error talking to $OPTEAMAX_Api$ep"
return 1
fi
_debug2 response "$response"
# A proxy or gateway error comes back as an HTML page with a 5xx status, and
# the http helpers only report transport failures -- so without this check the
# caller parses an error page as data and reports something misleading, such
# as the zone not existing.
case "$response" in
"{"* | "["*) ;;
*)
_err "Unexpected response from $OPTEAMAX_Api$ep (not JSON):"
_err "$(echo "$response" | _head_n 3)"
return 1
;;
esac
# The API answers an authentication or permission problem with a JSON body
# and a 4xx status; the http helpers only report transport errors, so the
# body is what tells us the call was refused.
if _contains "$response" '"detail"'; then
_err "The API refused the request: $response"
return 1
fi
return 0
}
+444
View File
@@ -0,0 +1,444 @@
#!/usr/bin/env sh
# shellcheck disable=SC2034
dns_optidata_info='Optidata Cloud
Site: console.optidata.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_optidata
Options:
OPTIDATA_Token DNS API key. Create a key of kind DNS in the Optidata Console (API Keys); it starts with "ocs_".
OPTIDATA_Api API base URL. Default "https://console.optidata.com".
OPTIDATA_Location Location code or UUID of the zone. Only needed when the zone lives outside the account default location and the lookup cannot tell. Optional.
OPTIDATA_Zone_ID Zone ID. Pins the zone and skips the zone lookup. Optional.
Issues: github.com/acmesh-official/acme.sh/issues/7241
Author: Eduardo Langner <https://github.com/optidatacloud>
'
# Port of dnsapi/dns_cf.sh (CloudFlare) to the Optidata Cloud DNS API served by
# ocs-backend. Routes used, all under $OPTIDATA_Api/api/v1 and authenticated
# with the x-api-key header:
#
# GET dns-zones?name=<record fqdn> resolve the zone containing the name
# GET dns-zones/<zone_id> read one zone (OPTIDATA_Zone_ID)
# POST dns-zones/<zone_id>/recordsets create / upsert the TXT record set
# DELETE dns-zones/<zone_id>/recordsets?name&type&value remove one TXT value
#
# Every response is wrapped in {"success":true,"data":...}; errors are flat
# {"status_code":<n>,"message":"...","error":"..."}.
OPTIDATA_DEFAULT_API="https://console.optidata.com"
OPTIDATA_TTL=120
OPTIDATA_MAX_ATTEMPTS=3
######## Public functions #####################
#Usage: dns_optidata_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_optidata_add() {
fulldomain=$1
txtvalue=$2
_info "Using Optidata Cloud DNS API"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _optidata_load_config; then
return 1
fi
_optidata_save_config
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_info "Adding TXT record $fulldomain"
# A record set is unique per (name, type) and the apex and wildcard
# challenges share the same name, so the second value has to be merged into
# the existing set: that is what upsert does. require_active_zone makes the
# API fail now (409) instead of queueing a record that would only be
# published after delegation, long after the ACME server gave up.
_body="{\"type\":\"TXT\",\"name\":\"$(_optidata_json_escape "$fulldomain")\",\"records\":[\"$(_optidata_json_escape "$txtvalue")\"],\"ttl\":$OPTIDATA_TTL,\"upsert\":true,\"require_active_zone\":true}"
if _optidata_rest POST "dns-zones/$_domain_id/recordsets$(_optidata_query "")" "$_body"; then
# The API echoes the whole record set back. The value is base64url
# ([A-Za-z0-9_-]), so it needs no JSON escaping and a case pattern matches
# it without depending on a grep that supports -F (Solaris grep does not).
case "$response" in
*"$txtvalue"*)
_info "Added, OK"
return 0
;;
esac
_err "The API accepted the record but the value is missing from the record set: $response"
return 1
fi
_optidata_report_error "Add txt record error."
return 1
}
#Usage: dns_optidata_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_optidata_rm() {
fulldomain=$1
txtvalue=$2
_info "Using Optidata Cloud DNS API"
_debug fulldomain "$fulldomain"
_debug txtvalue "$txtvalue"
if ! _optidata_load_config; then
return 1
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
return 1
fi
_debug _domain_id "$_domain_id"
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_info "Removing TXT record $fulldomain"
# Deleting by value keeps the other challenge value (wildcard + apex) in
# place; the API drops the whole record set once its last value goes away.
_q="name=$(printf "%s" "$fulldomain" | _url_encode)&type=TXT&value=$(printf "%s" "$txtvalue" | _url_encode)"
if _optidata_rest DELETE "dns-zones/$_domain_id/recordsets$(_optidata_query "$_q")"; then
if printf "%s\n" "$response" | tr -d " " | grep '"deleted":true' >/dev/null; then
_info "Removed, OK"
else
_info "Record value not found, nothing to remove."
fi
return 0
fi
_optidata_report_error "Delete txt record error."
return 1
}
#################### Private functions below ##################################
# Reads the settings from the environment or from the saved acme.sh config and
# validates them. Shared by add and rm, which run in separate subshells.
_optidata_load_config() {
OPTIDATA_Token="${OPTIDATA_Token:-$(_readdomainconf OPTIDATA_Token)}"
OPTIDATA_Token="${OPTIDATA_Token:-$(_readaccountconf_mutable OPTIDATA_Token)}"
OPTIDATA_Api="${OPTIDATA_Api:-$(_readaccountconf_mutable OPTIDATA_Api)}"
OPTIDATA_Location="${OPTIDATA_Location:-$(_readdomainconf OPTIDATA_Location)}"
OPTIDATA_Location="${OPTIDATA_Location:-$(_readaccountconf_mutable OPTIDATA_Location)}"
OPTIDATA_Zone_ID="${OPTIDATA_Zone_ID:-$(_readdomainconf OPTIDATA_Zone_ID)}"
OPTIDATA_Zone_ID="${OPTIDATA_Zone_ID:-$(_readaccountconf_mutable OPTIDATA_Zone_ID)}"
# Keys are pasted with quotes or blanks often enough to be worth cleaning.
OPTIDATA_Token="$(printf "%s" "$OPTIDATA_Token" | tr -d '" ')"
if [ -z "$OPTIDATA_Token" ]; then
OPTIDATA_Token=""
_err "You did not specify OPTIDATA_Token yet."
_err "Create a DNS API key in the Optidata Console (API Keys) and export it:"
_err "export OPTIDATA_Token=ocs_xxxxxxxxxxxxxxxx"
return 1
fi
if ! _startswith "$OPTIDATA_Token" "ocs_"; then
OPTIDATA_Token=""
_err 'OPTIDATA_Token must be an Optidata API key: it starts with "ocs_". Did you copy the entire key?'
return 1
fi
OPTIDATA_Api="${OPTIDATA_Api:-$OPTIDATA_DEFAULT_API}"
OPTIDATA_Api="$(printf "%s\n" "$OPTIDATA_Api" | sed 's:/*$::')"
case "$OPTIDATA_Api" in
http://* | https://*) ;;
*)
_err "OPTIDATA_Api must be an http(s) URL, e.g. $OPTIDATA_DEFAULT_API"
return 1
;;
esac
_debug OPTIDATA_Api "$OPTIDATA_Api"
_debug OPTIDATA_Location "$OPTIDATA_Location"
_debug OPTIDATA_Zone_ID "$OPTIDATA_Zone_ID"
return 0
}
# Persists the settings so renewals work without the environment, following
# dns_cf.sh: with a pinned zone the key lives in the domain config (so a
# zone-restricted key can be used per certificate), otherwise in the account
# config.
_optidata_save_config() {
if [ "$OPTIDATA_Zone_ID" ]; then
_savedomainconf OPTIDATA_Token "$OPTIDATA_Token"
_savedomainconf OPTIDATA_Zone_ID "$OPTIDATA_Zone_ID"
if [ "$OPTIDATA_Location" ]; then
_savedomainconf OPTIDATA_Location "$OPTIDATA_Location"
else
_cleardomainconf OPTIDATA_Location
fi
else
_saveaccountconf_mutable OPTIDATA_Token "$OPTIDATA_Token"
if [ "$OPTIDATA_Location" ]; then
_saveaccountconf_mutable OPTIDATA_Location "$OPTIDATA_Location"
else
_clearaccountconf_mutable OPTIDATA_Location
fi
_clearaccountconf_mutable OPTIDATA_Zone_ID
_clearaccountconf OPTIDATA_Zone_ID
fi
if [ "$OPTIDATA_Api" != "$OPTIDATA_DEFAULT_API" ]; then
_saveaccountconf_mutable OPTIDATA_Api "$OPTIDATA_Api"
else
_clearaccountconf_mutable OPTIDATA_Api
fi
}
#_acme-challenge.www.domain.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=domain.com
# _domain_id=a86dba58-0043-4cc6-a1bb-69d5e86f3ca3
# _zone_location=3f2b46f2-4f14-44e2-8e21-1b6c17f2a9d1 (empty when the API does not report one)
_get_root() {
domain=$1
_domain_lc="$(printf "%s\n" "$domain" | _lower_case | sed 's/\.$//')"
_domain=""
_domain_id=""
_sub_domain=""
_zone_location=""
_zone_status=""
if [ "$OPTIDATA_Zone_ID" ]; then
_debug "Using the pinned zone" "$OPTIDATA_Zone_ID"
if ! _optidata_rest GET "dns-zones/$OPTIDATA_Zone_ID$(_optidata_query "")"; then
_optidata_report_error "Can not read zone $OPTIDATA_Zone_ID."
return 1
fi
_zone_json="$response"
else
# The API returns every zone that contains the name, most specific first.
if ! _optidata_rest GET "dns-zones?name=$(printf "%s" "$_domain_lc" | _url_encode)"; then
_optidata_report_error "Zone lookup for $domain failed."
return 1
fi
if printf "%s\n" "$response" | tr -d " " | grep '"data":\[\]' >/dev/null; then
_err "No Optidata DNS zone contains $domain."
_err "Check that the zone exists in this account and that the API key is allowed to access it."
return 1
fi
# Pick the longest zone that is a suffix of the name ourselves as well, so
# an API that ignores the name filter still resolves the right zone.
_zone_json="$(_optidata_pick_zone "$response" "$_domain_lc")"
if [ -z "$_zone_json" ]; then
_err "No Optidata DNS zone contains $domain: $response"
return 1
fi
fi
_domain_id="$(_optidata_json_string "$_zone_json" id)"
_domain="$(_optidata_json_string "$_zone_json" zone_name)"
if [ -z "$_domain" ]; then
_domain="$(_optidata_json_string "$_zone_json" name)"
fi
_domain="$(printf "%s\n" "$_domain" | _lower_case | sed 's/\.$//')"
_zone_location="$(_optidata_json_string "$_zone_json" location)"
_zone_status="$(_optidata_json_string "$_zone_json" status)"
_debug _zone_location "$_zone_location"
_debug _zone_status "$_zone_status"
if [ -z "$_domain_id" ] || [ -z "$_domain" ]; then
_err "Could not read the zone id and name from the API response: $response"
return 1
fi
if [ "$_domain_lc" = "$_domain" ]; then
_sub_domain=""
else
case "$_domain_lc" in
*".$_domain")
_sub_domain="${_domain_lc%".$_domain"}"
;;
*)
_err "Zone $_domain ($_domain_id) does not contain $domain."
return 1
;;
esac
fi
if [ "$_zone_status" ] && [ "$_zone_status" != "ACTIVE" ]; then
_info "Zone $_domain has status $_zone_status; records are only published once the zone is ACTIVE (delegated to the Optidata name servers)."
fi
return 0
}
# Usage: _optidata_pick_zone '<list response>' '<lowercase fqdn>'
# Prints the JSON of the zone with the longest name that is the fqdn itself or
# one of its parents. Zones are flat objects, so splitting on "},{" is safe.
_optidata_pick_zone() {
_pz_json="$1"
_pz_name="$2"
_pz_objects="$(printf "%s\n" "$_pz_json" | sed 's/}, *{/}\
{/g')"
_pz_count="$(printf "%s\n" "$_pz_objects" | wc -l | tr -d " ")"
_pz_best=""
_pz_best_len=0
_pz_i=1
while [ "$_pz_i" -le "$_pz_count" ]; do
_pz_obj="$(printf "%s\n" "$_pz_objects" | sed -n "${_pz_i}p")"
_pz_zone="$(_optidata_json_string "$_pz_obj" zone_name)"
if [ -z "$_pz_zone" ]; then
_pz_zone="$(_optidata_json_string "$_pz_obj" name)"
fi
_pz_zone="$(printf "%s\n" "$_pz_zone" | _lower_case | sed 's/\.$//')"
if [ "$_pz_zone" ]; then
case "$_pz_name" in
"$_pz_zone" | *".$_pz_zone")
if [ "${#_pz_zone}" -gt "$_pz_best_len" ]; then
_pz_best="$_pz_obj"
_pz_best_len="${#_pz_zone}"
fi
;;
esac
fi
_pz_i=$(_math "$_pz_i" + 1)
done
printf "%s" "$_pz_best"
}
# Usage: _optidata_json_string '<json>' key
# Prints the string value of the first "key" in the JSON, nothing when the key
# is absent or not a string (e.g. "location":null).
_optidata_json_string() {
printf "%s\n" "$1" | _egrep_o "\"$2\": *\"[^\"]*\"" | _head_n 1 | sed 's/^"[^"]*": *"//; s/"$//'
}
# Escapes a value for use inside a JSON string literal.
_optidata_json_escape() {
printf "%s\n" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
# Usage: _optidata_query '<query without the leading ?>'
# Appends the location (explicit OPTIDATA_Location, else the one reported by
# the zone lookup) and prints the query string with its leading "?", or
# nothing when there is nothing to send.
_optidata_query() {
_oq="$1"
_oq_loc="${OPTIDATA_Location:-$_zone_location}"
if [ "$_oq_loc" ]; then
if [ "$_oq" ]; then
_oq="$_oq&location=$(printf "%s" "$_oq_loc" | _url_encode)"
else
_oq="location=$(printf "%s" "$_oq_loc" | _url_encode)"
fi
fi
if [ "$_oq" ]; then
printf "?%s" "$_oq"
fi
}
# Usage: _optidata_rest METHOD 'endpoint under /api/v1' [json body]
# Sets $response to the normalized JSON body. Returns 0 on a success envelope;
# otherwise sets $_optidata_status / $_optidata_message and returns 1. Rate
# limits and upstream hiccups (429, 502-504) are retried a few times.
_optidata_rest() {
_m=$1
_ep=$2
_data=$3
_debug "$_m $_ep"
# Hooks share one shell and _get/_post always send _H1 to _H5, so the unused
# slots have to be cleared: otherwise a previous provider's header (an auth
# header, for instance) is sent to the Optidata endpoint.
export _H1="Accept: application/json"
export _H2="Content-Type: application/json"
export _H3="x-api-key: $OPTIDATA_Token"
export _H4=""
export _H5=""
_url="$OPTIDATA_Api/api/v1/$_ep"
_optidata_status=""
_optidata_message=""
_attempt=1
while true; do
# A failed request leaves the previous status line in the header file, which
# would then be read as this request's response code.
if [ -z "$HTTP_HEADER" ]; then
_err "HTTP header file is not initialized"
return 1
fi
: >"$HTTP_HEADER" || return 1
if [ "$_m" = "GET" ]; then
response="$(_get "$_url")"
else
_debug2 data "$_data"
response="$(_post "$_data" "$_url" "" "$_m")"
fi
_ret="$?"
if [ "$_ret" != "0" ]; then
_err "Request to $_url failed. Is OPTIDATA_Api correct and reachable?"
return 1
fi
_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n')"
_debug "http response code" "$_code"
response="$(printf "%s\n" "$response" | _normalizeJson)"
_debug2 response "$response"
if printf "%s\n" "$response" | tr -d " " | grep '"success":true' >/dev/null; then
return 0
fi
_optidata_status="$(printf "%s\n" "$response" | _egrep_o '"status_code": *[0-9]*' | _head_n 1 | cut -d : -f 2 | tr -d " ")"
if [ -z "$_optidata_status" ]; then
_optidata_status="$_code"
fi
_optidata_message="$(_optidata_json_string "$response" message)"
if [ -z "$_optidata_message" ]; then
# Validation errors carry an array of messages.
_optidata_message="$(printf "%s\n" "$response" | _egrep_o '"message": *\[[^]]*\]' | _head_n 1 | sed 's/^"message": *\[//; s/\]$//' | tr -d '"')"
fi
case "$_optidata_status" in
429 | 502 | 503 | 504)
if [ "$_attempt" -lt "$OPTIDATA_MAX_ATTEMPTS" ]; then
_wait="$(grep -i "^Retry-After:" "$HTTP_HEADER" | _tail_n 1 | cut -d : -f 2 | tr -d ' \r\n')"
case "$_wait" in
'' | *[!0-9]*) _wait=5 ;;
esac
if [ "$_wait" -gt 60 ]; then
_wait=60
fi
_info "Optidata API answered HTTP $_optidata_status; retrying in ${_wait}s (attempt $_attempt of $OPTIDATA_MAX_ATTEMPTS)."
_sleep "$_wait"
_attempt=$(_math "$_attempt" + 1)
continue
fi
;;
esac
return 1
done
}
# Usage: _optidata_report_error 'what failed'
# Logs the API error captured by _optidata_rest plus a hint for the usual causes.
_optidata_report_error() {
_err "$1"
if [ "$_optidata_message" ]; then
_err "Optidata API answered HTTP ${_optidata_status:-?}: $_optidata_message"
elif [ "$_optidata_status" ]; then
_err "Optidata API answered HTTP $_optidata_status: $response"
fi
case "$_optidata_status" in
401)
_err "Check OPTIDATA_Token: it must be a valid, enabled Optidata API key (it starts with ocs_). Did you copy the entire key?"
;;
402)
_err "The account is blocked for billing reasons. Check the payment method in the Optidata Console."
;;
403)
_err "The key must be a DNS API key with the dns_zones scope, the permissions zones_read, records_create, records_update and records_delete, and access to this zone."
;;
404)
_err "The zone was not found. If it lives outside the account default location, set OPTIDATA_Location to its location code or UUID."
;;
409)
_err "The zone is not delegated to the Optidata name servers yet. Point the domain NS records to them and retry once the zone status is ACTIVE."
;;
429)
_err "The Optidata API rate limit was reached. Retry in a minute."
;;
esac
}
+1 -1
View File
@@ -227,7 +227,7 @@ _poweradmin_rest() {
return 1
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"
_debug "Response: $response"
return 1
+1 -1
View File
@@ -71,7 +71,7 @@ dns_rage4_rm() {
_debug "Getting txt records"
_rage4_rest "getrecords/?id=${_domain_id}"
_record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p')
_record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -n 's/.*"id":\([0-9][0-9]*\),.*/\1/p')
if [ -z "$_record_id" ]; then
_err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'."
return 1
+145
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
}
+1 -1
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}\"}}}}}"
export _H1="Content-Type: application/json"
_result=$(_post "$_data_auth" "$auth_uri")
_token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | sed -nE "s/[[:space:]]*x-subject-token:[[:space:]]*([[:print:]]*)(\r*)/\1/p")
_token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | cut -d ':' -f 2- | tr -d ' \t\r')
_dt_curr=$(date +%s)
SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}"
_saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2"
+5 -2
View File
@@ -42,7 +42,10 @@ dns_selfhost_add() {
# only match full domains (at the beginning of the string or with a leading whitespace),
# e.g. don't match mytest.example.com or sub.test.example.com for test.example.com
# if the domain is defined multiple times only the last occurance will be matched
mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain)(:[[:digit:]]+)([:]?[[:digit:]]*)(.*)/\2\3\4/p")
# prepend a space to each line so "start of line" and "after whitespace"
# can both be matched as "after a space/tab" (portable BRE, no ERE (^|..))
_selfhost_tab="$(printf '\t')"
mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p")
_debug2 mapEntry "$mapEntry"
if test -z "$mapEntry"; then
_err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID"
@@ -54,7 +57,7 @@ dns_selfhost_add() {
rid2=$(echo "$mapEntry" | cut -d: -f3)
# read last used rid domain
lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain:[[:digit:]]+)(.*)/\2/p")
lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p")
_debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry"
lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2)
+2 -2
View File
@@ -145,8 +145,8 @@ _udr_rest() {
_debug data "${data}"
response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")"
_code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
_code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | tr -d ' \t\r')
_description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | tr -d '\r' | sed -e 's/^[ ]*//' -e 's/[ ]*$//')
_debug response_code "$_code"
_debug response_description "$_description"
+13 -2
View File
@@ -61,7 +61,7 @@ dns_world4you_add() {
if _contains "$res" "successfully"; then
return 0
else
msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g')
msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then
_err "Unable to add record: Unknown error"
echo "$ret" >'error-01.html'
@@ -125,7 +125,7 @@ dns_world4you_rm() {
if _contains "$res" "successfully"; then
return 0
else
msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g')
msg=$(_w4y_alert_msg "$res")
if [ "$msg" = '' ]; then
_err "Unable to remove record: Unknown error"
echo "$ret" >'error-01.html'
@@ -145,6 +145,17 @@ dns_world4you_rm() {
################ Private functions ################
# Usage: _w4y_alert_msg <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
_login() {
WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}"
+2 -2
View File
@@ -149,7 +149,7 @@ _check_variables() {
org_response="$(echo "$org_response" | _normalizeJson)"
YANDEX360_ORG_ID=$(
echo "$org_response" |
_egrep_o '"id":[[:space:]]*[0-9]+' |
_egrep_o '"id":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID"
@@ -216,7 +216,7 @@ _get_token() {
interval=$(
echo "$response" |
_egrep_o '"interval":[[:space:]]*[0-9]+' |
_egrep_o '"interval":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Polling interval' "$interval"
+51 -15
View File
@@ -22,21 +22,32 @@ dns_yc_add() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2
# YC_SA_Key_File_PEM_b64/Path are always persisted to the domain conf below,
# so they must be recovered from there first (account conf is only a
# fallback for the YC_Folder_ID case, see the SA_ID/SA_Key_ID save below).
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
if [ "$YC_SA_Key_File_PEM_b64" ]; then
echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
YC_SA_Key_File="private.key"
_yc_key_is_temp=1
_savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64"
else
YC_SA_Key_File="$YC_SA_Key_File_Path"
_yc_key_is_temp=""
_savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path"
fi
YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then
@@ -65,11 +76,21 @@ dns_yc_add() {
return 1
fi
else
# Clear both possible stores -- YC_Zone_ID/YC_Folder_ID/key material are
# persisted to the domain conf, while YC_SA_ID/YC_SA_Key_ID may have been
# saved account-wide (Folder_ID mode), so a plain _clearaccountconf alone
# would leave stale values behind in whichever store wasn't touched.
_cleardomainconf YC_Zone_ID
_clearaccountconf YC_Zone_ID
_cleardomainconf YC_Folder_ID
_clearaccountconf YC_Folder_ID
_clearaccountconf YC_SA_ID
_clearaccountconf YC_SA_Key_ID
_cleardomainconf YC_SA_ID
_clearaccountconf_mutable YC_SA_ID
_cleardomainconf YC_SA_Key_ID
_clearaccountconf_mutable YC_SA_Key_ID
_cleardomainconf YC_SA_Key_File_PEM_b64
_clearaccountconf YC_SA_Key_File_PEM_b64
_cleardomainconf YC_SA_Key_File_Path
_clearaccountconf YC_SA_Key_File_Path
_err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File."
return 1
@@ -110,11 +131,30 @@ dns_yc_rm() {
fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name
txtvalue=$2
YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}"
YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}"
YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}"
YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}"
YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}"
# See dns_yc_add() for why domain conf is checked before account conf.
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}"
YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}"
if [ "$YC_SA_Key_File_PEM_b64" ]; then
echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key
YC_SA_Key_File="private.key"
_yc_key_is_temp=1
else
YC_SA_Key_File="$YC_SA_Key_File_Path"
_yc_key_is_temp=""
fi
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
@@ -124,16 +164,10 @@ dns_yc_rm() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
_debug "Getting txt records"
if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then
exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:]*$")
_debug exists_txtvalue "$exists_txtvalue"
else
_err "Error: $response"
return 1
fi
if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then
# upsertRecordSets.deletions removes only the given value from the rrset,
# leaving any other values at the same name (e.g. base + wildcard domain)
# intact -- no need to read the current data set and recompute it.
if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then
if _contains "$response" "\"done\": true"; then
_info "Delete, OK"
return 0
@@ -194,7 +228,7 @@ _get_root() {
return 1
fi
if _contains "$response" "\"zone\": \"$h\""; then
_domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')
_domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')
_debug _domain_id "$_domain_id"
if [ "$_domain_id" ]; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
@@ -255,7 +289,9 @@ _yc_login() {
_signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace)
_debug2 _signature "$_signature"
rm -rf "$YC_SA_Key_File"
if [ "$_yc_key_is_temp" ]; then
rm -f "$YC_SA_Key_File"
fi
_jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature")
_debug2 _jwt "$_jwt"
@@ -264,7 +300,7 @@ _yc_login() {
_iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")"
_debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)"
YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')"
YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')"
_debug3 YC_Token
return 0
+1 -2
View File
@@ -57,9 +57,8 @@ waha_send() {
_debug "_data" "$_data"
export _H1="Content-Type: application/json"
if [ "$WAHA_API_KEY" ]; then
export _H2="X-Api-Key: $WAHA_API_KEY"
export _H1="X-Api-Key: $WAHA_API_KEY"
fi
_waha_url="${WAHA_URL}/api/sendText"