__init__.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. # systemctl-mqtt - MQTT client triggering shutdown on systemd-based systems
  2. #
  3. # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. import argparse
  18. import datetime
  19. import functools
  20. import logging
  21. import os
  22. import pathlib
  23. import socket
  24. import threading
  25. import typing
  26. import dbus
  27. import dbus.mainloop.glib
  28. import dbus.types
  29. # black keeps inserting a blank line above
  30. # https://pygobject.readthedocs.io/en/latest/getting_started.html#ubuntu-logo-ubuntu-debian-logo-debian
  31. import gi.repository.GLib # pylint-import-requirements: imports=PyGObject
  32. import paho.mqtt.client
  33. _LOGGER = logging.getLogger(__name__)
  34. _SHUTDOWN_DELAY = datetime.timedelta(seconds=4)
  35. def _get_login_manager() -> dbus.proxies.Interface:
  36. # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html
  37. bus = dbus.SystemBus()
  38. proxy = bus.get_object(
  39. bus_name="org.freedesktop.login1", object_path="/org/freedesktop/login1"
  40. ) # type: dbus.proxies.ProxyObject
  41. # https://freedesktop.org/wiki/Software/systemd/logind/
  42. return dbus.Interface(object=proxy, dbus_interface="org.freedesktop.login1.Manager")
  43. def _log_shutdown_inhibitors(login_manager: dbus.proxies.Interface) -> None:
  44. if _LOGGER.getEffectiveLevel() > logging.DEBUG:
  45. return
  46. found_inhibitor = False
  47. try:
  48. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  49. for what, who, why, mode, uid, pid in login_manager.ListInhibitors():
  50. if "shutdown" in what:
  51. found_inhibitor = True
  52. _LOGGER.debug(
  53. "detected shutdown inhibitor %s (pid=%u, uid=%u, mode=%s): %s",
  54. who,
  55. pid,
  56. uid,
  57. mode,
  58. why,
  59. )
  60. except dbus.DBusException as exc:
  61. _LOGGER.warning(
  62. "failed to fetch shutdown inhibitors: %s", exc.get_dbus_message()
  63. )
  64. return
  65. if not found_inhibitor:
  66. _LOGGER.debug("no shutdown inhibitor locks found")
  67. def _schedule_shutdown(action: str) -> None:
  68. # https://github.com/systemd/systemd/blob/v237/src/systemctl/systemctl.c#L8553
  69. assert action in ["poweroff", "reboot"], action
  70. shutdown_datetime = datetime.datetime.now() + _SHUTDOWN_DELAY
  71. # datetime.datetime.isoformat(timespec=) not available in python3.5
  72. # https://github.com/python/cpython/blob/v3.5.9/Lib/datetime.py#L1552
  73. _LOGGER.info(
  74. "scheduling %s for %s", action, shutdown_datetime.strftime("%Y-%m-%d %H:%M:%S"),
  75. )
  76. shutdown_epoch_usec = int(shutdown_datetime.timestamp() * 10 ** 6)
  77. login_manager = _get_login_manager()
  78. try:
  79. # $ gdbus introspect --system --dest org.freedesktop.login1 \
  80. # --object-path /org/freedesktop/login1 | grep -A 1 ScheduleShutdown
  81. # ScheduleShutdown(in s arg_0,
  82. # in t arg_1);
  83. # $ gdbus call --system --dest org.freedesktop.login1 \
  84. # --object-path /org/freedesktop/login1 \
  85. # --method org.freedesktop.login1.Manager.ScheduleShutdown \
  86. # poweroff "$(date --date=10min +%s)000000"
  87. # $ dbus-send --type=method_call --print-reply --system --dest=org.freedesktop.login1 \
  88. # /org/freedesktop/login1 \
  89. # org.freedesktop.login1.Manager.ScheduleShutdown \
  90. # string:poweroff "uint64:$(date --date=10min +%s)000000"
  91. login_manager.ScheduleShutdown(action, shutdown_epoch_usec)
  92. except dbus.DBusException as exc:
  93. exc_msg = exc.get_dbus_message()
  94. if "authentication required" in exc_msg.lower():
  95. _LOGGER.error(
  96. "failed to schedule %s: unauthorized; missing polkit authorization rules?",
  97. action,
  98. )
  99. else:
  100. _LOGGER.error("failed to schedule %s: %s", action, exc_msg)
  101. _log_shutdown_inhibitors(login_manager)
  102. class _State:
  103. def __init__(self, mqtt_topic_prefix: str) -> None:
  104. self._mqtt_topic_prefix = mqtt_topic_prefix
  105. self._login_manager = _get_login_manager() # type: dbus.proxies.Interface
  106. self._login_manager.connect_to_signal(
  107. signal_name="PrepareForShutdown",
  108. handler_function=self.prepare_for_shutdown_handler,
  109. )
  110. self._shutdown_lock = None # type: typing.Optional[dbus.types.UnixFd]
  111. self._shutdown_lock_mutex = threading.Lock()
  112. @property
  113. def mqtt_topic_prefix(self) -> str:
  114. return self._mqtt_topic_prefix
  115. def acquire_shutdown_lock(self) -> None:
  116. with self._shutdown_lock_mutex:
  117. assert self._shutdown_lock is None
  118. # https://www.freedesktop.org/wiki/Software/systemd/inhibit/
  119. self._shutdown_lock = _get_login_manager().Inhibit(
  120. "shutdown", "systemctl-mqtt", "Report shutdown via MQTT", "delay",
  121. )
  122. _LOGGER.debug("acquired shutdown inhibitor lock")
  123. def release_shutdown_lock(self) -> None:
  124. with self._shutdown_lock_mutex:
  125. if self._shutdown_lock:
  126. # https://dbus.freedesktop.org/doc/dbus-python/dbus.types.html#dbus.types.UnixFd.take
  127. os.close(self._shutdown_lock.take())
  128. _LOGGER.debug("released shutdown inhibitor lock")
  129. self._shutdown_lock = None
  130. def prepare_for_shutdown_handler(self, active: bool) -> None:
  131. if active:
  132. _LOGGER.debug("system preparing for shutdown")
  133. self.release_shutdown_lock()
  134. else:
  135. _LOGGER.debug("system shutdown failed?")
  136. self.acquire_shutdown_lock()
  137. class _MQTTAction:
  138. # pylint: disable=too-few-public-methods
  139. def __init__(self, name: str, action: typing.Callable) -> None:
  140. self.name = name
  141. self.action = action
  142. def mqtt_message_callback(
  143. self,
  144. mqtt_client: paho.mqtt.client.Client,
  145. state: _State,
  146. message: paho.mqtt.client.MQTTMessage,
  147. ) -> None:
  148. # pylint: disable=unused-argument; callback
  149. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L3416
  150. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L469
  151. _LOGGER.debug("received topic=%s payload=%r", message.topic, message.payload)
  152. if message.retain:
  153. _LOGGER.info("ignoring retained message")
  154. return
  155. _LOGGER.debug("executing action %s (%r)", self.name, self.action)
  156. self.action()
  157. _LOGGER.debug("completed action %s (%r)", self.name, self.action)
  158. _MQTT_TOPIC_SUFFIX_ACTION_MAPPING = {
  159. "poweroff": _MQTTAction(
  160. name="poweroff", action=functools.partial(_schedule_shutdown, action="poweroff")
  161. ),
  162. }
  163. def _mqtt_on_connect(
  164. mqtt_client: paho.mqtt.client.Client,
  165. state: _State,
  166. flags: typing.Dict,
  167. return_code: int,
  168. ) -> None:
  169. # pylint: disable=unused-argument; callback
  170. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L441
  171. assert return_code == 0, return_code # connection accepted
  172. mqtt_broker_host, mqtt_broker_port = mqtt_client.socket().getpeername()
  173. _LOGGER.debug("connected to MQTT broker %s:%d", mqtt_broker_host, mqtt_broker_port)
  174. state.acquire_shutdown_lock()
  175. for topic_suffix, action in _MQTT_TOPIC_SUFFIX_ACTION_MAPPING.items():
  176. topic = state.mqtt_topic_prefix + "/" + topic_suffix
  177. _LOGGER.info("subscribing to %s", topic)
  178. mqtt_client.subscribe(topic)
  179. mqtt_client.message_callback_add(
  180. sub=topic, callback=action.mqtt_message_callback
  181. )
  182. _LOGGER.debug(
  183. "registered MQTT callback for topic %s triggering %r", topic, action.action
  184. )
  185. def _run(
  186. mqtt_host: str,
  187. mqtt_port: int,
  188. mqtt_username: typing.Optional[str],
  189. mqtt_password: typing.Optional[str],
  190. mqtt_topic_prefix: str,
  191. ) -> None:
  192. # https://dbus.freedesktop.org/doc/dbus-python/tutorial.html#setting-up-an-event-loop
  193. dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
  194. # https://pypi.org/project/paho-mqtt/
  195. mqtt_client = paho.mqtt.client.Client(
  196. userdata=_State(mqtt_topic_prefix=mqtt_topic_prefix)
  197. )
  198. mqtt_client.on_connect = _mqtt_on_connect
  199. mqtt_client.tls_set(ca_certs=None) # enable tls trusting default system certs
  200. _LOGGER.info(
  201. "connecting to MQTT broker %s:%d", mqtt_host, mqtt_port,
  202. )
  203. if mqtt_username:
  204. mqtt_client.username_pw_set(username=mqtt_username, password=mqtt_password)
  205. elif mqtt_password:
  206. raise ValueError("Missing MQTT username")
  207. mqtt_client.connect(host=mqtt_host, port=mqtt_port)
  208. # loop_start runs loop_forever in a new thread (daemon)
  209. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1814
  210. # loop_forever attempts to reconnect if disconnected
  211. # https://github.com/eclipse/paho.mqtt.python/blob/v1.5.0/src/paho/mqtt/client.py#L1744
  212. mqtt_client.loop_start()
  213. try:
  214. gi.repository.GLib.MainLoop().run()
  215. finally:
  216. # blocks until loop_forever stops
  217. _LOGGER.debug("waiting for MQTT loop to stop")
  218. mqtt_client.loop_stop()
  219. _LOGGER.debug("MQTT loop stopped")
  220. def _get_hostname() -> str:
  221. return socket.gethostname()
  222. def _main() -> None:
  223. logging.basicConfig(
  224. level=logging.DEBUG,
  225. format="%(asctime)s:%(levelname)s:%(message)s",
  226. datefmt="%Y-%m-%dT%H:%M:%S%z",
  227. )
  228. argparser = argparse.ArgumentParser(
  229. description="MQTT client triggering shutdown on systemd-based systems",
  230. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  231. )
  232. argparser.add_argument("--mqtt-host", type=str, required=True)
  233. argparser.add_argument("--mqtt-port", type=int, default=8883)
  234. argparser.add_argument("--mqtt-username", type=str)
  235. password_argument_group = argparser.add_mutually_exclusive_group()
  236. password_argument_group.add_argument("--mqtt-password", type=str)
  237. password_argument_group.add_argument(
  238. "--mqtt-password-file",
  239. type=pathlib.Path,
  240. metavar="PATH",
  241. dest="mqtt_password_path",
  242. help="stripping trailing newline",
  243. )
  244. # https://www.home-assistant.io/docs/mqtt/discovery/#discovery_prefix
  245. argparser.add_argument(
  246. "--mqtt-topic-prefix",
  247. type=str,
  248. default="systemctl/" + _get_hostname(),
  249. help=" ", # show default
  250. )
  251. args = argparser.parse_args()
  252. if args.mqtt_password_path:
  253. # .read_text() replaces \r\n with \n
  254. mqtt_password = args.mqtt_password_path.read_bytes().decode()
  255. if mqtt_password.endswith("\r\n"):
  256. mqtt_password = mqtt_password[:-2]
  257. elif mqtt_password.endswith("\n"):
  258. mqtt_password = mqtt_password[:-1]
  259. else:
  260. mqtt_password = args.mqtt_password
  261. _run(
  262. mqtt_host=args.mqtt_host,
  263. mqtt_port=args.mqtt_port,
  264. mqtt_username=args.mqtt_username,
  265. mqtt_password=mqtt_password,
  266. mqtt_topic_prefix=args.mqtt_topic_prefix,
  267. )