Ver código fonte

suggest polkit rule when poweroff fails due to `org.freedesktop.DBus.Error.InteractiveAuthorizationRequired`

fixes part of https://github.com/fphammerle/systemctl-mqtt/issues/67
Fabian Peter Hammerle 3 meses atrás
pai
commit
452940d251
3 arquivos alterados com 92 adições e 2 exclusões
  1. 3 0
      CHANGELOG.md
  2. 32 1
      systemctl_mqtt/_dbus/login_manager.py
  3. 57 1
      tests/test_dbus.py

+ 3 - 0
CHANGELOG.md

@@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - entity `sensor.[hostname]_unit_system_[unit_name]_active_state`
     for each command-line parameter `--monitor-system-unit [unit_name]`
 - command-line option `--log-level {debug,info,warning,error,critical}`
+- suggest polkit rule when poweroff fails due to
+  `org.freedesktop.DBus.Error.InteractiveAuthorizationRequired`
+  (https://github.com/fphammerle/systemctl-mqtt/issues/67)
 - declare compatibility with `python3.11`, `python3.12` & `python3.13`
 
 ### Changed

+ 32 - 1
systemctl_mqtt/_dbus/login_manager.py

@@ -16,7 +16,10 @@
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 
 import datetime
+import getpass
+import json
 import logging
+import typing
 
 import jeepney
 import jeepney.io.blocking
@@ -29,6 +32,22 @@ _LOGIN_MANAGER_OBJECT_PATH = "/org/freedesktop/login1"
 _LOGIN_MANAGER_INTERFACE = "org.freedesktop.login1.Manager"
 
 
+def _get_username() -> typing.Optional[str]:
+    try:
+        return getpass.getuser()
+    except OSError:
+        # > Traceback (most recent call last):
+        # >   File "/usr/local/lib/python3.13/getpass.py", line 173, in getuser
+        # >     return pwd.getpwuid(os.getuid())[0]
+        # >            ~~~~~~~~~~~~^^^^^^^^^^^^^
+        # > KeyError: 'getpwuid(): uid not found: 100'
+        #
+        # > The above exception was the direct cause of the following exception:
+        # > …
+        # > OSError: No username set in the environment
+        return None
+
+
 def get_login_manager_signal_match_rule(member: str) -> jeepney.MatchRule:
     return jeepney.MatchRule(
         type="signal",
@@ -158,8 +177,20 @@ def schedule_shutdown(*, action: str, delay: datetime.timedelta) -> None:
             and exc.data == ("Interactive authentication required.",)
         ):
             _LOGGER.error(
-                "failed to schedule %s: unauthorized; missing polkit authorization rules?",
+                """failed to schedule %s: interactive authorization required
+
+create %s and insert the following rule:
+polkit.addRule(function(action, subject) {
+    if(action.id === %s && subject.user === %s) {
+        return polkit.Result.YES;
+    }
+});
+""",
                 action,
+                "/etc/polkit-1/rules.d/50-systemctl-mqtt.rules",
+                # org.freedesktop.login1.lock-sessions
+                json.dumps("org.freedesktop.login1.power-off"),
+                json.dumps(_get_username() or "USERNAME"),
             )
         else:
             _LOGGER.error("failed to schedule %s: %s", action, exc)

+ 57 - 1
tests/test_dbus.py

@@ -17,6 +17,7 @@
 
 import asyncio
 import datetime
+import getpass
 import logging
 import typing
 import unittest.mock
@@ -126,7 +127,17 @@ class DBusErrorResponseMock(jeepney.wrappers.DBusErrorResponse):
         (
             "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
             "Interactive authentication required.",
-            "unauthorized; missing polkit authorization rules?",
+            """interactive authorization required
+
+create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
+polkit.addRule(function(action, subject) {
+    if(action.id === "org.freedesktop.login1.power-off" && subject.user === "{{username}}") {
+        return polkit.Result.YES;
+    }
+});
+""".replace(
+                "{{username}}", getpass.getuser()
+            ),
         ),
     ],
 )
@@ -155,6 +166,51 @@ def test__schedule_shutdown_fail(
     assert "inhibitor" in caplog.records[2].message
 
 
+@pytest.mark.parametrize("action", ["poweroff"])
+@pytest.mark.parametrize(
+    ("error_name", "error_message", "log_message"),
+    [
+        (
+            "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
+            "Interactive authentication required.",
+            """interactive authorization required
+
+create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
+polkit.addRule(function(action, subject) {
+    if(action.id === "org.freedesktop.login1.power-off" && subject.user === "USERNAME") {
+        return polkit.Result.YES;
+    }
+});
+""",
+        ),
+    ],
+)
+def test__schedule_shutdown_fail_no_username(
+    caplog, action, error_name, error_message, log_message
+):
+    login_manager_mock = unittest.mock.MagicMock()
+    login_manager_mock.ScheduleShutdown.side_effect = DBusErrorResponseMock(
+        name=error_name,
+        data=(error_message,),
+    )
+    login_manager_mock.ListInhibitors.return_value = ([],)
+    with unittest.mock.patch(
+        "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
+        return_value=login_manager_mock,
+    ), unittest.mock.patch(
+        "getpass.getuser", side_effect=OSError("No username set in the environment")
+    ), caplog.at_level(
+        logging.ERROR
+    ):
+        systemctl_mqtt._dbus.login_manager.schedule_shutdown(
+            action=action, delay=datetime.timedelta(seconds=21)
+        )
+    login_manager_mock.ScheduleShutdown.assert_called_once()
+    assert len(caplog.records) == 1
+    assert caplog.records[0].levelno == logging.ERROR
+    assert caplog.records[0].message == f"failed to schedule {action}: {log_message}"
+
+
 def test_suspend(caplog):
     login_manager_mock = unittest.mock.MagicMock()
     with unittest.mock.patch(