Skip to content

fence_shelly: new fence agent for Shelly Gen2+ Switches #628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions agents/shelly/fence_shelly_gen2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
#!@PYTHON@ -tt
import sys
import pycurl
import io
import json
import logging
import atexit

sys.path.append("@FENCEAGENTSLIBDIR@")
from fencing import *
from fencing import fail, run_delay, EC_LOGIN_DENIED, EC_STATUS


state = {True: "on", False: "off"}


def get_power_status(conn, opt):
try:
result = send_command(conn, gen_payload(opt, "Switch.GetStatus"))
except Exception as e:
fail(EC_STATUS)
# Shelly Documentation Uses `params` subkey:
# https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch/#switchgetstatus-example
# Observed behavior (ShellyHTTP/1.0.0) uses `result` subkey
# Used Shelly Plus Plug US - FW 1.5.1
if "params" in result:
subkey = "params"
elif "result" in result:
subkey = "result"
return state[result[subkey]["output"]]


def set_power_status(conn, opt):
action = {state[k]: k for k in state}
output = action[opt["--action"]]
try:
result = send_command(conn, gen_payload(opt, "Switch.Set", output=output))
except Exception as e:
fail(EC_STATUS)


# We use method here as the RPC procedure not HTTP method as all commands use POST
def gen_payload(opt, method, output=None):
if "--sw_id" in opt:
sw_id = opt["--sw_id"]
else:
sw_id = 0
ret = {"id": 1,
"method": method,
"params": {"id": sw_id}}
if output is not None:
ret["params"]["on"] = output
return ret


def connect(opt):
conn = pycurl.Curl()

## setup correct URL
if "--ssl-secure" in opt or "--ssl-insecure" in opt:
conn.base_url = "https:"
else:
conn.base_url = "http:"

api_path = "/rpc"
conn.base_url += "//" + opt["--ip"] + ":" + str(opt["--ipport"]) + api_path

## send command through pycurl
conn.setopt(pycurl.HTTPHEADER, [
"Accept: application/json",
])

# Shelly always uses a default user of admin
# ex. https://shelly-api-docs.shelly.cloud/gen2/General/Authentication#successful-request-with-authentication-details
if "--password" in opt:
conn.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_ANY)
conn.setopt(pycurl.USERPWD, "admin:" + opt["--password"])
conn.setopt(pycurl.TIMEOUT, int(opt["--shell-timeout"]))
if "--ssl-secure" in opt:
conn.setopt(pycurl.SSL_VERIFYPEER, 1)
conn.setopt(pycurl.SSL_VERIFYHOST, 2)
elif "--ssl-insecure" in opt:
conn.setopt(pycurl.SSL_VERIFYPEER, 0)
conn.setopt(pycurl.SSL_VERIFYHOST, 0)

# Check general reachability (unprotected method)
try:
device_info_payload = {"id": 1, "method": "Shelly.GetDeviceInfo"}
_ = send_command(conn, device_info_payload)
except Exception as e:
logging.debug("Failed: {}".format(e))
fail(EC_LOGIN_DENIED)

# Check method requiring authentication
try:
_ = send_command(conn, gen_payload(opt, "Switch.GetStatus"))
except Exception as e:
logging.debug("Invalid Authentication: {}".format(e))
return conn


def send_command(conn, payload):
conn.setopt(pycurl.URL, conn.base_url.encode("ascii"))
conn.setopt(pycurl.POSTFIELDS, json.dumps(payload))
web_buffer = io.BytesIO()
conn.setopt(pycurl.WRITEFUNCTION, web_buffer.write)
try:
conn.perform()
except Exception as e:
raise(e)
rc = conn.getinfo(pycurl.HTTP_CODE)
result = web_buffer.getvalue().decode("UTF-8")
web_buffer.close()
if rc != 200:
if len(result) > 0:
raise Exception("Remote returned {}: {}".format(rc, result))
else:
raise Exception("Remote returned {} for request to {}".format(rc, conn.base_url))
if len(result) > 0:
result = json.loads(result)
logging.debug("url: {}".format(conn.base_url))
logging.debug("POST method payload: {}".format(payload))
logging.debug("response code: {}".format(rc))
logging.debug("result: {}\n".format(result))
return result

