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