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 +}