From 698f6c73298e76c65ad7e726e462b50af1c5cf28 Mon Sep 17 00:00:00 2001 From: Qhilm <3350433+Qhilm@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:03:56 +0200 Subject: [PATCH] Feat: Shelly deploy hook for firmware 2.0.0+ (#7145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Shelly Gen3+ deploy hook with RFC 7616 HTTP Digest auth Adds deploy/shelly.sh for deploying Let's Encrypt HTTPS server certificates to Shelly Gen3+ devices (Gen4 tested) via JSON-RPC over HTTP. - RFC 7616 SHA-256 HTTP Digest authentication (Authorization header) - Uploads fullchain.pem and private key via Shelly.PutHTTPServerCert / PutHTTPServerKey - Auto-reboot support (SHELLY_REBOOT to disable) - Auth auto-detection: no password = no auth, password = Digest - Nonce counter (nc) increments per request per RFC 7616 - Tested against Shelly 2PM Gen4 (firmware 2.0.0) Also adds deploy/test_shelly.sh for self-testing the hook logic without a real device (mocked _post). * fix: address review feedback on shelly deploy hook - Fix _secure_debug calls to use two arguments (label + value) - Remove bash-only $RANDOM cnonce fallback; openssl always available - Parse $HTTP_HEADER directly instead of raw curl re-request - Detect auth via HTTP 401 status line, not empty response body - Route reboot through _shelly_rpc to rebuild auth header with correct nc - Remove export HTTPS_INSECURE=1 (no-op for http://, leaks to other hooks) - Clear _H1 before returning from shelly_deploy - Prefix all helper variables with _shelly_ to avoid namespace collisions - Delete deploy/test_shelly.sh (deploy/ files become hook names) - Fix missing trailing newline * fix: validate shelly JSON-RPC responses are valid JSON Non-JSON responses like HTTP 429 'Too Many Requests' would pass the empty-response and '"error"' checks and be reported as success. Now reject any response that doesn't start with '{' and contain '"id"'. * fix: add 1s delay between shelly cert/key clear and upload calls The Shelly device has a race condition where uploading data immediately after clearing the existing cert/key returns -103 'Missing required argument data!'. A 1-second delay fixes this. * fix: remove clear-before-upload in shelly deploy hook Shelly auto-removes all three TLS files (cert, key, CA bundle) when any single one is cleared. The old sequence clear-cert → upload-cert → clear-key → upload-key resulted in the key clear wiping the newly uploaded cert, leaving only the key at boot time. The mbedtls pk_check_pair then silently skipped the HTTPS listener. Fix: just upload directly (overwrite in place). No clearing needed. * Fix ShellCheck SC2090 and shfmt in shelly deploy hook SC2090: false positive on export _H1 (used quoted in _post) shfmt: no space after "<" in _json_encode redirects * moved two lines to cover the whole if block --------- Co-authored-by: neil Co-authored-by: cysimons --- deploy/shelly.sh | 280 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 deploy/shelly.sh diff --git a/deploy/shelly.sh b/deploy/shelly.sh new file mode 100644 index 00000000..dbdab346 --- /dev/null +++ b/deploy/shelly.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to a Shelly Gen3+ device. +# 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 +# acme.sh --deploy -d shelly.example.com --deploy-hook shelly +# ``` +# +# 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. +# Default: 1 (reboot after upload) +# +# Requirements: +# - Shelly Gen3+ device (Gen4 recommended) +# - Firmware 2.0.0+ for HTTPS server certificate support +# - curl or wget +# - openssl (for SHA-256 digest and random cnonce) +# +# The device must be reachable via HTTP on the local network. +# The hook uploads the fullchain.pem and private key, +# then reboots the device to apply the new certificate. +# +# Authentication uses standard RFC 7616 HTTP Digest (SHA-256) since +# firmware 2.0.0. The JSON-RPC auth object is not used for HTTP transport. +# +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +shelly_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 SHELLY_HOST + _getdeployconf SHELLY_PASSWORD + _getdeployconf SHELLY_USER + _getdeployconf SHELLY_REBOOT + + _debug SHELLY_HOST "$SHELLY_HOST" + _debug SHELLY_USER "$SHELLY_USER" + _secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD" + _debug SHELLY_REBOOT "$SHELLY_REBOOT" + + if [ -z "$SHELLY_HOST" ]; then + _err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device." + return 1 + fi + + SHELLY_USER="${SHELLY_USER:-admin}" + SHELLY_REBOOT="${SHELLY_REBOOT:-1}" + + _savedeployconf SHELLY_HOST "$SHELLY_HOST" + _savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD" + _savedeployconf SHELLY_USER "$SHELLY_USER" + _savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT" + + # --- Auth handshake (only if password is set) --- + _shelly_auth_header="" + if [ -n "$SHELLY_PASSWORD" ]; then + _info "Authenticating to Shelly device at $SHELLY_HOST" + if ! _shelly_handshake; then + _err "Authentication handshake failed. Check SHELLY_PASSWORD and device accessibility." + return 1 + fi + _info "Authentication successful" + fi + + # --- Upload certificate --- + _info "Uploading certificate to Shelly device at $SHELLY_HOST" + if ! _shelly_upload_cert; then + _err "Certificate upload failed" + return 1 + fi + + # --- Upload key --- + _info "Uploading private key to Shelly device" + if ! _shelly_upload_key; then + _err "Private key upload failed" + return 1 + fi + + _info "Certificate and key uploaded successfully" + + # --- Reboot --- + if [ "$SHELLY_REBOOT" != "0" ]; then + _info "Rebooting Shelly device to apply certificate" + # Reboot may close the connection before sending a response + _shelly_rpc "Shelly.Reboot" '{}' || _debug "Reboot may have closed connection (expected)" + _info "Reboot command sent. Device will restart shortly." + else + _info "Skipping reboot (SHELLY_REBOOT=0). Certificate will apply on next restart." + fi + + # Clear auth header so it does not leak to other hooks + export _H1="" + + return 0 +} + +# --- Helper functions --- + +# Perform RFC 7616 HTTP Digest auth handshake. +# Sets _shelly_auth_header on success (the Authorization header value). +_shelly_handshake() { + _inithttp + + _debug "Probing device for auth challenge" + + # 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" + + # Detect auth from HTTP status line rather than response body + if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then + # No auth challenge — device accepted the request without credentials + _debug "Device responded without auth challenge. Proceeding without auth." + return 0 + fi + + _shelly_realm="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*realm="//;s/".*//')" + _shelly_nonce="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*nonce="//;s/".*//')" + _shelly_qop="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*qop="//;s/".*//')" + + if [ -z "$_shelly_nonce" ]; then + _err "Failed to extract nonce from WWW-Authenticate header. Is SHELLY_PASSWORD correct?" + return 1 + fi + + _shelly_qop="${_shelly_qop:-auth}" + + _debug "Shelly realm: $_shelly_realm" + _debug "Shelly qop: $_shelly_qop" + _secure_debug "Shelly nonce" "$_shelly_nonce" + + # ha1 = SHA256(username:realm:password) + _shelly_ha1="$(printf '%s' "${SHELLY_USER}:${_shelly_realm}:${SHELLY_PASSWORD}" | _digest sha256 hex)" + _secure_debug "Shelly ha1" "$_shelly_ha1" + + # Generate client nonce (openssl is required for _digest, so always available) + _shelly_cnonce="$(${ACME_OPENSSL_BIN:-openssl} rand -hex 8 2>/dev/null)" + _debug "Shelly cnonce: $_shelly_cnonce" + + # Build the digest Authorization header value (stored for reuse) + _shelly_nc=1 + _shelly_build_auth_header + + return 0 +} + +# Check whether the HTTP response headers contain a digest auth challenge. +# Returns 0 (true) if a 401 with WWW-Authenticate is present. +_shelly_has_auth_challenge() { + _shelly_headers_file="$1" + _shelly_status="$(grep -i '^HTTP/' "$_shelly_headers_file" | _tail_n 1 | awk '{print $2}')" + [ "$_shelly_status" = "401" ] && grep -qi '^WWW-Authenticate:' "$_shelly_headers_file" +} + +# Build or rebuild the RFC 7616 Authorization header. +# Uses: _shelly_ha1, _shelly_nonce, _shelly_cnonce, _shelly_qop, _shelly_realm, _shelly_nc +# Sets: _shelly_auth_header +_shelly_build_auth_header() { + _shelly_nc_hex="$(printf '%08x' "$_shelly_nc")" + + # ha2 = SHA256(POST:/rpc) + _shelly_ha2="$(printf '%s' "POST:/rpc" | _digest sha256 hex)" + + # response = SHA256(ha1:nonce:nc:cnonce:qop:ha2) + _shelly_digest_response="$(printf '%s' "${_shelly_ha1}:${_shelly_nonce}:${_shelly_nc_hex}:${_shelly_cnonce}:${_shelly_qop}:${_shelly_ha2}" | _digest sha256 hex)" + + # Build the Authorization header value (without the "Authorization: " prefix) + _shelly_auth_header="Digest username=\"${SHELLY_USER}\", realm=\"${_shelly_realm}\", nonce=\"${_shelly_nonce}\", uri=\"/rpc\", qop=${_shelly_qop}, nc=${_shelly_nc_hex}, cnonce=\"${_shelly_cnonce}\", response=\"${_shelly_digest_response}\", algorithm=SHA-256" + + _secure_debug "Authorization header" "$_shelly_auth_header" +} + +# Make a Shelly JSON-RPC call. +# Usage: _shelly_rpc +# Returns 0 on success, 1 on error. +_shelly_rpc() { + _shelly_method="$1" + _shelly_params="$2" + + _shelly_body='{"id":1,"method":"'"$_shelly_method"'","params":'"$_shelly_params"'}' + + _debug "RPC method: $_shelly_method" + _debug2 "RPC body: $_shelly_body" + + # shellcheck disable=SC2090 + if [ -n "$_shelly_auth_header" ]; then + export _H1="Authorization: $_shelly_auth_header" + else + export _H1="" + fi + + _post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json" + _shelly_ret=$? + + if [ "$_shelly_ret" != "0" ]; then + _err "HTTP request failed for $_shelly_method (curl/wget error $_shelly_ret)" + return 1 + fi + + # Empty response means something went wrong (auth required but not provided, etc.) + if [ -z "$response" ]; then + _err "Empty response from Shelly device. If authentication is enabled on the device, set SHELLY_PASSWORD." + return 1 + fi + + # Validate response looks like a Shelly JSON-RPC response. + # Catches non-JSON responses such as HTTP 429 "Too Many Requests" which + # would otherwise pass the empty and "error" checks below. + if ! _startswith "$response" '{' || ! _contains "$response" '"id"'; then + _err "Invalid response from Shelly device: $response" + return 1 + fi + + # Check for JSON-RPC error in response + if _contains "$response" '"error"'; then + _err "RPC error from Shelly: $response" + return 1 + fi + + _debug "RPC response: $response" + + # Increment nonce counter and rebuild auth header for next request + if [ -n "$_shelly_auth_header" ]; then + _shelly_nc=$((_shelly_nc + 1)) + _shelly_build_auth_header + fi + + return 0 +} + +# Upload the certificate to the device. +# Note: We do NOT clear the existing certificate first, because the Shelly +# auto-removes all three files (cert, key, CA) when any one is cleared. +# Uploading overwrites in place — no clearing needed. +_shelly_upload_cert() { + _shelly_cert_data="$(_json_encode <"$_cfullchain")" + + _debug "Uploading certificate" + if ! _shelly_rpc "Shelly.PutHTTPServerCert" '{"data":"'"$_shelly_cert_data"'"}'; then + _err "Failed to upload certificate to device" + return 1 + fi + + return 0 +} + +# Upload the private key to the device. +# Note: Do not clear first — see _shelly_upload_cert for rationale. +_shelly_upload_key() { + _shelly_key_data="$(_json_encode <"$_ckey")" + + _debug "Uploading key" + if ! _shelly_rpc "Shelly.PutHTTPServerKey" '{"data":"'"$_shelly_key_data"'"}'; then + _err "Failed to upload key to device" + return 1 + fi + + return 0 +}