Merge pull request #7270 from acmesh-official/dev

sync
This commit is contained in:
neil
2026-09-19 15:49:12 +02:00
committed by GitHub
Unverified
13 changed files with 2218 additions and 76 deletions
+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
+15 -3
View File
@@ -125,6 +125,18 @@ jobs:
exit 1
fi
echo "dispatching a rebuild of ${tag}"
# fails with 422 when the tag's workflow file has no workflow_dispatch
# trigger (releases before this job existed); nothing to do then
gh workflow run dockerhub.yml --repo "${GITHUB_REPOSITORY}" --ref "${tag}"
# 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
+91 -4
View File
@@ -1982,6 +1982,63 @@ _ssldate2time() {
return 1
}
#support the IMF-fixdate form of an HTTP-date, the one a Retry-After header
#carries; it is always GMT:
# Sun, 06 Nov 1994 08:49:37 GMT to 784111777
#Computed in shell arithmetic rather than through date(1): GNU, BSD and
#busybox date each want a different invocation for this form, and %a/%b are
#locale lookups. The day count is the civil-to-days formula, exact for every
#Gregorian date from 1970 on. Prints nothing and fails on any other input.
_httpdate2time() {
_hdt="$1"
case "$_hdt" in
[A-Za-z][A-Za-z][A-Za-z]", "[0-9][0-9]" "[A-Za-z][A-Za-z][A-Za-z]" "[0-9][0-9][0-9][0-9]" "[0-9][0-9]:[0-9][0-9]:[0-9][0-9]" GMT") ;;
*)
return 1
;;
esac
#the shell reads a leading zero as octal, so strip it before any arithmetic
_hdt_d="$(echo "$_hdt" | cut -d ' ' -f 2 | sed 's/^0*\([0-9]\)/\1/')"
_hdt_y="$(echo "$_hdt" | cut -d ' ' -f 4)"
_hdt_tm="$(echo "$_hdt" | cut -d ' ' -f 5)"
_hdt_H="$(echo "$_hdt_tm" | cut -d : -f 1 | sed 's/^0*\([0-9]\)/\1/')"
_hdt_M="$(echo "$_hdt_tm" | cut -d : -f 2 | sed 's/^0*\([0-9]\)/\1/')"
_hdt_S="$(echo "$_hdt_tm" | cut -d : -f 3 | sed 's/^0*\([0-9]\)/\1/')"
case "$(echo "$_hdt" | cut -d ' ' -f 3 | _lower_case)" in
jan) _hdt_m=1 ;;
feb) _hdt_m=2 ;;
mar) _hdt_m=3 ;;
apr) _hdt_m=4 ;;
may) _hdt_m=5 ;;
jun) _hdt_m=6 ;;
jul) _hdt_m=7 ;;
aug) _hdt_m=8 ;;
sep) _hdt_m=9 ;;
oct) _hdt_m=10 ;;
nov) _hdt_m=11 ;;
dec) _hdt_m=12 ;;
*)
return 1
;;
esac
if [ "$_hdt_y" -lt 1970 ] || [ "$_hdt_d" -lt 1 ] || [ "$_hdt_d" -gt 31 ] || [ "$_hdt_H" -gt 23 ] || [ "$_hdt_M" -gt 59 ] || [ "$_hdt_S" -gt 60 ]; then
return 1
fi
#years start in March so the leap day is the last day of the year
if [ "$_hdt_m" -le 2 ]; then
_hdt_y="$((_hdt_y - 1))"
_hdt_mp="$((_hdt_m + 9))"
else
_hdt_mp="$((_hdt_m - 3))"
fi
_hdt_era="$((_hdt_y / 400))"
_hdt_yoe="$((_hdt_y - _hdt_era * 400))"
_hdt_doy="$(((153 * _hdt_mp + 2) / 5 + _hdt_d - 1))"
_hdt_doe="$((_hdt_yoe * 365 + _hdt_yoe / 4 - _hdt_yoe / 100 + _hdt_doy))"
_hdt_days="$((_hdt_era * 146097 + _hdt_doe - 719468))"
echo "$((_hdt_days * 86400 + _hdt_H * 3600 + _hdt_M * 60 + _hdt_S))"
}
_utc_date() {
date -u "+%Y-%m-%d %H:%M:%S"
}
@@ -2457,6 +2514,33 @@ _retry_backoff_sec() {
esac
}
#Reads response headers from stdin and prints the Retry-After value as a
#number of seconds from now. The header carries either delay-seconds, printed
#as is, or an HTTP-date (HARICA sends one on a processing order, Pebble too),
#converted with _httpdate2time and turned into a delay against the local
#clock. A date already in the past, or a value in neither form, prints
#nothing, so the caller falls back to its own delay. Cutting a date at the
#first colon used to leave "Thu,13Aug202612" behind, and every numeric test
#on it then errored with "integer expression expected".
_retryafter_seconds() {
_ras_v="$(tr -d '\r' | grep -i "^Retry-After *:" | _head_n 1 | cut -d : -f 2- | sed 's/^ *//; s/ *$//')"
if [ -z "$_ras_v" ]; then
return 0
fi
case "$_ras_v" in
*[!0-9]*)
_ras_t="$(_httpdate2time "$_ras_v")" || return 0
_ras_d="$((_ras_t - $(_time)))"
if [ "$_ras_d" -gt 0 ]; then
echo "$_ras_d"
fi
;;
*)
echo "$_ras_v"
;;
esac
}
# url payload needbase64 keyfile
_send_signed_request() {
url=$1
@@ -2580,7 +2664,7 @@ _send_signed_request() {
_debug3 _body "$_body"
fi
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
_retryafter=$(echo "$responseHeaders" | _retryafter_seconds)
if _is_gateway_error "$code"; then
_sleep_overload_retry_sec=$_retryafter
if [ -z "$_sleep_overload_retry_sec" ]; then
@@ -5449,6 +5533,9 @@ issue() {
#for dns manual mode
_savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize"
#the second invocation must poll this order, not the one the previous cert came from
_savedomainconf "Le_LinkOrder" "$Le_LinkOrder"
_cleardomainconf "Le_LinkCert"
_authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)"
_debug2 _authorizations_seg "$_authorizations_seg"
@@ -5951,7 +6038,7 @@ $_authorizations_map"
_on_issue_err "$_post_hook" "$vlist"
return 1
fi
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
_retryafter=$(echo "$responseHeaders" | _retryafter_seconds)
_sleep_overload_retry_sec=$_retryafter
if [ "$_sleep_overload_retry_sec" ]; then
if [ $_sleep_overload_retry_sec -le 600 ]; then
@@ -6021,7 +6108,7 @@ $_authorizations_map"
break
elif _contains "$response" "\"ready\""; then
_info "Order status is 'ready', let's sleep and retry."
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
_retryafter=$(echo "$responseHeaders" | _retryafter_seconds)
_debug "_retryafter" "$_retryafter"
if [ "$_retryafter" ] && [ $_retryafter -gt 0 ]; then
_info "Sleeping for $_retryafter seconds then retrying"
@@ -6031,7 +6118,7 @@ $_authorizations_map"
fi
elif _contains "$response" "\"processing\""; then
_info "Order status is 'processing', let's sleep and retry."
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *:" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
_retryafter=$(echo "$responseHeaders" | _retryafter_seconds)
_debug "_retryafter" "$_retryafter"
if [ "$_retryafter" ] && [ $_retryafter -gt 0 ]; then
_info "Sleeping for $_retryafter seconds then retrying"
+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
}
+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
+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
+36 -5
View File
@@ -10,6 +10,7 @@ Options:
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. 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"
@@ -112,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"
@@ -138,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"
@@ -169,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"
@@ -227,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)"
@@ -252,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"
@@ -383,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
@@ -390,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)
@@ -398,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)
+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" ""
}
+4 -3
View File
@@ -134,7 +134,7 @@ _hw_get_recordset() {
_hw_recordid=""
_hw_records=""
_hw_recordttl=""
_hw_query="name=$(printf "%s" "$_hw_domain" | _url_encode upper-hex)&search_mode=equal&type=TXT"
_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
@@ -144,8 +144,9 @@ _hw_get_recordset() {
if [ -z "$_hw_recordid" ]; then
return 0
fi
_hw_expected_name="$(_lower_case "${_hw_domain}.")"
if [ "$(_lower_case "$_hw_returned_name")" != "$_hw_expected_name" ]; then
_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
+359 -41
View File
@@ -5,30 +5,315 @@ 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)
@@ -46,7 +331,7 @@ 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
@@ -65,13 +350,15 @@ dns_netcup_add() {
_nc_nozone "$fulldomain"
return 1
fi
logout
_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)
@@ -91,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
@@ -132,21 +419,70 @@ 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
}
# The zone is looked up by walking the challenge name from the right, one
# label at a time. The leftmost label is the challenge prefix, so the full
# name itself can never be a zone: asking netcup for it only returns 4013
# "Validation Error", which would then mask the real 5028 "zone could not be
# found". Stop one label short, unless the name is too short to have a
# challenge prefix at all (manual invocation).
_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
_err "$tmp"
return 1
fi
}
_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
@@ -159,23 +495,5 @@ _nc_lastlevel() {
# fulldomain
_nc_nozone() {
_err "No DNS zone for $1 was found at netcup."
_err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup."
}
_login() {
tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST")
sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4)
_debug "$tmp"
if [ "$(_getfield "$tmp" "4" | sed s/\"status\":\"//g | sed s/\"//g)" != "success" ]; then
_err "$tmp"
return 1
fi
}
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
_err "Check that the domain belongs to the account of the configured credentials and that its DNS is hosted at netcup."
}
+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
}