def main():
device_opt = [
"ipaddr",
"passwd",
"ssl",
"notls",
"web",
"port",
"sw_id",
]

all_opt["sw_id"] = {
Copy link
Collaborator

@oalbrigt oalbrigt May 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use plug like we do for the other agents.

You can override "help" and "shortdesc" just like you've done for a couple of default values below.

Remember to run make xml-upload after and add the updated metadata to the commit.

"getopt": ":",
"longopt": "sw-id",
"help": "--sw-id=[id] Id of the Switch component instance",
"default": "0",
"required": "0",
"shortdesc": "The id of the Switch component instance",
"order": 2
}

atexit.register(atexit_handler)
all_opt["shell_timeout"]["default"] = "5"
all_opt["power_wait"]["default"] = "1"

options = check_input(device_opt, process_input(device_opt))

docs = {}
docs["shortdesc"] = "Fence agent for Shelly Gen 2+ Switches"
docs["longdesc"] = """fence_shelly_gen2 is a Power Fencing agent which can be \
used with Shelly Switches supporting the gen 2+ API to fence attached hardware."""
docs["vendorurl"] = "https://shelly-api-docs.shelly.cloud/gen2/"
show_docs(options, docs)

####
## Fence operations
####
run_delay(options)
conn = connect(options)
atexit.register(conn.close)
result = fence_action(conn, options, set_power_status, get_power_status)
sys.exit(result)

if __name__ == "__main__":
main()

12 changes: 12 additions & 0 deletions fence-agents.spec.in
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ fence-agents-rsb \\
fence-agents-sanbox2 \\
fence-agents-sbd \\
fence-agents-scsi \\
fence-agents-shelly \\
fence-agents-vbox \\
fence-virt \\
fence-agents-vmware \\
Expand Down Expand Up @@ -1117,6 +1118,17 @@ Fence agent for SCSI persistent reservations.
%{_datadir}/cluster/fence_scsi_check_hardreboot
%{_mandir}/man8/fence_scsi.8*

%package shelly
License: GPL-2.0-or-later AND LGPL-2.0-or-later
Summary: Fence agent for Shelly Switches
Requires: fence-agents-common = %{version}-%{release}
BuildArch: noarch
%description shelly
Fence agent for Shelly Switches.
%files shelly
%{_sbindir}/fence_shelly_gen2
%{_mandir}/man8/fence_shelly_gen2.8*

