diff --git a/CHANGES b/CHANGES index fe990480e..4686bd425 100644 --- a/CHANGES +++ b/CHANGES @@ -59,6 +59,7 @@ Version 1.6.0-dev (development of upcoming release) * Fixed: Stop waking up monitor when inhibit suspend on backup starts (#714, #1090) * Fixed: Avoid shutdown confirmation dialog on Budgie and Cinnamon desktop environments (#788) * Fixed: Crash in "Manage profiles" dialog when using "qt6ct" (#2128) +* Fixed: **Breaking** Systray icon consider restricted privileges of scheduled backup profiles run by foreign users (#2237, reported and co-contributed by @samo-sk) * Fixed: Allow in BITs root-mode opening URLs in extern browse Version 1.5.6 (2025-10-05) diff --git a/qt/plugins/systrayiconplugin.py b/qt/plugins/systrayiconplugin.py index cad10eba7..bb0a34214 100644 --- a/qt/plugins/systrayiconplugin.py +++ b/qt/plugins/systrayiconplugin.py @@ -107,6 +107,9 @@ def processBegin(self): # noqa: N802 except Exception as exc: logger.critical(f'Undefined situation: {exc}', self) + else: + logger.info('Systray icon sub process started.') + # def processEnd(self): # """Dev note(2025-07, buhtz): Method makes no sense to me anymore. # Remove it soon. diff --git a/qt/qtsystrayicon.py b/qt/qtsystrayicon.py index aea87b30d..1b2ee5618 100644 --- a/qt/qtsystrayicon.py +++ b/qt/qtsystrayicon.py @@ -13,6 +13,8 @@ """Separate application managing the systray icon""" import sys import os +import re +import json import subprocess import signal import textwrap @@ -69,9 +71,17 @@ def __init__(self): self.status_icon = self._create_status_icon() self.contextMenu = QMenu() + # The systray icon instance runs as the same user and with similar + # privilegs as the BIT instance itself; e.g. root. + # We need to know which user "owns" the desktop. If it is another + # user, the systray instance should not expose sensible data like + # paths of files and dirs to backup. + desktop_user = self._determine_desktop_session_user() + self.menuProfileName = self.contextMenu.addAction( - _('Profile: {profile_name}').format( - profile_name=self.config.profileName()) + _('Profile: {profile_name} USER: {desktop_user}').format( + profile_name=self.config.profileName(), + desktop_user=desktop_user) ) self.contextMenu.addSeparator() @@ -143,7 +153,6 @@ def _create_status_icon(self) -> QSystemTrayIcon: return QSystemTrayIcon(self.get_dark_icon()) - def _create_progress_bar(self) -> QProgressBar: bar = QProgressBar() @@ -285,6 +294,146 @@ def onBtnStop(self): self.btnResume.setEnabled(False) self.snapshots.setTakeSnapshotMessage(0, 'Backup terminated') + def _get_desktop_user_via_loginctl(self) -> str | None: + """Get name of user logged in to the current desktop session using + loginctl. + """ + + try: + # get list of sessions + output = subprocess.check_output( + ['loginctl', 'list-sessions', '--no-legend', '--json=short'], + text=True) + + except FileNotFoundError: + logger.warning( + 'Can not determine user name of current desktop ' + 'session because "loginctl" is not available.' + ) + return None + + except Exception as exc: + logger.error( + 'Unexpected error while determining user name of ' + f'current desktop session: {exc}' + ) + return None + + sessions = [] + + # Check each session + for session in json.loads(output): + # Ignore none-user sessions + if session.get('class') != 'user': + continue + + # properties of the session + info = subprocess.check_output( + [ + 'loginctl', + 'show-session', + str(session['session']), + '--property=Active', + '--property=Name', + '--property=Seat', + '--property=Type', + '--property=Dispplay', + ], + text=True + ).strip() + + props = dict(line.split('=', 1) for line in info.splitlines()) + sessions.append(props) + + display = os.environ.get('DISPLAY') + + if display: + display = display.split('.')[0] + + matches = [ + s for s in sessions + if s.get('Display', '').split('.')[0] == display + ] + + if len(matches) == 1: + logger.info( + f'VARIANT - systemd loginctl DISPLAY: {matches[0]=}', + self, + ) + return matches[0].get('Name') + + return None + + # Fallback checking for one active session, if DISPLAY not set + fallback = [ + s for s in sessions + if s.get('Active', '').lower() == 'yes' + and s.get('Seat') == 'seat0' + and s.get('Type') in ('x11', 'wayland') + ] + + if len(fallback) == 1: + logger.info( + f'VARIANT - systemd loginctl SAFE FALLBACK: {fallback[0]=}', + self, + ) + return fallback[0].get('Name') + + return None + + def _get_desktop_user_via_x11_who(self) -> str | None: + """Using 'who' to determine the current DISPLAY's user. + + The output of 'who' can look like this: + + user sshd pts/0 2025-09-16 08:30 (fe80::d65:ea81:c46f:7f0d%eth0) + lightdm seat0 2025-09-15 16:07 (:0) + """ + if not os.environ.get('DISPLAY'): + return None + + try: + # list of users logged in + output = subprocess.check_output(['who'], text=True).strip() + except Exception as exc: + logger.error( + 'Unexpected error while determining user name of ' + f'current desktop session: {exc}' + ) + return None + + display = os.environ.get('DISPLAY', ':0').split('.')[0] + + # each user + for line in output: + found = re.match(r'^(\S+).*\((.*)\)$', line) + + if not found: + continue + + user, userdisplay = found.groups() + userdisplay = userdisplay.split('.')[0] + + if userdisplay == display: + logger.info(f'VARIANT - X11 who DISPLAY: {user=}', self) + return user + + return None + + def _determine_desktop_session_user(self): + """Return name of user logged in to the current desktop session. + """ + user = self._get_desktop_user_via_loginctl() + + if not user: + user = self._get_desktop_user_via_x11_who() + + logger.info( + f'Systray Icon determined user "{user}" as owner of ' + 'current desktop session.') + + return user + @classmethod def _get_icon_filled(cls, color: str) -> QIcon: """Generate the dark symbolic icon""" @@ -311,7 +460,8 @@ def get_light_icon() -> QIcon: if '--debug' in sys.argv: logger.DEBUG = True - logger.debug('Sub process tries to show systray icon, ' - f'called with args {str(sys.argv)}') + logger.debug( + f'Systray icon process (PID: {os.getpid()} User: {logger.USER}) ' + f'called with {sys.argv}') QtSysTrayIcon().run()