From 855650cc9b2b8dc5f0c896b1208737442aa97bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Exp=C3=B3sito?= Date: Wed, 21 Feb 2024 11:41:37 +0100 Subject: [PATCH] Drop the X.Org server dependency Start GNOME Kiosk as a Wayland compositor and run Anaconda as a native Wayland client. This commit is a follow up on the work done by Neal Gompa [1], Martin Kolman and Ray Strode [2]. Credit goes to them for the code I copied and pasted. [1] https://github.com/rhinstaller/anaconda/pull/5401 [2] https://github.com/rhinstaller/anaconda/pull/5309 Co-authored-by: Neal Gompa Co-authored-by: Martin Kolman Co-authored-by: Ray Strode Resolves: RHEL-38399 Resolves: https://fedoraproject.org/wiki/Changes/AnacondaWebUIforFedoraWorkstation (cherry picked from commit 8800331efab8459882596fe69706d7c52b3f1aa7) --- anaconda.py | 13 +- anaconda.spec.in | 4 +- configure.ac | 1 + data/Makefile.am | 2 +- data/pam/Makefile.am | 21 ++++ data/pam/anaconda | 8 ++ data/systemd/anaconda.service | 2 +- pyanaconda/core/constants.py | 3 + pyanaconda/core/util.py | 2 +- pyanaconda/display.py | 85 ++++++------- pyanaconda/flags.py | 1 + scripts/Makefile.am | 3 +- scripts/makeupdates | 3 +- scripts/run-in-new-session | 231 ++++++++++++++++++++++++++++++++++ 14 files changed, 323 insertions(+), 56 deletions(-) create mode 100644 data/pam/Makefile.am create mode 100644 data/pam/anaconda create mode 100755 scripts/run-in-new-session diff --git a/anaconda.py b/anaconda.py index e438f6aa8ed..d7990a0e444 100755 --- a/anaconda.py +++ b/anaconda.py @@ -150,6 +150,12 @@ def setup_environment(): if "LD_PRELOAD" in os.environ: del os.environ["LD_PRELOAD"] + # Go ahead and set $WAYLAND_DISPLAY whether we're going to use Wayland or not + if "WAYLAND_DISPLAY" in os.environ: + flags.preexisting_wayland = True + else: + os.environ["WAYLAND_DISPLAY"] = constants.WAYLAND_SOCKET_NAME # pylint: disable=possibly-used-before-assignment + # Go ahead and set $DISPLAY whether we're going to use X or not if "DISPLAY" in os.environ: flags.preexisting_x11 = True @@ -306,10 +312,11 @@ def setup_environment(): except pid.PidFileError as e: log.error("Unable to create %s, exiting", pidfile.filename) - # If we had a $DISPLAY at start and zenity is available, we may be - # running in a live environment and we can display an error dialog. + # If we had a Wayland/X11 display at start and zenity is available, we may + # be running in a live environment and we can display an error dialog. # Otherwise just print an error. - if flags.preexisting_x11 and os.access("/usr/bin/zenity", os.X_OK): + preexisting_graphics = flags.preexisting_wayland or flags.preexisting_x11 + if preexisting_graphics and os.access("/usr/bin/zenity", os.X_OK): # The module-level _() calls are ok here because the language may # be set from the live environment in this case, and anaconda's # language setup hasn't happened yet. diff --git a/anaconda.spec.in b/anaconda.spec.in index 16055f4ef87..9f667a8e9f9 100644 --- a/anaconda.spec.in +++ b/anaconda.spec.in @@ -253,8 +253,6 @@ Requires: zram-generator # needed for proper driver disk support - if RPMs must be installed, a repo is needed Requires: createrepo_c # Display stuff moved from lorax templates -Requires: xorg-x11-drivers -Requires: xorg-x11-server-Xorg Requires: xrandr Requires: xrdb Requires: dbus-x11 @@ -263,6 +261,7 @@ Requires: nm-connection-editor Requires: librsvg2 Requires: gnome-kiosk Requires: brltty +Requires: python3-pam # dependencies for rpm-ostree payload module Requires: rpm-ostree >= %{rpmostreever} Requires: ostree @@ -407,6 +406,7 @@ rm -rf \ %{_sbindir}/anaconda %{_sbindir}/handle-sshpw %{_datadir}/anaconda +%{_sysconfdir}/pam.d/anaconda %{_prefix}/libexec/anaconda %exclude %{_datadir}/anaconda/gnome %exclude %{_datadir}/anaconda/pixmaps diff --git a/configure.ac b/configure.ac index 8a53ca15f06..cbe6c4029bd 100644 --- a/configure.ac +++ b/configure.ac @@ -113,6 +113,7 @@ AC_CONFIG_FILES([Makefile data/systemd/Makefile data/dbus/Makefile data/gtk-4.0/Makefile + data/pam/Makefile data/window-manager/Makefile data/window-manager/config/Makefile po/Makefile diff --git a/data/Makefile.am b/data/Makefile.am index d08f3005a0e..9c5cff7766b 100644 --- a/data/Makefile.am +++ b/data/Makefile.am @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -SUBDIRS = command-stubs gtk-4.0 liveinst systemd pixmaps window-manager dbus conf.d profile.d +SUBDIRS = command-stubs gtk-4.0 liveinst systemd pam pixmaps window-manager dbus conf.d profile.d CLEANFILES = *~ diff --git a/data/pam/Makefile.am b/data/pam/Makefile.am new file mode 100644 index 00000000000..97e6657be15 --- /dev/null +++ b/data/pam/Makefile.am @@ -0,0 +1,21 @@ +# Copyright (C) 2024 Neal Gompa. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +CLEANFILES = *~ + +pamdir = $(sysconfdir)/pam.d +dist_pam_DATA = anaconda + +MAINTAINERCLEANFILES = Makefile.in diff --git a/data/pam/anaconda b/data/pam/anaconda new file mode 100644 index 00000000000..af8758d3b79 --- /dev/null +++ b/data/pam/anaconda @@ -0,0 +1,8 @@ +#%PAM-1.0 +auth sufficient pam_permit.so +account sufficient pam_permit.so +password sufficient pam_permit.so +session required pam_loginuid.so +-session optional pam_keyinit.so revoke +-session optional pam_limits.so +session required pam_systemd.so \ No newline at end of file diff --git a/data/systemd/anaconda.service b/data/systemd/anaconda.service index a80c6bb7075..0a3580b89ad 100644 --- a/data/systemd/anaconda.service +++ b/data/systemd/anaconda.service @@ -5,6 +5,6 @@ Wants=anaconda-noshell.service [Service] Type=forking -Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr/sbin:/mnt/sysimage/bin:/mnt/sysimage/usr/bin:/mnt/sysimage/usr/sbin:/mnt/sysimage/sbin LANG=en_US.UTF-8 GDK_BACKEND=x11 XDG_RUNTIME_DIR=/tmp GIO_USE_VFS=local +Environment=HOME=/root MALLOC_CHECK_=2 MALLOC_PERTURB_=204 PATH=/usr/bin:/bin:/sbin:/usr/sbin:/mnt/sysimage/bin:/mnt/sysimage/usr/bin:/mnt/sysimage/usr/sbin:/mnt/sysimage/sbin LANG=en_US.UTF-8 GDK_BACKEND=wayland XDG_RUNTIME_DIR=/run/user/0 GIO_USE_VFS=local WorkingDirectory=/root ExecStart=/usr/bin/tmux -u -f /usr/share/anaconda/tmux.conf start diff --git a/pyanaconda/core/constants.py b/pyanaconda/core/constants.py index 06ff8cb0416..2f7dd28236a 100644 --- a/pyanaconda/core/constants.py +++ b/pyanaconda/core/constants.py @@ -269,6 +269,9 @@ class SecretStatus(Enum): IPMI_ABORTED = 0x9 # installation finished unsuccessfully, due to some non-exn error IPMI_FAILED = 0xA # installation hit an exception +# Wayland socket name to use +WAYLAND_SOCKET_NAME = "wl-sysinstall-0" + # X display number to use X_DISPLAY_NUMBER = 1 diff --git a/pyanaconda/core/util.py b/pyanaconda/core/util.py index 537201995d0..c509f751e02 100644 --- a/pyanaconda/core/util.py +++ b/pyanaconda/core/util.py @@ -255,7 +255,7 @@ def sigusr1_preexec(): log.debug("Exception handler test suspended to prevent accidental activation by " "delayed Xorg start. Next SIGUSR1 will be handled as delayed Xorg start.") # Raise an exception to notify the caller that things went wrong. This affects - # particularly pyanaconda.display.do_startup_x11_actions(), where the window manager + # particularly pyanaconda.display.do_startup_wl_actions(), where the window manager # is started immediately after this. The WM would just wait forever. raise TimeoutError("Timeout trying to start %s" % argv[0]) diff --git a/pyanaconda/display.py b/pyanaconda/display.py index c527b66b577..00219a0b9cd 100644 --- a/pyanaconda/display.py +++ b/pyanaconda/display.py @@ -20,7 +20,6 @@ # Author(s): Martin Kolman # import os -import subprocess import time import textwrap import pkgutil @@ -51,9 +50,9 @@ log = get_module_logger(__name__) stdout_log = get_stdout_logger() -X_TIMEOUT_ADVICE = \ +WAYLAND_TIMEOUT_ADVICE = \ "Do not load the stage2 image over a slow network link.\n" \ - "Wait longer for the X server startup with the inst.xtimeout= boot option." \ + "Wait longer for Wayland startup with the inst.xtimeout= boot option." \ "The default is 60 seconds.\n" \ "Load the stage2 image into memory with the rd.live.ram boot option to decrease access " \ "time.\n" \ @@ -183,22 +182,7 @@ def check_vnc_can_be_started(anaconda): return vnc_startup_possible, error_messages -# X11 - -def start_x11(xtimeout): - """Start the X server for the Anaconda GUI.""" - - # Start Xorg and wait for it become ready - util.startX(["Xorg", "-br", "-logfile", "/tmp/X.log", - ":%s" % constants.X_DISPLAY_NUMBER, "vt6", "-s", "1440", "-ac", - "-nolisten", "tcp", "-dpi", "96", - "-noreset"], - output_redirect=subprocess.DEVNULL, timeout=xtimeout) - - -# function to handle X startup special issues for anaconda - -def do_startup_x11_actions(): +def do_startup_wl_actions(timeout): """Start the window manager. When window manager actually connects to the X server is unknowable, but @@ -221,14 +205,35 @@ def do_startup_x11_actions(): # pylint: disable=environment-modify os.environ['XDG_CONFIG_DIRS'] = xdg_config_dirs - def x11_preexec(): + os.environ["XDG_SESSION_TYPE"] = "wayland" + + def wl_preexec(): # to set GUI subprocess SIGINT handler signal.signal(signal.SIGINT, signal.SIG_IGN) - childproc = util.startProgram(["gnome-kiosk", "--display", ":1", "--sm-disable", "--x11"], - env_add={'XDG_DATA_DIRS': xdg_data_dirs}, - preexec_fn=x11_preexec) - WatchProcesses.watch_process(childproc, "gnome-kiosk") + argv = ["/usr/libexec/anaconda/run-in-new-session", + "--user", "root", + "--service", "anaconda", + "--vt", "6", + "--session-type", "wayland", + "--session-class", "user", + "gnome-kiosk", "--sm-disable", "--wayland", "--no-x11", + "--wayland-display", constants.WAYLAND_SOCKET_NAME] + + childproc = util.startProgram(argv, env_add={'XDG_DATA_DIRS': xdg_data_dirs}, + preexec_fn=wl_preexec) + WatchProcesses.watch_process(childproc, argv[0]) + + for _i in range(0, int(timeout / 0.1)): + wl_socket_path = os.path.join(os.getenv("XDG_RUNTIME_DIR"), constants.WAYLAND_SOCKET_NAME) + if os.path.exists(wl_socket_path): + return + + time.sleep(0.1) + + WatchProcesses.unwatch_process(childproc) + childproc.terminate() + raise TimeoutError("Timeout trying to start gnome-kiosk") def set_x_resolution(runres): @@ -353,17 +358,6 @@ def setup_display(anaconda, options): for error_message in vnc_error_messages: stdout_log.warning(error_message) - # Should we try to start Xorg? - want_x = anaconda.gui_mode and not (flags.preexisting_x11 or flags.usevnc) - - # Is Xorg is actually available? - if want_x and not os.access("/usr/bin/Xorg", os.X_OK): - stdout_log.warning(_("Graphical installation is not available. " - "Starting text mode.")) - time.sleep(2) - anaconda.display_mode = constants.DisplayModes.TUI - want_x = False - if anaconda.tui_mode and flags.vncquestion: # we prefer vnc over text mode, so ask about that message = _("Text mode provides a limited set of installation " @@ -379,18 +373,17 @@ def setup_display(anaconda, options): startup_utils.check_memory(anaconda, options) # check_memory may have changed the display mode - want_x = want_x and (anaconda.gui_mode) - if want_x: + want_gui = anaconda.gui_mode and not (flags.preexisting_wayland or flags.usevnc) + if want_gui: try: - start_x11(xtimeout) - do_startup_x11_actions() + do_startup_wl_actions(xtimeout) except TimeoutError as e: - log.warning("X startup failed: %s", e) - print("\nX did not start in the expected time, falling back to text mode. There are " - "multiple ways to avoid this issue:") + log.warning("Wayland startup failed: %s", e) + print("\nWayland did not start in the expected time, falling back to text mode. " + "There are multiple ways to avoid this issue:") wrapper = textwrap.TextWrapper(initial_indent=" * ", subsequent_indent=" ", width=os.get_terminal_size().columns - 3) - for line in X_TIMEOUT_ADVICE.split("\n"): + for line in WAYLAND_TIMEOUT_ADVICE.split("\n"): print(wrapper.fill(line)) util.vtActivate(1) anaconda.display_mode = constants.DisplayModes.TUI @@ -398,8 +391,8 @@ def setup_display(anaconda, options): time.sleep(2) except (OSError, RuntimeError) as e: - log.warning("X or window manager startup failed: %s", e) - print("\nX or window manager startup failed, falling back to text mode.") + log.warning("Wayland startup failed: %s", e) + print("\nWayland startup failed, falling back to text mode.") util.vtActivate(1) anaconda.display_mode = constants.DisplayModes.TUI anaconda.gui_startup_failed = True @@ -418,7 +411,7 @@ def setup_display(anaconda, options): # if they want us to use VNC do that now if anaconda.gui_mode and flags.usevnc: vnc_server.startServer() - do_startup_x11_actions() + do_startup_wl_actions(xtimeout) # with X running we can initialize the UI interface anaconda.initialize_interface() diff --git a/pyanaconda/flags.py b/pyanaconda/flags.py index 000fd5df876..b70aa7def8e 100644 --- a/pyanaconda/flags.py +++ b/pyanaconda/flags.py @@ -35,6 +35,7 @@ def __init__(self): self.__dict__['_in_init'] = True self.usevnc = False self.vncquestion = True + self.preexisting_wayland = False self.preexisting_x11 = False self.automatedInstall = False self.eject = True diff --git a/scripts/Makefile.am b/scripts/Makefile.am index b69e97365ec..09cd9d925ec 100644 --- a/scripts/Makefile.am +++ b/scripts/Makefile.am @@ -16,7 +16,8 @@ # along with this program. If not, see . scriptsdir = $(libexecdir)/$(PACKAGE_NAME) -dist_scripts_SCRIPTS = run-anaconda anaconda-pre-log-gen log-capture start-module apply-updates +dist_scripts_SCRIPTS = run-anaconda anaconda-pre-log-gen log-capture start-module apply-updates \ + run-in-new-session dist_noinst_SCRIPTS = makeupdates makebumpver diff --git a/scripts/makeupdates b/scripts/makeupdates index 2b1dddf20e4..59728122a0b 100755 --- a/scripts/makeupdates +++ b/scripts/makeupdates @@ -35,7 +35,8 @@ RPM_RELEASE_DIR_TEMPLATE = "for_%s" SITE_PACKAGES_PATH = "./usr/lib64/python3.13/site-packages/" # Anaconda scripts that should be installed into the libexec folder -LIBEXEC_SCRIPTS = ["log-capture", "start-module", "apply-updates", "anaconda-pre-log-gen"] +LIBEXEC_SCRIPTS = ["log-capture", "start-module", "apply-updates", "anaconda-pre-log-gen", + "run-in-new-session"] # Anaconda scripts that should be installed into /usr/bin USR_BIN_SCRIPTS = ["anaconda-disable-nm-ibft-plugin", "anaconda-nm-disable-autocons"] diff --git a/scripts/run-in-new-session b/scripts/run-in-new-session new file mode 100755 index 00000000000..80aa673eb37 --- /dev/null +++ b/scripts/run-in-new-session @@ -0,0 +1,231 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2024 Red Hat, Inc. +# +# This copyrighted material is made available to anyone wishing to use, +# modify, copy, or redistribute it subject to the terms and conditions of +# the GNU General Public License v.2, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY expressed or implied, including the implied warranties of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. You should have received a copy of the +# GNU General Public License along with this program; if not, write to the +# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA +# 02110-1301, USA. Any Red Hat trademarks that are incorporated in the +# source code or documentation are not subject to the GNU General Public +# License and may only be used or replicated with the express permission of +# Red Hat, Inc. +# +# Author(s): Martin Kolman , Ray Strode +# + +import argparse +import fcntl +import pam +import pwd +import os +import signal +import struct +import subprocess +import sys + +VT_GETSTATE = 0x5603 +VT_ACTIVATE = 0x5606 +VT_OPENQRY = 0x5600 +VT_WAITACTIVE = 0x5607 +TIOCSCTTY = 0x540E + + +def is_running_in_logind_session(): + try: + with open('/proc/self/loginuid', 'r') as f: + loginuid = int(f.read().strip()) + return loginuid != 0xFFFFFFFF + except Exception as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error reading /proc/self/loginuid: {e}") from e + + +def find_free_vt(): + with open('/dev/tty0', 'w') as console: + result = fcntl.ioctl(console, VT_OPENQRY, struct.pack('i', 0)) + vt = struct.unpack('i', result)[0] + return vt + + +def run_program_in_new_session(arguments, pam_environment, user, service, + tty_input, tty_output, vt): + pam_handle = pam.pam() + + for key, value in pam_environment.items(): + pam_handle.putenv(f'{key}={value}') + + old_tty_input = os.fdopen(os.dup(0), 'r') + os.dup2(os.dup(tty_input.fileno()), 0) + + if not pam_handle.authenticate(user, '', service=service, call_end=False): + # pylint: disable-next=broad-exception-raised + raise Exception("Authentication failed") + + for key, value in pam_environment.items(): + pam_handle.putenv(f'{key}={value}') + + if pam_handle.open_session() != pam.PAM_SUCCESS: + # pylint: disable-next=broad-exception-raised + raise Exception("Failed to open PAM session") + + session_environment = os.environ.copy() + session_environment.update(pam_handle.getenvlist()) + + os.dup2(old_tty_input.fileno(), 0) + + user_info = pwd.getpwnam(user) + uid = user_info.pw_uid + gid = user_info.pw_gid + + old_tty_output = os.fdopen(os.dup(2), 'w') + + console = open("/dev/tty0", 'w') + + try: + old_vt = 0 + if vt: + vt_state = fcntl.ioctl(console, VT_GETSTATE, struct.pack('HHH', 0, 0, 0)) + old_vt, _, _ = struct.unpack('HHH', vt_state) + except OSError as e: + print(f"Could not read current VT: {e}", file=old_tty_output) + + pid = os.fork() + if pid == 0: + try: + os.setsid() + except OSError as e: + print(f"Could not create new pid session: {e}", file=old_tty_output) + + try: + fcntl.ioctl(tty_output, TIOCSCTTY, 1) + except OSError as e: + print(f"Could not take control of tty: {e}", file=old_tty_output) + + try: + fcntl.ioctl(console, VT_ACTIVATE, vt) + except OSError as e: + print(f"Could not change to VT {vt}: {e}", file=old_tty_output) + + try: + fcntl.ioctl(console, VT_WAITACTIVE, vt) + except OSError as e: + print(f"Could not wait for VT {vt} to change: {e}", file=old_tty_output) + + try: + os.dup2(tty_input.fileno(), 0) + os.dup2(tty_output.fileno(), 1) + os.dup2(tty_output.fileno(), 2) + except OSError as e: + print(f"Could not set up standard i/o: {e}", file=old_tty_output) + + try: + os.initgroups(user, gid) + os.setgid(gid) + os.setuid(uid) + except OSError as e: + print(f"Could not become user {user} (uid={uid}): {e}", file=old_tty_output) + + try: + os.execvpe(arguments[0], arguments, session_environment) + except OSError as e: + print(f"Could not run program \"{' '.join(arguments)}\": {e}", file=old_tty_output) + os._exit(1) + + try: + (_, exit_code) = os.waitpid(pid, 0) + except KeyboardInterrupt: + os.kill(pid, signal.SIGINT) + except OSError as e: + print(f"Could not wait for program to finish: {e}", file=old_tty_output) + + try: + if old_vt: + fcntl.ioctl(console, VT_ACTIVATE, old_vt) + fcntl.ioctl(console, VT_WAITACTIVE, old_vt) + except OSError as e: + print(f"Could not change VTs back: {e}", file=old_tty_output) + + if os.WIFEXITED(exit_code): + exit_code = os.WEXITSTATUS(exit_code) + else: + os.kill(os.getpid(), os.WTERMSIG(exit_code)) + old_tty_output.close() + console.close() + + if pam_handle.close_session() != pam.PAM_SUCCESS: + # pylint: disable-next=broad-exception-raised + raise Exception("Failed to close PAM session") + + pam_handle.end() + + return exit_code + + +def main(): + parser = argparse.ArgumentParser(description='Run a program in a PAM session with specific' + ' environment variables as a specified user.') + parser.add_argument('--user', default='root', help='Username for which to run the program') + parser.add_argument('--service', default='su-l', help='PAM service to use') + parser.add_argument('--session-type', default='x11', help='e.g., x11, wayland, or tty') + parser.add_argument('--session-class', default='user', help='e.g., greeter or user') + parser.add_argument('--session-desktop', help='desktop file id associated with session, e.g.' + ' gnome, gnome-classic, gnome-wayland') + parser.add_argument('--vt', help='VT to run on') + + args, remaining_args = parser.parse_known_args() + + if not remaining_args: + remaining_args = ["bash", "-l"] + + if not args.vt: + vt = find_free_vt() + print(f'Using VT {vt}') + else: + vt = int(args.vt) + + if is_running_in_logind_session(): + program = ['systemd-run', + f'--unit=run-in-new-session-{os.getpid()}.service', + '--pipe', + '--wait', + '-d'] + + program += [sys.executable] + program += sys.argv + subprocess.run(program, check=False) + return + + try: + tty_path = f'/dev/tty{vt}' + tty_input = open(tty_path, 'r') + tty_output = open(tty_path, 'w') + + pam_environment = {} + pam_environment['XDG_SEAT'] = "seat0" + pam_environment['XDG_SESSION_TYPE'] = args.session_type + pam_environment['XDG_SESSION_CLASS'] = args.session_class + pam_environment['XDG_SESSION_DESKTOP'] = args.session_desktop + pam_environment['XDG_VTNR'] = vt + + try: + result = run_program_in_new_session(remaining_args, pam_environment, args.user, + args.service, tty_input, tty_output, vt) + except OSError as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error running program \"{' '.join(remaining_args)}\": {e}") from e + tty_input.close() + tty_output.close() + sys.exit(result) + except OSError as e: + # pylint: disable-next=broad-exception-raised + raise Exception(f"Error opening tty associated with VT {vt}: {e}") from e + + +if __name__ == '__main__': + main()