@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user