diff --git a/acme.sh b/acme.sh index ccd34b18..e1a35075 100755 --- a/acme.sh +++ b/acme.sh @@ -2914,6 +2914,7 @@ _setopt() { _err "Cannot read $__conf." return 1 fi + __old_text="$__text" #build the new content first and write it once through _write_conf: #redirecting straight into the conf truncates it before anything is #written, so a failing sed (#2426) or a failing write (#7247) left a @@ -2930,6 +2931,13 @@ _setopt() { _debug3 APP __text="$__text${__text:+$__nl}$__opt$__sep$__val$__end" fi + if [ -z "$__sed_err" ] && [ "$__text" = "$__old_text" ]; then + #the value is already saved: callers re-save the same values on every + #run, and each write is a temp file plus a rename, which is slow on + #some storage (#7335) + _debug3 "Unchanged, skip writing $__conf" + return 0 + fi if [ "$__sed_err" ] || ! _write_conf "$__conf" "$__text"; then _err "Cannot save '$__opt' to $__conf." return 1 @@ -2959,11 +2967,19 @@ _clear_conf() { _c_c_f="$1" _sdkey="$2" if [ "$_c_c_f" ]; then - _conf_data="$(cat "$_c_c_f")" + _c_c_old="$(cat "$_c_c_f")" #printf, not echo: dash's builtin echo interprets backslash escapes and #would corrupt saved values that contain them on every rewrite - if ! _conf_data="$(printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d")" || - ! _write_conf "$_c_c_f" "$_conf_data"; then + if ! _conf_data="$(printf -- "%s\n" "$_c_c_old" | sed "/^$_sdkey *=.*$/d")"; then + _err "Cannot clear '$_sdkey' in $_c_c_f." + return 1 + fi + #the key is not there, the usual case for the legacy keys cleared on + #every _savedeployconf: nothing to write (#7335) + if [ "$_conf_data" = "$_c_c_old" ]; then + return 0 + fi + if ! _write_conf "$_c_c_f" "$_conf_data"; then _err "Cannot clear '$_sdkey' in $_c_c_f." return 1 fi diff --git a/deploy/redfish.sh b/deploy/redfish.sh new file mode 100644 index 00000000..ab65b8cd --- /dev/null +++ b/deploy/redfish.sh @@ -0,0 +1,492 @@ +#!/usr/bin/env sh + +# Script to deploy certificates to compatible BMCs (baseboard management +# controllers) using the [Redfish API](https://www.dmtf.org/standards/redfish). +# +# Environment Variables: +# +# ```sh +# DEPLOY_REDFISH_HOST="ipmi.example.com" # Required +# DEPLOY_REDFISH_USERNAME="Administrator" # Required +# DEPLOY_REDFISH_PASSWORD="superuser" # Required +# DEPLOY_REDFISH_USE_BASIC_AUTH=0 # Optional (set to "1" to enable) +# DEPLOY_REDFISH_RESTART=0 # Optional (set to "1" to enable) +# DEPLOY_REDFISH_MANAGER= # Optional (e.g. "/redfish/v1/Managers/Self") +# DEPLOY_REDFISH_CERTIFICATE= # Optional (e.g. "/redfish/v1/CertificateService/Certificates/1") +# ``` +# +# Compatibility: +# +# Compatible with any IPMI/BMC solution supporting Redfish v1.6.1 or newer (or +# Redfish Schema Bundle 2018.3), as defined in the following documents: +# +# +# +# +# +# This script is confirmed to work on the following systems: +# +# | IPMI Solution | BMC Chipset | Firmware Family | Redfish Version | +# |------------------|----------------|------------------|-----------------| +# | ASUS ASMB12-iKVM | ASPEED AST2600 | AMI MegaRAC SP-X | 1.15.1 | +# +# However, this should also work with HPE iLO 5 v2.42 (or newer), Dell iDRAC9 +# v5.0 (or newer), or any ASUS/Lenovo/Supermicro IPMI solution based on AMI +# MegaRAC SP-X v12.x (or newer). + +#domain keyfile certfile cafile fullchain +redfish_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + _getdeployconf DEPLOY_REDFISH_HOST + _getdeployconf DEPLOY_REDFISH_USERNAME + _getdeployconf DEPLOY_REDFISH_PASSWORD + _getdeployconf DEPLOY_REDFISH_USE_BASIC_AUTH + _getdeployconf DEPLOY_REDFISH_RESTART + _getdeployconf DEPLOY_REDFISH_MANAGER + _getdeployconf DEPLOY_REDFISH_CERTIFICATE + + if [ -z "${DEPLOY_REDFISH_HOST}" ]; then + _err 'DEPLOY_REDFISH_HOST must be set. Please specify the IP or domain name of the Redfish server.' + return 1 + fi + + if [ -z "${DEPLOY_REDFISH_USERNAME}" ] || [ -z "${DEPLOY_REDFISH_PASSWORD}" ]; then + _err 'DEPLOY_REDFISH_USERNAME and DEPLOY_REDFISH_PASSWORD must be set.' + return 1 + fi + + DEPLOY_REDFISH_USE_BASIC_AUTH="${DEPLOY_REDFISH_USE_BASIC_AUTH:-0}" + DEPLOY_REDFISH_RESTART="${DEPLOY_REDFISH_RESTART:-0}" + + _debug DEPLOY_REDFISH_HOST "${DEPLOY_REDFISH_HOST}" + _debug DEPLOY_REDFISH_USERNAME "${DEPLOY_REDFISH_USERNAME}" + _secure_debug DEPLOY_REDFISH_PASSWORD "${DEPLOY_REDFISH_PASSWORD}" + _debug DEPLOY_REDFISH_USE_BASIC_AUTH "${DEPLOY_REDFISH_USE_BASIC_AUTH}" + + _redfish_log_in "$DEPLOY_REDFISH_USERNAME" "$DEPLOY_REDFISH_PASSWORD" || return 1 + + # Credentials are proven correct now -- save them, rather than only at the + # very end, so a later step failing doesn't discard a working login. + _savedeployconf DEPLOY_REDFISH_HOST "${DEPLOY_REDFISH_HOST}" + _savedeployconf DEPLOY_REDFISH_USERNAME "${DEPLOY_REDFISH_USERNAME}" + _savedeployconf DEPLOY_REDFISH_PASSWORD "${DEPLOY_REDFISH_PASSWORD}" 'base64' + _savedeployconf DEPLOY_REDFISH_USE_BASIC_AUTH "${DEPLOY_REDFISH_USE_BASIC_AUTH}" + + if ! _redfish_get_certificate_service_endpoint; then + _err "Redfish server ${DEPLOY_REDFISH_HOST} doesn't support certificate management API." + return 1 + fi + + _debug DEPLOY_REDFISH_RESTART "${DEPLOY_REDFISH_RESTART}" + _debug DEPLOY_REDFISH_MANAGER "${DEPLOY_REDFISH_MANAGER}" + _debug DEPLOY_REDFISH_CERTIFICATE "${DEPLOY_REDFISH_CERTIFICATE}" + + if [ -n "${DEPLOY_REDFISH_MANAGER}" ]; then + _manager_endpoint="${DEPLOY_REDFISH_MANAGER}" + else + _info 'Identifying REST endpoint of primary Redfish manager.' + _redfish_get_primary_manager_endpoint || return 1 + _savedeployconf DEPLOY_REDFISH_MANAGER "${_manager_endpoint}" + fi + + if [ -n "${DEPLOY_REDFISH_CERTIFICATE}" ]; then + _certificate_endpoint="${DEPLOY_REDFISH_CERTIFICATE}" + else + _info "Identifying REST endpoint of primary TLS certificate on: ${_manager_endpoint}" + _redfish_get_manager_certificate_endpoint "${_manager_endpoint}" || return 1 + fi + + if [ -f "${_ckey}" ] && [ -f "${_cfullchain}" ]; then + _info "Uploading private key and full certificate chain to: ${_certificate_endpoint}" + + if ! _redfish_supports_full_certificate_chains; then + _err 'Redfish server does not support uploading full certificate chains!' + _err "Please call 'GenerateCSR' API on server first, pass the CSR to 'acme.sh --sign-csr --csr ', and try again." + _err 'For more information: https://github.com/acmesh-official/acme.sh/wiki/deployhooks#generate-csr-on-server-optional' + return 1 + elif ! _redfish_supports_private_key "${_ckey}" "${_certificate_service_endpoint}"; then + _err "Redfish server does not support this type of private key." + return 1 + fi + + _ckey_pkcs8="$("${ACME_OPENSSL_BIN:-openssl}" pkcs8 -topk8 -nocrypt -in "${_ckey}")" + + # shellcheck disable=SC2181 + if [ "$?" != '0' ] || [ -z "${_ckey_pkcs8}" ]; then + _err 'Failed to convert private key to PKCS#8 format!' + return 1 + fi + + _certificate_str="$(echo "${_ckey_pkcs8}" | cat - "${_cfullchain}" | _json_encode)" + _certificate_type='PEMchain' + else + _info "Private key and full certificate chain not available; we must be using a server-generated CSR." + _info "Uploading only leaf certificate to: ${_certificate_endpoint}" + + _certificate_str="$(_json_encode <"${_ccert}")" + _certificate_type='PEM' + fi + + _body="$(printf '{"CertificateString":"%s","CertificateType":"%s","CertificateUri":{"@odata.id":"%s"}}' "${_certificate_str}" "${_certificate_type}" "${_certificate_endpoint}")" + _redfish_rest POST "${_certificate_service_endpoint}/Actions/CertificateService.ReplaceCertificate" "${_body}" || return 1 + _code="$(_redfish_response_code)" + + if ! _startswith "${_code}" '2'; then + _err "Failed to update Redfish server TLS certificate! (HTTP ${_code})" + _err "Response: ${_response}" + return 1 + fi + + _info 'Successfully updated Redfish server TLS certificate!' + _savedeployconf DEPLOY_REDFISH_CERTIFICATE "${_certificate_endpoint}" + + if [ "${DEPLOY_REDFISH_RESTART}" = '1' ]; then + _info 'Attempting to restart BMC gracefully.' + _redfish_attempt_graceful_restart "${_manager_endpoint}" || return 0 + _info 'Successfully sent graceful restart command to BMC. After restarting, the new TLS certificate will be active.' + else + _info 'Please wait up to 20 seconds to take effect, or restart the BMC manually.' + fi + + _savedeployconf DEPLOY_REDFISH_RESTART "${DEPLOY_REDFISH_RESTART}" + + return 0 +} + +_redfish_rest() { + _method="$1" + _endpoint="$2" + _body="${3:-}" + + export _H1='OData-Version: 4.0' + + if [ "${_method}" = 'GET' ]; then + _response="$(_get "https://${DEPLOY_REDFISH_HOST}${_endpoint}")" + else + _response="$(_post "${_body}" "https://${DEPLOY_REDFISH_HOST}${_endpoint}" '' "${_method}" "${_body:+application/json}")" + fi + + _ret="$?" + _response="$(echo "${_response}" | _normalizeJson)" + + if [ "${_ret}" != '0' ]; then + _err "Error while calling ${_method} ${_endpoint}. Deploy with --insecure if current certificate is invalid. Try deploying with --debug to troubleshoot." + fi + + return "${_ret}" +} + +_redfish_response_code() { + _egrep_o <"${HTTP_HEADER}" '^HTTP[^ ]* [0-9]+' | _tail_n 1 | tr -d '\r\n' | cut -d ' ' -f 2 +} + +_redfish_get_odata_id() { + _regex='s/{' + for key in $(echo "$1" | tr '.' '\n'); do + _regex="${_regex}.*\"${key}\":{" + done + sed -n "${_regex}[^}]*\"@odata\\.id\":[ ]*\"\\([^\"]*\\)\".*/\\1/p" +} + +_redfish_get_odata_members() { + sed -n 's/.*"Members":[ ]*\[\([^]]*\)\].*/\1/p' | sed 's/,/\n/g' | sed -n 's/.*{"@odata\.id":"\([^"]*\)"}.*/\1/p' +} + +_redfish_get_odata_count() { + sed -n 's/.*"Members@odata\.count":[ ]*\([0-9][0-9]*\).*/\1/p' +} + +_redfish_log_in() { + _username="$1" + _password="$2" + + _redfish_rest GET '/redfish/v1/' || return 1 + _info "Authenticating with Redfish API: ${DEPLOY_REDFISH_HOST}" + + if [ "${DEPLOY_REDFISH_USE_BASIC_AUTH}" = '1' ]; then + _access_token="$(printf '%s:%s' "${_username}" "${_password}" | _base64)" + export _H2="Authorization: Basic ${_access_token}" + + # Verify credentials are valid + _account_service_endpoint="$(echo "${_response}" | _redfish_get_odata_id 'AccountService')" + _redfish_rest GET "${_account_service_endpoint}" || return 1 + _code="$(_redfish_response_code)" + + if [ "${_code}" != '200' ]; then + _err "Redfish authentication failed (HTTP ${_code})" + _err "Response: ${_response}" + return 1 + fi + else + # Create new session + _sessions_endpoint="$(echo "${_response}" | _redfish_get_odata_id 'Links.Sessions')" + _username="$(printf '%s\n' "${_username}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + _password="$(printf '%s\n' "${_password}" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + _body="$(printf '{"UserName":"%s","Password":"%s"}' "${_username}" "${_password}")" + _redfish_rest POST "${_sessions_endpoint}" "${_body}" || return 1 + _code="$(_redfish_response_code)" + + # Verify authentication succeeded + if [ "${_code}" != '201' ]; then + _err "Redfish authentication failed (HTTP ${_code})" + _err "Response: ${_response}" + if [ "${_code}" = '401' ] && grep -qi '^WWW-Authenticate: Basic.*$' "${HTTP_HEADER}"; then + _err 'NOTE: Server supports Basic auth scheme. Consider setting DEPLOY_REDFISH_USE_BASIC_AUTH=1 and try again.' + fi + return 1 + fi + + _session="$(echo "${_response}" | _redfish_get_odata_id '')" + _auth_token="$(grep -i '^X-Auth-Token: .*$' "${HTTP_HEADER}" | _tail_n 1 | tr -d ' \r\n' | cut -d ':' -f 2)" + export _H2="X-Auth-Token: ${_auth_token}" + fi + + trap '_redfish_log_out "${_session:-}"' EXIT INT +} + +_redfish_log_out() { + _session_endpoint="${1:-}" + + _info "Logging out of Redfish session on: ${DEPLOY_REDFISH_HOST}" + + if [ -n "${_session_endpoint}" ]; then + _redfish_rest DELETE "${_session_endpoint}" || return 1 + _code="$(_redfish_response_code)" + + if ! _startswith "${_code}" '2'; then + _err "Failed to log out of Redfish server (HTTP ${_code})." + _err "Response: ${_response}" + fi + else + _info 'Using basic HTTP auth, no need to log out of Redfish API.' + fi + + export _H1= + export _H2= +} + +_redfish_get_certificate_service_endpoint() { + _redfish_rest GET '/redfish/v1/' || return 1 + _certificate_service_endpoint="$(echo "${_response}" | _redfish_get_odata_id 'CertificateService')" + [ -n "${_certificate_service_endpoint}" ] +} + +_redfish_supports_full_certificate_chains() { + _redfish_rest GET "/redfish/v1/\$metadata" || return 1 + + _newest_certificate_schema_version="$(echo "${_response}" | + _egrep_o 'Namespace="Certificate\.v[_0-9][_0-9]*"' | + sed -n 's/.*v\([_0-9][_0-9]*\)".*/\1/p' | + sort -n -t _ -k1,1 -k2,2 -k3,3 | _tail_n 1 | tr '_' '.')" + _debug _newest_certificate_schema_version "${_newest_certificate_schema_version}" + + _schema_major=${_newest_certificate_schema_version%%.*} + _schema_patch_minor=${_newest_certificate_schema_version#*.} + _schema_minor=${_schema_patch_minor%%.*} + _schema_patch=${_schema_patch_minor#*.} + + for var in _schema_major _schema_minor _schema_patch; do + _debug "$var" "$(eval echo "\$$var")" + case "$(eval echo "\$$var")" in + '' | *[!0-9]*) return 1 ;; + *) continue ;; + esac + done + + [ "${_schema_major}" -gt '1' ] || { [ "${_schema_major}" -ge '1' ] && [ "${_schema_minor}" -ge '4' ]; } +} + +_redfish_get_action() { + unset _action _action_info + + _redfish_rest GET "$1" || return 1 + _action="$(echo "${_response}" | sed -n "s/{.*\"Actions\":{.*\"#$2\":\({[^}]*}\).*/\1/p")" + _action_info_endpoint="$(echo "${_action}" | sed -n 's/.*"@Redfish\.ActionInfo":"\([^"]*\)".*/\1/p')" + + if [ -n "${_action_info_endpoint}" ]; then + _redfish_rest GET "${_action_info_endpoint}" || return 1 + _code="$(_redfish_response_code)" + + if [ "${_code}" = '200' ]; then + _action_info="${_response}" + fi + fi +} + +_redfish_get_action_allowable_values() { + _parameter="$1" + + _allowable_values="$(echo "${_action}" | + sed -n "s/.*\"${_parameter}@Redfish\.AllowableValues\":[ ]*\[\([^]]*\)\].*/\1/p" | + tr -d '"')" + + if [ -n "${_allowable_values}" ]; then + _debug2 "Found allowable values for '${_parameter}' in '@Redfish.Action': [${_allowable_values}]" + echo "${_allowable_values}" + elif [ -n "${_action_info}" ]; then + _parameter_info="$(echo "${_action_info}" | + sed -n 's/.*"Parameters":[ ]*\[\(\({[^}]*},\{0,1\}\)*\)\].*/\1/p' | _egrep_o '\{[^\}]{1,}\}' | + grep "\"Name\":\"${_parameter}\"")" + + if [ -n "${_parameter_info}" ]; then + _debug2 "Found allowable values for '${_parameter}' in @Redfish.ActionInfo: ${_parameter_info}" + _data_type="$(echo "${_parameter_info}" | sed -n 's/.*"DataType":[ ]*"\([^"]*\)".*/\1/p')" + + case "${_data_type}" in + String) + echo "${_parameter_info}" | sed -n 's/.*"AllowableValues":[ ]*\[\([^]]*\)\].*/\1/p' | tr -d '"' + ;; + Number) + _min_value="$(echo "${_parameter_info}" | sed -n 's/.*"MinimumValue":[ ]*\([0-9]\{1,\}\).*/\1/p')" + _max_value="$(echo "${_parameter_info}" | sed -n 's/.*"MaximumValue":[ ]*\([0-9]\{1,\}\).*/\1/p')" + echo "${_min_value},${_max_value}" + ;; + esac + fi + fi +} + +_redfish_supports_private_key() { + _private_key="$1" + _certificate_service_endpoint="$2" + + if _isRSA "${_private_key}"; then + _key_algo='RSA' + elif _isEcc "${_private_key}"; then + _key_algo='ECDSA' + else + _err 'Private key uses unknown cryptographic algorithm!' + return 1 + fi + + _redfish_get_action "${_certificate_service_endpoint}" 'CertificateService.GenerateCSR' || return 1 + _allowed_key_algos="$(_redfish_get_action_allowable_values 'KeyPairAlgorithm')" + _allowed_key_bit_lengths="$(_redfish_get_action_allowable_values 'KeyBitLength')" + _allowed_key_curve_ids="$(_redfish_get_action_allowable_values 'KeyCurveId')" + + if [ -z "${_allowed_key_algos}" ]; then + _contains "${_response}" '"KeyBitLength"' && _allowed_key_algos='TPM_ALG_RSA' + _contains "${_response}" '"KeyCurveId"' && _allowed_key_algos="${_allowed_key_algos},TPM_ALG_ECDSA" + _allowed_key_algos="${_allowed_key_algos#,}" + + if [ -z "${_allowed_key_algos}" ]; then + _debug 'Failed to determine server crypto requirements, proceeding blindly.' + return 0 + fi + + _debug "GenerateCSR does not specify allowed KeyPairAlgorithm(s), so assuming ${_allowed_key_algos}." + fi + + _debug _key_algo "${_key_algo}" + _debug _allowed_key_algos "${_allowed_key_algos}" + + case "${_key_algo}:${_allowed_key_algos}" in + RSA:*RSA*) + _min_key_length="$(echo "${_allowed_key_bit_lengths}" | cut -d ',' -f 1)" + _max_key_length="${_allowed_key_bit_lengths##*,}" + _debug _min_key_length "${_min_key_length}" + _debug _max_key_length "${_max_key_length}" + + if [ -z "${_min_key_length}" ] && [ -z "${_max_key_length}" ]; then + _debug "Server supports RSA private keys but doesn't specify minimum/maximum bit length, proceeding blindly." + return 0 + fi + + # shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook + if [ "${Le_Keylength}" -lt "${_min_key_length:-${Le_Keylength}}" ] || [ "${Le_Keylength}" -gt "${_max_key_length:-${Le_Keylength}}" ]; then + _err "Unsupported RSA private key length ${Le_Keylength}!" + _err "Please re-run acme.sh with --keylength set to a value between ${_min_key_length} and ${_max_key_length}." + return 1 + fi + ;; + ECDSA:*ECDSA*) + _debug _allowed_key_curve_ids "${_allowed_key_curve_ids}" + + if [ -z "${_allowed_key_curve_ids}" ]; then + _debug "Server supports ECDSA private keys but doesn't specify which curves, proceeding blindly." + return 0 + fi + + # shellcheck disable=SC2154 # Le_Keylength is set by acme.sh core, not this hook + if ! _contains "${_allowed_key_curve_ids}" "TPM_ECC_NIST_P${Le_Keylength#ec-}"; then + _err "Unsupported ECDSA private key type! Server supports only: ${_allowed_key_curve_ids}" + return 1 + fi + ;; + *) + _err "Unsupported key pair algorithm ${_key_algo}! Server supports only: ${_allowed_key_algos}" + return 1 + ;; + esac +} + +_redfish_get_primary_manager_endpoint() { + _redfish_rest GET '/redfish/v1/' || return 1 + _managers_endpoint="$(echo "${_response}" | _redfish_get_odata_id 'Managers')" + _redfish_rest GET "${_managers_endpoint}" || return 1 + _num_managers="$(echo "${_response}" | _redfish_get_odata_count)" + + if [ "${_num_managers}" != '1' ]; then + _all_managers="$(echo "${_response}" | _redfish_get_odata_members | tr '\n' ',')" + _err "Multiple Redfish managers identified (${_all_managers}), but expected exactly one." + _err 'Please specify the correct manager in DEPLOY_REDFISH_MANAGER.' + return 1 + fi + + _manager_endpoint="$(echo "${_response}" | _redfish_get_odata_members | _head_n 1)" + [ -n "${_manager_endpoint}" ] +} + +_redfish_get_manager_certificate_endpoint() { + _manager_endpoint="$1" + + _redfish_rest GET "${_manager_endpoint}" || return 1 + _protocol_endpoint="$(echo "${_response}" | _redfish_get_odata_id 'NetworkProtocol')" + _redfish_rest GET "${_protocol_endpoint}/HTTPS/Certificates" || return 1 + _num_certificates="$(echo "${_response}" | _redfish_get_odata_count)" + + if [ -z "${_num_certificates}" ]; then + _certificate_endpoint="${_protocol_endpoint}/HTTPS/Certificates/1" + return 0 + elif [ "${_num_certificates}" != '1' ]; then + _all_certificates="$(echo "${_response}" | _redfish_get_odata_members | tr '\n' ',')" + _err "Multiple web service HTTPS certificates identified (${_all_certificates}), but expected exactly one." + _err "Please specify which certificate should be replaced in DEPLOY_REDFISH_CERTIFICATE." + return 1 + fi + + _certificate_endpoint="$(echo "${_response}" | _redfish_get_odata_members | _head_n 1)" + [ -n "${_certificate_endpoint}" ] +} + +_redfish_attempt_graceful_restart() { + _manager_endpoint="$1" + + _redfish_get_action "${_manager_endpoint}" 'Manager.Reset' || return 1 + _allowed_reset_types="$(_redfish_get_action_allowable_values 'ResetType')" + + if ! _contains "${_allowed_reset_types}" 'GracefulRestart'; then + _err "BMC doesn't support graceful restarts. Please wait up to 20 seconds to take effect, or restart the BMC manually." + return 1 + fi + + _redfish_rest POST "${_manager_endpoint}/Actions/Manager.Reset" '{"ResetType":"GracefulRestart"}' || return 1 + _code="$(_redfish_response_code)" + + if ! _startswith "${_code}" '2'; then + _err "Failed to gracefully restart BMC! (HTTP ${_code})" + _err "Response: ${_response}" + return 1 + fi +} diff --git a/deploy/shelly.sh b/deploy/shelly.sh index dbdab346..d87828de 100644 --- a/deploy/shelly.sh +++ b/deploy/shelly.sh @@ -4,18 +4,29 @@ # Deploy the HTTPS server certificate to a Shelly device on the local network. # # ```sh -# export SHELLY_HOST=192.168.1.100 -# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device +# export SHELLY_HOST=shelly.example.com +# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device +# export SHELLY_SCHEME=https # only if devices factory shipped with +# # firmware 2.0.0 or already has https +# # enabled # acme.sh --deploy -d shelly.example.com --deploy-hook shelly # ``` # +# If `SHELLY_SCHEME=https` is used, the first deploy is likely to require +# the additional `--insecure` parameter due to factory signed certificates. +# After the initial deployment make sure to retest deployment without +# `--insecure` simply using `acme.sh --deploy -d shelly.example.com` +# to ensure follow up deployment will work. +# # Environment variables: # SHELLY_HOST (required) IP or hostname of the Shelly device # SHELLY_PASSWORD (optional) Admin password for digest authentication. # Omit if auth is disabled on the device. # SHELLY_USER (optional) Username for auth. Default: admin -# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot. +# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot. # Default: 1 (reboot after upload) +# SHELLY_SCHEME (optional) Scheme used to call the API: http or https +# Default: http # # Requirements: # - Shelly Gen3+ device (Gen4 recommended) @@ -23,7 +34,7 @@ # - curl or wget # - openssl (for SHA-256 digest and random cnonce) # -# The device must be reachable via HTTP on the local network. +# The device must be reachable on the local network. # The hook uploads the fullchain.pem and private key, # then reboots the device to apply the new certificate. # @@ -52,11 +63,13 @@ shelly_deploy() { _getdeployconf SHELLY_PASSWORD _getdeployconf SHELLY_USER _getdeployconf SHELLY_REBOOT + _getdeployconf SHELLY_SCHEME _debug SHELLY_HOST "$SHELLY_HOST" _debug SHELLY_USER "$SHELLY_USER" _secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD" _debug SHELLY_REBOOT "$SHELLY_REBOOT" + _debug SHELLY_SCHEME "$SHELLY_SCHEME" if [ -z "$SHELLY_HOST" ]; then _err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device." @@ -65,11 +78,20 @@ shelly_deploy() { SHELLY_USER="${SHELLY_USER:-admin}" SHELLY_REBOOT="${SHELLY_REBOOT:-1}" + SHELLY_SCHEME="${SHELLY_SCHEME:-http}" + case "$SHELLY_SCHEME" in + http | https) ;; + *) + _err "SHELLY_SCHEME must be http or https" + return 1 + ;; + esac _savedeployconf SHELLY_HOST "$SHELLY_HOST" _savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD" _savedeployconf SHELLY_USER "$SHELLY_USER" _savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT" + _savedeployconf SHELLY_SCHEME "$SHELLY_SCHEME" # --- Auth handshake (only if password is set) --- _shelly_auth_header="" @@ -126,7 +148,7 @@ _shelly_handshake() { # Use a protected method (Shelly.GetStatus) to trigger 401. # Shelly.GetDeviceInfo is excluded from auth and would miss the challenge. _post '{"id":1,"method":"Shelly.GetStatus"}' \ - "http://${SHELLY_HOST}/rpc" "" "" "application/json" + "${SHELLY_SCHEME}://${SHELLY_HOST}/rpc" "" "" "application/json" # Detect auth from HTTP status line rather than response body if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then @@ -210,7 +232,7 @@ _shelly_rpc() { export _H1="" fi - _post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json" + _post "$_shelly_body" "${SHELLY_SCHEME}://${SHELLY_HOST}/rpc" "" "" "application/json" _shelly_ret=$? if [ "$_shelly_ret" != "0" ]; then diff --git a/deploy/ssh.sh b/deploy/ssh.sh index a76d8097..16ec6482 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -189,6 +189,19 @@ ssh_deploy() { _info "Required commands batched and sent in single call to remote host" fi + # With a POSIX remote shell, steps are joined with && so that the first + # failed step stops the rest (no REMOTE_CMD after a failed write). Other + # shells ("powershell -c", "cmd /c") keep the plain ";" join. + _ssh_shell_name="${DEPLOY_SSH_REMOTE_SHELL%% *}" + case "${_ssh_shell_name##*/}" in + sh | bash | dash | ash | ksh | mksh | zsh) + _ssh_fail_fast="yes" + ;; + *) + _ssh_fail_fast="no" + ;; + esac + _returnCode=0 _deploy_ssh_servers="$DEPLOY_SSH_SERVER" for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do @@ -228,9 +241,9 @@ _ssh_deploy() { _backupdir="$_backupprefix-$(_utc_date | tr ' ' '-')" # run cleanup on the backup directory, erase all older # than 180 days (15552000 seconds). - _cmdstr="{ now=\"\$(date -u +%s)\"; for fn in $_backupprefix*; \ + _ssh_add_step "now=\"\$(date -u +%s)\"; for fn in $_backupprefix*; \ 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" +then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; done" # Alternate version of above... _cmdstr="find $_backupprefix* -type d -mtime +180 2>/dev/null | xargs rm -rf; $_cmdstr" _info "Backup of old certificate files will be placed in remote directory $_backupdir" _info "Backup directories erased after 180 days." @@ -245,7 +258,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 if [ -f $DEPLOY_SSH_KEYFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_KEYFILE $_backupdir >/dev/null; fi;" + _ssh_add_step "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 @@ -261,10 +274,9 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d return $_err_code fi else - # If file doesn't exist, create it and change its permissions. - _cmdstr="$_cmdstr test ! -f $DEPLOY_SSH_KEYFILE && touch $DEPLOY_SSH_KEYFILE && chmod 600 $DEPLOY_SSH_KEYFILE;" - # ssh echo to the file - _cmdstr="$_cmdstr echo \"$(cat "$_ckey")\" > $DEPLOY_SSH_KEYFILE;" + # If file doesn't exist, create it and change its permissions, then + # ssh echo to the file. One step: "test ! -f" fails if the file exists. + _ssh_add_step "test ! -f $DEPLOY_SSH_KEYFILE && touch $DEPLOY_SSH_KEYFILE && chmod 600 $DEPLOY_SSH_KEYFILE; echo \"$(cat "$_ckey")\" > $DEPLOY_SSH_KEYFILE" _info "will copy private key to remote file $DEPLOY_SSH_KEYFILE" if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then if ! _ssh_remote_cmd "$_cmdstr"; then @@ -282,7 +294,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 if [ -f $DEPLOY_SSH_CERTFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CERTFILE $_backupdir >/dev/null; fi;" + _ssh_add_step "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 @@ -304,7 +316,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d fi else # ssh echo to the file - _cmdstr="$_cmdstr echo \"$(cat "$_ccert")\" $_pipe $DEPLOY_SSH_CERTFILE;" + _ssh_add_step "echo \"$(cat "$_ccert")\" $_pipe $DEPLOY_SSH_CERTFILE" _info "will copy certificate to remote file $DEPLOY_SSH_CERTFILE" if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then if ! _ssh_remote_cmd "$_cmdstr"; then @@ -323,7 +335,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 if [ -f $DEPLOY_SSH_CAFILE ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_CAFILE $_backupdir >/dev/null; fi;" + _ssh_add_step "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 @@ -348,7 +360,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d fi else # ssh echo to the file - _cmdstr="$_cmdstr echo \"$(cat "$_cca")\" $_pipe $DEPLOY_SSH_CAFILE;" + _ssh_add_step "echo \"$(cat "$_cca")\" $_pipe $DEPLOY_SSH_CAFILE" _info "will copy CA file to remote file $DEPLOY_SSH_CAFILE" if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then if ! _ssh_remote_cmd "$_cmdstr"; then @@ -368,7 +380,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 if [ -f $DEPLOY_SSH_FULLCHAIN ]; then mkdir -p $_backupdir; cp $DEPLOY_SSH_FULLCHAIN $_backupdir >/dev/null; fi;" + _ssh_add_step "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 @@ -396,7 +408,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d fi else # ssh echo to the file - _cmdstr="$_cmdstr echo \"$(cat "$_cfullchain")\" $_pipe $DEPLOY_SSH_FULLCHAIN;" + _ssh_add_step "echo \"$(cat "$_cfullchain")\" $_pipe $DEPLOY_SSH_FULLCHAIN" _info "will copy fullchain to remote file $DEPLOY_SSH_FULLCHAIN" if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then if ! _ssh_remote_cmd "$_cmdstr"; then @@ -419,7 +431,7 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d fi if [ -n "$DEPLOY_SSH_REMOTE_CMD" ]; then - _cmdstr="$_cmdstr $DEPLOY_SSH_REMOTE_CMD;" + _ssh_add_step "$DEPLOY_SSH_REMOTE_CMD" _info "Will execute remote command $DEPLOY_SSH_REMOTE_CMD" if [ "$DEPLOY_SSH_MULTI_CALL" = "yes" ]; then if ! _ssh_remote_cmd "$_cmdstr"; then @@ -439,6 +451,20 @@ then rm -rf \"\$fn\"; echo \"Backup \$fn deleted as older than 180 days\"; fi; d return 0 } +#step +_ssh_add_step() { + if [ "$_ssh_fail_fast" = "yes" ]; then + if [ -n "$_cmdstr" ]; then + _cmdstr="$_cmdstr && " + fi + # the newline ends the step even if it ends with a comment, ";" or "&" + _cmdstr="$_cmdstr{ $1 +}" + else + _cmdstr="$_cmdstr $1;" + fi +} + #cmd _ssh_remote_cmd() { _cmd="$1" diff --git a/dnsapi/dns_abion.sh b/dnsapi/dns_abion.sh new file mode 100644 index 00000000..acd12229 --- /dev/null +++ b/dnsapi/dns_abion.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_abion_info='Abion.com +Site: abion.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_abion +Options: + ABION_API_KEY API Key. Created in the Abion Core web interface. +Issues: github.com/acmesh-official/acme.sh/issues/7325 +' + +ABION_Api_Endpoint="https://api.abion.com/v1" + +##################### Public functions ##################### + +# Usage: dns_abion_add +# Example: dns_abion_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_abion_add() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue=$2 + + ABION_API_KEY="${ABION_API_KEY:-$(_readaccountconf_mutable ABION_API_KEY)}" + + if [ -z "$ABION_API_KEY" ]; then + ABION_API_KEY="" + _err "You need to specify ABION_API_KEY." + return 1 + fi + + _saveaccountconf_mutable ABION_API_KEY "$ABION_API_KEY" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + if ! _abion_get_txt_records; then + return 1 + fi + + if _contains "$_txt_records" "\"rdata\":\"$txtvalue\""; then + _info "TXT record already exists" + return 0 + fi + + _records=$(echo "$_txt_records" | tr '\n' ',' | sed 's/,*$//') + if [ -n "$_records" ]; then + _records="$_records," + fi + + _info "Adding record" + if ! _abion_patch_txt "[$_records{\"rdata\":\"$txtvalue\",\"ttl\":60}]"; then + return 1 + fi + + _info "Added, OK" + return 0 +} + +# Usage: dns_abion_rm +dns_abion_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue=$2 + + ABION_API_KEY="${ABION_API_KEY:-$(_readaccountconf_mutable ABION_API_KEY)}" + + if [ -z "$ABION_API_KEY" ]; then + ABION_API_KEY="" + _err "You need to specify ABION_API_KEY." + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + if ! _abion_get_txt_records; then + return 1 + fi + + if ! _contains "$_txt_records" "\"rdata\":\"$txtvalue\""; then + _info "TXT record not found, nothing to remove" + return 0 + fi + + _records=$(echo "$_txt_records" | grep -v "\"rdata\":\"$txtvalue\"" | tr '\n' ',' | sed 's/,*$//') + + if [ -n "$_records" ]; then + _records="[$_records]" + else + _records="null" + fi + + _info "Deleting record" + if ! _abion_patch_txt "$_records"; then + return 1 + fi + + _info "Deleted, OK" + return 0 +} + +##################### Private functions ##################### + +_get_root() { + domain=$1 + i=1 + p=0 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + return 1 + fi + + if ! _abion_rest GET "zones/$h"; then + return 1 + fi + + if _contains "$response" '"type" *: *"zone"'; then + if [ "$p" = "0" ]; then + _sub_domain="@" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + fi + _domain="$h" + return 0 + fi + + if ! _contains "$response" "Zone not found"; then + _err "$response" + return 1 + fi + + p="$i" + i=$(_math "$i" + 1) + done +} + +_abion_get_txt_records() { + if ! _abion_rest GET "zones/$_domain"; then + return 1 + fi + + _sub_re=$(printf "%s" "$_sub_domain" | sed 's/[.]/\\./g') + _txt_records=$(echo "$response" | _normalizeJson | + sed -n "s/.*\"$_sub_re\":{//p" | + sed 's/\] *}.*/]/' | + _egrep_o '"TXT":\[[^]]*\]' | + sed 's/^"TXT":\[ *//;s/ *\]$//;s/} *, *{/}\ +{/g') + _debug2 _txt_records "$_txt_records" + return 0 +} + +_abion_patch_txt() { + if ! _abion_rest PATCH "zones/$_domain" "{\"data\":{\"type\":\"zone\",\"id\":\"$_domain\",\"attributes\":{\"records\":{\"$_sub_domain\":{\"TXT\":$1}}}}}"; then + return 1 + fi + + if ! _contains "$response" '"type" *: *"zone"'; then + _err "Unexpected API response: $response" + return 1 + fi + + return 0 +} + +_abion_rest() { + method=$1 + endpoint=$2 + data=$3 + + export _H1="X-API-KEY: $ABION_API_KEY" + export _H2="Content-Type: application/json" + + if [ "$method" = "GET" ]; then + response="$(_get "$ABION_Api_Endpoint/$endpoint")" + else + response="$(_post "$data" "$ABION_Api_Endpoint/$endpoint" "" "$method")" + fi + + if [ "$?" != "0" ]; then + _err "error $endpoint" + return 1 + fi + + _debug2 response "$response" + return 0 +} diff --git a/dnsapi/dns_cf.sh b/dnsapi/dns_cf.sh index 7b383c43..3342fbef 100755 --- a/dnsapi/dns_cf.sh +++ b/dnsapi/dns_cf.sh @@ -18,7 +18,7 @@ CF_Api="https://api.cloudflare.com/client/v4" #Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_cf_add() { - fulldomain=$1 + fulldomain="$(_idn "$1")" txtvalue=$2 CF_Token="${CF_Token:-$(_readaccountconf_mutable CF_Token)}" @@ -109,7 +109,7 @@ dns_cf_add() { #fulldomain txtvalue dns_cf_rm() { - fulldomain=$1 + fulldomain="$(_idn "$1")" txtvalue=$2 CF_Token="${CF_Token:-$(_readaccountconf_mutable CF_Token)}" diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index 257549b4..983ee9f0 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -15,7 +15,7 @@ DNSimple_API="https://api.dnsimple.com/v2" # Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_dnsimple_add() { - fulldomain=$1 + fulldomain=$(echo "$1" | _lower_case) txtvalue=$2 DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" @@ -56,7 +56,7 @@ dns_dnsimple_add() { # fulldomain dns_dnsimple_rm() { - fulldomain=$1 + fulldomain=$(echo "$1" | _lower_case) DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" if [ -z "$DNSimple_OAUTH_TOKEN" ]; then @@ -110,9 +110,13 @@ _get_root() { return 1 fi - if _contains "$response" 'not found'; then - _debug "$h not found" - else + if ! _contains "$response" "\"name\":\"$h\""; then + if ! _dnsimple_rest GET "$_account_id/zones/$h/records?per_page=1"; then + return 1 + fi + fi + + if _contains "$response" "\"name\":\"$h\"" || _contains "$response" '"pagination":'; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$previous") _domain="$h" @@ -121,6 +125,7 @@ _get_root() { return 0 fi + _debug "$h not found" previous="$i" i=$(_math "$i" + 1)