From 0cee646267e8d42be678319c73cb6d72547d1602 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Tue, 15 Oct 2024 10:09:57 +0200 Subject: [PATCH] beiboot: Enable beiboot feature for distro packages for same OS We recently eliminated all on-demand installations of cockpit-* packages in beiboot mode, which prevents accidentally moving from a beiboot to a distro package scenario. This means we can now carefully open up the beiboot feature for distro packages. However, we need to be careful here: While we can reasonably assume an ever-green flatpak and cockpit/ws container, distro packages may be arbitrarily old, and hence incompatible with APIs from newer OSes. So start small and only allow connecting to the same target OS as the host. This should already cover a lot of use cases in homogenous environments. Note that this only applies to direct logins (bastion host). The code path for the (deprecated) host switcher is completely different and doesn't support beiboot at all. Drop the `no-cockpit` part of TestLoopback.testBasic. Removing /usr/bin/cockpit-bridge now just enables beiboot mode, the OS is always compatible (as it's localhost), and we already check this in TestMultiMachine. Part of https://issues.redhat.com/browse/COCKPIT-1178 --- pkg/static/login.js | 8 ++++- src/cockpit/beiboot.py | 42 +++++++++++++++++++++++++-- src/ws/cockpitauth.c | 6 ++-- test/verify/check-loopback | 8 ----- test/verify/check-shell-multi-machine | 17 +++++++++++ 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/pkg/static/login.js b/pkg/static/login.js index 605e2d56cb03..a760efa31e0d 100644 --- a/pkg/static/login.js +++ b/pkg/static/login.js @@ -1037,7 +1037,13 @@ function debug(...args) { } else if (xhr.status == 403) { login_failure(_(decodeURIComponent(xhr.statusText)) || _("Permission denied")); } else if (xhr.status == 500 && xhr.statusText.indexOf("no-cockpit") > -1) { - login_failure(format(_("A compatible version of Cockpit is not installed on $0."), login_machine || "localhost")); + // always show what's going on + let message = format(_("A compatible version of Cockpit is not installed on $0."), login_machine || "localhost"); + // in beiboot mode we get some more info + const error = JSON.parse(xhr.responseText); + if (error.supported) + message += " " + format(_("This is only supported for $0 on the target machine."), error.supported); + login_failure(message); } else if (xhr.statusText) { fatal(decodeURIComponent(xhr.statusText)); } else { diff --git a/src/cockpit/beiboot.py b/src/cockpit/beiboot.py index 2089ec31feb2..26404bb4c2f6 100644 --- a/src/cockpit/beiboot.py +++ b/src/cockpit/beiboot.py @@ -110,6 +110,15 @@ def __init__(self, file_status: Dict[str, bool]): def report_exists(files): command('cockpit.report-exists', {name: os.path.exists(name) for name in files}) """, + "check_os_release": r""" + import os + def check_os_release(_argv): + try: + with open('/etc/os-release') as f: + command('cockpit.check-os-release', f.read()) + except OSError: + command('cockpit.check-os-release', "") + """, "force_exec": r""" import os def force_exec(argv): @@ -136,8 +145,14 @@ def shutdown(self) -> None: self.peer.close() +def parse_os_release(text): + return dict(line.strip().split('=', 1) + for line in text.splitlines() + if not line.startswith('#') and '=' in line) + + class AuthorizeResponder(ferny.AskpassHandler): - commands = ('ferny.askpass', 'cockpit.report-exists', 'cockpit.fail-no-cockpit') + commands = ('ferny.askpass', 'cockpit.report-exists', 'cockpit.fail-no-cockpit', 'cockpit.check-os-release') router: Router def __init__(self, router: Router, basic_password: Optional[str]): @@ -225,6 +240,23 @@ async def do_custom_command(self, command: str, args: tuple, fds: list[int], std if command == 'cockpit.fail-no-cockpit': raise CockpitProblem('no-cockpit', message=args[0]) + if command == 'cockpit.check-os-release': + remote_os = parse_os_release(args[0]) + logger.debug("cockpit.check-os-release: remote: %r", remote_os) + try: + with open("/etc/os-release") as f: + local_os = parse_os_release(f.read()) + except OSError as e: + logger.warning("failed to read local /etc/os-release, skipping OS compatibility check: %s", e) + return + + logger.debug("cockpit.check-os-release: local: %r", local_os) + # for now, just support the same OS + if remote_os.get('ID') != local_os.get('ID') or remote_os.get('VERSION_ID') != local_os.get('VERSION_ID'): + unsupported = f'{remote_os.get("ID", "?")} {remote_os.get("VERSION_ID", "")}' + supported = f'{local_os.get("ID", "?")} {local_os.get("VERSION_ID", "")}' + raise CockpitProblem('no-cockpit', unsupported=unsupported, supported=supported) + def python_interpreter(comment: str) -> tuple[Sequence[str], Sequence[str]]: return ('python3', '-ic', f'# {comment}'), () @@ -262,7 +294,7 @@ def flatpak_spawn(cmd: Sequence[str], env: Sequence[str]) -> tuple[Sequence[str] class SshPeer(Peer): - mode: 'Literal["always"] | Literal["never"] | Literal["auto"]' + mode: 'Literal["always"] | Literal["never"] | Literal["supported"] | Literal["auto"]' def __init__(self, router: Router, destination: str, args: argparse.Namespace): self.destination = destination @@ -344,6 +376,9 @@ async def boot(self, cmd: Sequence[str], env: Sequence[str]) -> None: exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],))] elif self.remote_bridge == 'always': exec_cockpit_bridge_steps = [('force_exec', (['cockpit-bridge'],))] + elif self.remote_bridge == 'supported': + # native bridge first; check OS compatibility for beiboot fallback + exec_cockpit_bridge_steps = [('try_exec', (['cockpit-bridge'],)), ('check_os_release', ([],))] else: assert self.remote_bridge == 'never' exec_cockpit_bridge_steps = [] @@ -480,9 +515,10 @@ def main() -> None: polyfills.install() parser = argparse.ArgumentParser(description='cockpit-bridge is run automatically inside of a Cockpit session.') - parser.add_argument('--remote-bridge', choices=['auto', 'never', 'always'], default='auto', + parser.add_argument('--remote-bridge', choices=['auto', 'never', 'supported', 'always'], default='auto', help="How to run cockpit-bridge from the remote host: auto: if installed (default), " "never: always copy the local one; " + "supported: copy local one for compatible OSes, fail otherwise; " "always: fail if not installed") parser.add_argument('--debug', action='store_true') parser.add_argument('destination', help="Name of the remote host to connect to, or 'localhost'") diff --git a/src/ws/cockpitauth.c b/src/ws/cockpitauth.c index 86c568bd222d..3b239707d556 100644 --- a/src/ws/cockpitauth.c +++ b/src/ws/cockpitauth.c @@ -49,9 +49,9 @@ #define ACTION_NONE "none" #define LOCAL_SESSION "local-session" -/* for the time being, we only support running an installed cockpit-bridge on the remote, - * and leave beibooting to the flatpak */ -const gchar *cockpit_ws_ssh_program = "/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=always"; +/* we only support beibooting machines with a known/vetted OS, as it's impossible to guarantee + * forward compatibility for all pages */ +const gchar *cockpit_ws_ssh_program = "/usr/bin/env python3 -m cockpit.beiboot --remote-bridge=supported"; /* Some tunables that can be set from tests */ const gchar *cockpit_ws_session_program = LIBEXECDIR "/cockpit-session"; diff --git a/test/verify/check-loopback b/test/verify/check-loopback index 5dab47ac4952..5b6872ff36d3 100755 --- a/test/verify/check-loopback +++ b/test/verify/check-loopback @@ -38,14 +38,6 @@ class TestLoopback(testlib.MachineCase): b.logout() b.wait_visible("#login") - self.restore_file("/usr/bin/cockpit-bridge") - m.execute("rm /usr/bin/cockpit-bridge") - - b.set_val('#login-user-input', "admin") - b.set_val('#login-password-input', "foobar") - b.click('#login-button') - b.wait_text("#login-error-message", "A compatible version of Cockpit is not installed on localhost.") - m.disconnect() self.restore_dir("/etc/ssh", restart_unit=self.sshd_service) m.execute("sed -i 's/.*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config $(ls /etc/ssh/sshd_config.d/* 2>/dev/null || true)") diff --git a/test/verify/check-shell-multi-machine b/test/verify/check-shell-multi-machine index 4d32f469230f..f9b3b141576d 100755 --- a/test/verify/check-shell-multi-machine +++ b/test/verify/check-shell-multi-machine @@ -355,6 +355,23 @@ class TestMultiMachine(testlib.MachineCase): b.wait_in_text(hostname_selector, "machine2") b.logout() + # beiboot mode: same OS → compatible, supported + break_bridge(m2) + b.try_login(password="alt-password") + b.wait_visible('#content') + b.logout() + + # beiboot mode: future OS version → incompatible, not supported + # rolling OSes don't have a VERSION_ID + if m.image not in ["arch", "debian-testing"]: + m2.execute("sed -i '/^VERSION_ID/ s/$/1/' /etc/os-release") + b.try_login(password="alt-password") + b.wait_in_text('#login-error-message', "A compatible version of Cockpit is not installed on 10.111.113.2") + source_os = m.execute('. /etc/os-release; echo "$ID $VERSION_ID"').strip() + b.wait_in_text('#login-error-message', f"This is only supported for {source_os} on the target machine") + + fix_bridge(m2) + login_options = '#show-other-login-options' # Connect to bad machine