diff --git a/.github/workflows/PebbleStrict.yml b/.github/workflows/PebbleStrict.yml index 946d993a..09de7170 100644 --- a/.github/workflows/PebbleStrict.yml +++ b/.github/workflows/PebbleStrict.yml @@ -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 diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index 383db8d9..1709d4d6 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -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 diff --git a/acme.sh b/acme.sh index d883f3e4..f7ba06e7 100755 --- a/acme.sh +++ b/acme.sh @@ -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" diff --git a/deploy/jetkvm.sh b/deploy/jetkvm.sh new file mode 100644 index 00000000..a6129be7 --- /dev/null +++ b/deploy/jetkvm.sh @@ -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@, 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 +} diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 0bf3ee48..a76d8097 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -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 diff --git a/deploy/truenas_websocat.sh b/deploy/truenas_websocat.sh new file mode 100644 index 00000000..c5c7db2e --- /dev/null +++ b/deploy/truenas_websocat.sh @@ -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: /ui/apikeys +# +# Required: +# export DEPLOY_TRUENAS_APIKEY="" +# +# Optional: +# export DEPLOY_TRUENAS_HOSTNAME="" (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 + # 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