Files
acme.sh/deploy/jetkvm.sh
T
daemonhornandClaude Sonnet 5 a5e683546f Add JetKVM SSH deploy hook (#7254)
* Add JetKVM SSH deploy hook

Adds deploy/jetkvm.sh to deploy a certificate to a JetKVM
(https://jetkvm.com) KVM-over-IP device over plain SSH, writing the
cert/key via a small POSIX shell script piped to the remote "sh" (JetKVM
has no scp/SFTP server), staged under temp names and atomically renamed
into place so a dropped connection can't leave the device with a
mismatched cert/key pair for its own HTTPS listener. Defaults target
JetKVM's confirmed "Custom" TLS storage path/filenames and default the
post-upload command to "reboot", since JetKVM has no hot-reload for a new
certificate.

This factors out the SSH upload logic originally proposed in
opnsense/plugins#5621 (an OPNsense ACME Client plugin automation) per
maintainer feedback there, so it can be reused as a small config instead
of plugin-specific code: https://github.com/opnsense/plugins/pull/5621#issuecomment-5570647257

* Fix restart-command exit-code handling in jetkvm.sh

The default restart command ("reboot") tears down the very SSH
connection running it, which real hardware testing shows makes ssh's
own exit code unreliable: it can come back as either a clean 0 or a
connection-reset 255 for the exact same successful reboot depending on
timing. The hook previously trusted that single exit code directly, so
a fully successful, unattended cron renewal could be reported as a
failed deploy.

Split into two SSH calls: the first uploads and stages the cert/key and
its exit code is trusted as-is (no reboot risk there). The second runs
the restart command and is judged by whether a marker line printed
*before* that command shows up in the captured output -- if the marker
is missing, the call never really ran (real failure); if it's present,
only a clean exit or 255 (connection dropped, expected) counts as
success, while any other exit code is treated as the restart command's
own genuine failure (e.g. 127 = command not found).

Also: a blank DEPLOY_JETKVM_RESTART_CMD now falls back to "reboot"
rather than silently skipping the restart -- previously there was no
way to actually configure "no restart command", since the hook coerced
any blank value (including one the user deliberately set) back to
"reboot" on every run. Skipping it now requires the explicit sentinel
DEPLOY_JETKVM_RESTART_CMD="none".

* Verify JetKVM HTTPS Mode is "Custom" before uploading

Uploading a certificate that the device's active HTTPS Mode won't even
serve was previously a silent no-op -- the write would succeed but never
take effect until a human noticed and fixed the mode themselves.

JetKVM's own JSON-RPC getTLSState/setTLSState calls require an
authenticated WebRTC session (see jetkvm/kvm#1240 and the still-open
jetkvm/kvm#1515), so there's no documented/headless way to query this.
Its firmware (web_tls.go / config.go in jetkvm/kvm) does persist the
mode as a plain JSON field, "tls_mode" (values "", "self-signed", or
"custom"), in /userdata/kvm_config.json -- confirmed against a real
device, including that its busybox grep handles the -E/[[:space:]]
regex used here.

The check runs as the first step of the existing upload SSH call (no
extra round trip), exits a dedicated code (3) if "tls_mode" isn't
"custom", and the hook surfaces that as a specific, actionable error
pointing at the device's web UI setting, distinct from a generic upload
failure. Since the underlying config file/field is just as undocumented
as everything else this hook depends on, DEPLOY_JETKVM_REQUIRE_CUSTOM_MODE=no
opts out entirely in case a future firmware version changes the format.

Also tightens two things noticed while adding this: the "Uploading
certificate..." info log no longer prints before a call that might
immediately fail the mode check, and the remote script's own error
echo (redundant with the local hook's more detailed _err message) is
dropped.

Confirmed end-to-end against a real JetKVM device: the regex correctly
matched the device's actual tls_mode=custom, and a full run of the
updated hook (mode check included) succeeded.

* Address maintainer review: hardcode firmware constants, drop local temp files, fix POSIX portability

Per @neilpang's review (acmesh-official/acme.sh#7254):

1. Drop [[:space:]]/-E from the tls_mode grep -- not portable (Solaris
   sed/grep read it as a literal bracket set); the compact and indented
   JSON forms are both covered by a plain space with '*'.
2. Use the core _time() wrapper instead of `date +%s || echo 0` -- the
   fallback was dead code (a date binary that doesn't understand %s
   still exits 0), and _time() is the idiom every other hook/dnsapi
   script already uses for this.
3. Drop the local temp files entirely for both the upload and restart
   SSH calls. The upload script is now built in a variable and piped
   directly into `ssh ... sh` (same pattern as deploy/windows_rdp.sh);
   $? after the pipeline is still ssh's own exit code. This keeps the
   private key off local disk and removes the _mktemp/chmod/rm dance.
4. Refuse to deploy when the key or fullchain file is empty (e.g. a
   --signcsr-only run) instead of uploading an empty key file and
   rebooting the device.
5. Use `printf '%s\n'`, not `echo`, for every generated script line --
   dash's echo interprets backslash escapes, so the remote script's
   content would otherwise depend on which /bin/sh happens to run
   acme.sh.
6. Save DEPLOY_JETKVM_SSH_CMD and DEPLOY_JETKVM_RESTART_CMD with
   _savedeployconf's "base64" flag (as deploy/docker.sh does for its
   own reload command), since a value containing a single quote would
   otherwise break the saved domain.conf line.
7. Distinguish "config file missing/unreadable" from "HTTPS Mode isn't
   Custom" with separate exit codes -- grep's own exit 2 for a missing
   file was previously funneled into the same "not custom" error,
   misdiagnosing the actual problem. Also stopped suggesting
   REQUIRE_CUSTOM_MODE=no in that error message: following it silently
   turns every future deploy into a no-op once persisted to domain.conf.
8. Hardcode the remote path, filenames, chmod values and config file
   path as constants instead of DEPLOY_JETKVM_* variables. They're
   firmware facts on a single-purpose, single-root appliance, not user
   configuration -- and since _savedeployconf pins whatever value is
   first used into domain.conf, a firmware-side correction to one of
   these later would never reach anyone who'd already deployed once.
   Only USER/HOST/PORT/SSH_CMD/RESTART_CMD/REQUIRE_CUSTOM_MODE remain.
9. Run the restart command detached (nohup sh -c 'sleep N; $CMD' &) so
   the ssh call returns as soon as it's launched, before the device
   actually reboots, instead of racing the connection teardown. This
   also removes the marker/case-based "0 or 255" exit-code logic
   entirely, along with the bug it had: a connection dropping after the
   marker printed but before the restart command actually ran was
   previously reported as a successful deploy. The tradeoff (also
   called out inline and in the PR description): a restart command that
   fails after being launched can no longer be detected, only a failure
   to launch it at all.
10. Use fixed temp filenames for the staged cert/key (not one new name
    per run) plus a `trap ... EXIT` in the generated script, so any
    abort (the mode check, a write failure under `set -e`) cleans up
    instead of leaving another stray key-bearing file on the device.
11. Trimmed the header: removed the marker/0|255 rationale (obsoleted by
    #9), corrected the "typically overnight" claim about when the
    restart actually runs, and added a wiki reference.

Not yet done: a deployhooks wiki entry (acmesh-official/acme.sh#7254's
point 12) -- flagged in the PR thread since only a repo collaborator can
edit that wiki.

Verified: shellcheck (no exclusions needed anymore) and shfmt -i 2
clean; a local smoke-test harness (stubbed acme.sh core, a fake ssh
that redirects the hardcoded device paths into a scratch directory)
covering a clean deploy with byte-exact content/permissions and no
leftover staged files, RESTART_CMD=none, HTTPS Mode not "custom",
the config file missing entirely (now a distinct error),
REQUIRE_CUSTOM_MODE=no, an empty key file (--signcsr case), and a
failed restart-command launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7rTpUF3btoBXSZ95Psjh7

* Fix restart-command quoting and correct the failure-detection comment

Per @neilpang's second review round:

1. DEPLOY_JETKVM_RESTART_CMD was interpolated unescaped inside the
   detached command's own single-quoted "sh -c '...'" wrapper. A value
   containing a single quote (e.g. "sh -c 'sync; reboot'") broke that
   quoting, splitting the string so only part of the intended command
   ran, un-detached. Escape embedded single quotes (the standard
   '\'' substitution) before nesting the value, matching how a value
   with no quotes at all still behaves identically. Verified against
   sh and dash directly, and with a new local smoke-test case that
   actually executes the generated detached command and confirms both
   halves of a quoted restart command run intact.

2. The comment claiming "only a failure to launch it at all is caught
   below" was wrong: since the restart command runs as an unwaited
   background job (nohup ... &), the remote sh returns 0 as soon as
   that job is launched, regardless of whether nohup, sh, or the
   restart command itself actually exist or succeed -- measured 0 in
   both cases. Reworded so the comment describes what's actually
   caught (an outright SSH connection failure) instead of implying a
   guarantee the code doesn't provide. No behavior change from this
   half of the fix, comment-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7rTpUF3btoBXSZ95Psjh7

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:43:23 +02:00

254 lines
12 KiB
Bash

#!/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
}