%package vbox
License: GPL-2.0-or-later AND LGPL-2.0-or-later
Summary: Fence agent for VirtualBox
Expand Down
182 changes: 182 additions & 0 deletions tests/data/metadata/fence_shelly_gen2.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
<?xml version="1.0" ?>
<resource-agent name="fence_shelly_gen2" shortdesc="Fence agent for Shelly Gen 2+ Switches" >
<longdesc>fence_shelly_gen2 is a Power Fencing agent which can be used with Shelly Switches supporting the gen 2+ API to fence attached hardware.</longdesc>
<vendor-url>https://shelly-api-docs.shelly.cloud/gen2/</vendor-url>
<parameters>
<parameter name="action" unique="0" required="1">
<getopt mixed="-o, --action=[action]" />
<content type="string" default="reboot" />
<shortdesc lang="en">Fencing action</shortdesc>
</parameter>
<parameter name="ip" unique="0" required="1" obsoletes="ipaddr">
<getopt mixed="-a, --ip=[ip]" />
<content type="string" />
<shortdesc lang="en">IP address or hostname of fencing device</shortdesc>
</parameter>
<parameter name="ipaddr" unique="0" required="1" deprecated="1">
<getopt mixed="-a, --ip=[ip]" />
<content type="string" />
<shortdesc lang="en">IP address or hostname of fencing device</shortdesc>
</parameter>
<parameter name="ipport" unique="0" required="0">
<getopt mixed="-u, --ipport=[port]" />
<content type="integer" default="80" />
<shortdesc lang="en">TCP/UDP port to use for connection with device</shortdesc>
</parameter>
<parameter name="notls" unique="0" required="0">
<getopt mixed="-t, --notls" />
<content type="boolean" />
<shortdesc lang="en">Disable TLS negotiation and force SSL3.0. This should only be used for devices that do not support TLS1.0 and up.</shortdesc>
</parameter>
<parameter name="passwd" unique="0" required="0" deprecated="1">
<getopt mixed="-p, --password=[password]" />
<content type="string" />
<shortdesc lang="en">Login password or passphrase</shortdesc>
</parameter>
<parameter name="passwd_script" unique="0" required="0" deprecated="1">
<getopt mixed="-S, --password-script=[script]" />
<content type="string" />
<shortdesc lang="en">Script to run to retrieve password</shortdesc>
</parameter>
<parameter name="password" unique="0" required="0" obsoletes="passwd">
<getopt mixed="-p, --password=[password]" />
<content type="string" />
<shortdesc lang="en">Login password or passphrase</shortdesc>
</parameter>
<parameter name="password_script" unique="0" required="0" obsoletes="passwd_script">
<getopt mixed="-S, --password-script=[script]" />
<content type="string" />
<shortdesc lang="en">Script to run to retrieve password</shortdesc>
</parameter>
<parameter name="plug" unique="0" required="1" obsoletes="port">
<getopt mixed="-n, --plug=[id]" />
<content type="string" />
<shortdesc lang="en">Physical plug number on device, UUID or identification of machine</shortdesc>
</parameter>
<parameter name="port" unique="0" required="1" deprecated="1">
<getopt mixed="-n, --plug=[id]" />
<content type="string" />
<shortdesc lang="en">Physical plug number on device, UUID or identification of machine</shortdesc>
</parameter>
<parameter name="ssl" unique="0" required="0">
<getopt mixed="-z, --ssl" />
<content type="boolean" />
<shortdesc lang="en">Use SSL connection with verifying certificate</shortdesc>
</parameter>
<parameter name="ssl_insecure" unique="0" required="0">
<getopt mixed="--ssl-insecure" />
<content type="boolean" />
<shortdesc lang="en">Use SSL connection without verifying certificate</shortdesc>
</parameter>
<parameter name="ssl_secure" unique="0" required="0">
<getopt mixed="--ssl-secure" />
<content type="boolean" />
<shortdesc lang="en">Use SSL connection with verifying certificate</shortdesc>
</parameter>
<parameter name="sw_id" unique="0" required="0">
<getopt mixed="--sw-id=[id]" />
<content type="string" default="0" />
<shortdesc lang="en">The id of the Switch component instance</shortdesc>
</parameter>
<parameter name="quiet" unique="0" required="0">
<getopt mixed="-q, --quiet" />
<content type="boolean" />
<shortdesc lang="en">Disable logging to stderr. Does not affect --verbose or --debug-file or logging to syslog.</shortdesc>
</parameter>
<parameter name="verbose" unique="0" required="0">
<getopt mixed="-v, --verbose" />
<content type="boolean" />
<shortdesc lang="en">Verbose mode. Multiple -v flags can be stacked on the command line (e.g., -vvv) to increase verbosity.</shortdesc>
</parameter>
<parameter name="verbose_level" unique="0" required="0">
<getopt mixed="--verbose-level" />
<content type="integer" />
<shortdesc lang="en">Level of debugging detail in output. Defaults to the number of --verbose flags specified on the command line, or to 1 if verbose=1 in a stonith device configuration (i.e., on stdin).</shortdesc>
</parameter>
<parameter name="debug" unique="0" required="0" deprecated="1">
<getopt mixed="-D, --debug-file=[debugfile]" />
<content type="string" />
<shortdesc lang="en">Write debug information to given file</shortdesc>
</parameter>
<parameter name="debug_file" unique="0" required="0" obsoletes="debug">
<getopt mixed="-D, --debug-file=[debugfile]" />
<shortdesc lang="en">Write debug information to given file</shortdesc>
</parameter>
<parameter name="version" unique="0" required="0">
<getopt mixed="-V, --version" />
<content type="boolean" />
<shortdesc lang="en">Display version information and exit</shortdesc>
</parameter>
<parameter name="help" unique="0" required="0">
<getopt mixed="-h, --help" />
<content type="boolean" />
<shortdesc lang="en">Display help and exit</shortdesc>
</parameter>
<parameter name="plug_separator" unique="0" required="0">
<getopt mixed="--plug-separator=[char]" />
<content type="string" default="," />
<shortdesc lang="en">Separator for plug parameter when specifying more than 1 plug</shortdesc>
</parameter>
<parameter name="separator" unique="0" required="0">
<getopt mixed="-C, --separator=[char]" />
<content type="string" default="," />
<shortdesc lang="en">Separator for CSV created by 'list' operation</shortdesc>
</parameter>
<parameter name="delay" unique="0" required="0">
<getopt mixed="--delay=[seconds]" />
<content type="second" default="0" />
<shortdesc lang="en">Wait X seconds before fencing is started</shortdesc>
</parameter>
<parameter name="disable_timeout" unique="0" required="0">
<getopt mixed="--disable-timeout=[true/false]" />
<content type="string" />
<shortdesc lang="en">Disable timeout (true/false) (default: true when run from Pacemaker 2.0+)</shortdesc>
</parameter>
<parameter name="login_timeout" unique="0" required="0">
<getopt mixed="--login-timeout=[seconds]" />
<content type="second" default="5" />
<shortdesc lang="en">Wait X seconds for cmd prompt after login</shortdesc>
</parameter>
<parameter name="power_timeout" unique="0" required="0">
<getopt mixed="--power-timeout=[seconds]" />
<content type="second" default="20" />
<shortdesc lang="en">Test X seconds for status change after ON/OFF</shortdesc>
</parameter>
<parameter name="power_wait" unique="0" required="0">
<getopt mixed="--power-wait=[seconds]" />
<content type="second" default="1" />
<shortdesc lang="en">Wait X seconds after issuing ON/OFF</shortdesc>
</parameter>
<parameter name="shell_timeout" unique="0" required="0">
<getopt mixed="--shell-timeout=[seconds]" />
<content type="second" default="5" />
<shortdesc lang="en">Wait X seconds for cmd prompt after issuing command</shortdesc>
</parameter>
<parameter name="stonith_status_sleep" unique="0" required="0">
<getopt mixed="--stonith-status-sleep=[seconds]" />
<content type="second" default="1" />
<shortdesc lang="en">Sleep X seconds between status calls during a STONITH action</shortdesc>
</parameter>
<parameter name="retry_on" unique="0" required="0">
<getopt mixed="--retry-on=[attempts]" />
<content type="integer" default="1" />
<shortdesc lang="en">Count of attempts to retry power on</shortdesc>
</parameter>
<parameter name="gnutlscli_path" unique="0" required="0">
<getopt mixed="--gnutlscli-path=[path]" />
<shortdesc lang="en">Path to gnutls-cli binary</shortdesc>
</parameter>
</parameters>
<actions>
<action name="on" automatic="0"/>
<action name="off" />
<action name="reboot" />
<action name="status" />
<action name="list" />
<action name="list-status" />
<action name="monitor" />
<action name="metadata" />
<action name="manpage" />
<action name="validate-all" />
</actions>
</resource-agent>