| #!/usr/bin/env python3 |
| # Copyright 2026 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """ |
| Helper script to run the dev CRD multi-process host. Typical usage is: |
| |
| $ remoting/tools/run_multi_process_host.py out/debug |
| |
| The script will elevate itself and ask for your password. |
| """ |
| |
| import argparse |
| import atexit |
| import hashlib |
| import os |
| import shutil |
| import signal |
| import socket |
| import subprocess |
| import sys |
| import time |
| |
| |
| def run_command(command): |
| try: |
| subprocess.run(command, check=True) |
| except subprocess.CalledProcessError as e: |
| print( |
| "Command failed with exit code " |
| f"{e.returncode}: {' '.join(command)}", |
| file=sys.stderr) |
| raise |
| |
| |
| def get_gdm_version(): |
| try: |
| result = subprocess.run(["gdm3", "--version"], |
| capture_output=True, |
| text=True, |
| check=True) |
| # result.stdout: "GDM 38.0\n" |
| output = result.stdout.strip() |
| if output.startswith("GDM "): |
| return output[4:] |
| return output |
| except (subprocess.CalledProcessError, FileNotFoundError): |
| return None |
| |
| |
| def is_gdm_version_ge_49(): |
| version_str = get_gdm_version() |
| if not version_str: |
| return False |
| try: |
| # Version might be "49.0" or just "49" |
| major_version = int(version_str.split(".")[0]) |
| return major_version >= 49 |
| except (ValueError, IndexError): |
| return False |
| |
| |
| def terminate_remote_sessions(): |
| print("Cleaning up remote sessions...") |
| try: |
| result = subprocess.run(["loginctl", "list-sessions", "--no-legend"], |
| capture_output=True, |
| text=True, |
| check=True) |
| for line in result.stdout.splitlines(): |
| parts = line.split() |
| if not parts: |
| continue |
| session_id = parts[0] |
| show_session_command = [ |
| "loginctl", "show-session", session_id, "-p", "Remote", "-p", |
| "Type" |
| ] |
| show_result = subprocess.run(show_session_command, |
| capture_output=True, |
| text=True, |
| check=False) |
| if show_result.returncode != 0: |
| continue |
| |
| props = {} |
| for show_line in show_result.stdout.splitlines(): |
| if "=" in show_line: |
| k, v = show_line.split("=", 1) |
| props[k] = v |
| |
| if (props.get("Remote") == "yes" |
| and props.get("Type") in ["wayland", "x11"]): |
| print(f"Terminating remote session {session_id} " |
| f"(Type={props.get('Type')})...") |
| subprocess.run(["loginctl", "terminate-session", session_id], |
| check=False) |
| except subprocess.CalledProcessError as e: |
| print(f"Failed to list sessions: {e}", file=sys.stderr) |
| |
| |
| def handle_signal(signum, frame): |
| del signum, frame # Unused |
| # This will trigger atexit handlers. |
| sys.exit(0) |
| |
| |
| def get_file_trigger_state(path): |
| try: |
| st = os.stat(path) |
| return (st.st_mtime, st.st_ino) |
| except FileNotFoundError: |
| return None |
| |
| |
| def ensure_permissions(abs_out_dir, user_home, force=False): |
| remoting_host_path = os.path.join(abs_out_dir, "remoting_me2me_host") |
| remoting_core_path = os.path.join(abs_out_dir, "libremoting_core.so") |
| |
| def check_permissions(): |
| for user in [ |
| "_crd_network", "_crd_peer_connection", "_crd_crashpad" |
| ]: |
| try: |
| subprocess.run([ |
| "sudo", "-u", user, "test", "-x", remoting_host_path |
| ], |
| check=True, |
| capture_output=True) |
| subprocess.run([ |
| "sudo", "-u", user, "test", "-r", remoting_core_path |
| ], |
| check=True, |
| capture_output=True) |
| except subprocess.CalledProcessError: |
| return False |
| return True |
| |
| print("Checking permissions...") |
| if not force and check_permissions(): |
| print("System users have the right permissions.") |
| return |
| |
| if force: |
| print("Forcing permission update...") |
| else: |
| print("System users do not have execute permissions. Setting ACLs...") |
| |
| run_command([ |
| "setfacl", "-R", "-m", "u:_crd_network:rx", "-m", |
| "u:_crd_peer_connection:rx", "-m", "u:_crd_crashpad:rx", "-m", |
| "g:Debian-gdm:rx", abs_out_dir |
| ]) |
| |
| # Accounts also need to be granted read and executable permissions to |
| # all the parent directories. |
| current_dir = abs_out_dir |
| user_home_abs = os.path.abspath(user_home) |
| while not check_permissions(): |
| parent_dir = os.path.dirname(current_dir) |
| if parent_dir == current_dir: |
| break |
| |
| print(f"Setting ACLs on {parent_dir}...") |
| run_command([ |
| "setfacl", "-m", "u:_crd_network:rx", "-m", |
| "u:_crd_peer_connection:rx", "-m", "u:_crd_crashpad:rx", "-m", |
| "g:Debian-gdm:rx", parent_dir |
| ]) |
| |
| if parent_dir == user_home_abs: |
| break |
| current_dir = parent_dir |
| |
| if check_permissions(): |
| print("ACLs updated successfully.") |
| else: |
| print("ACLs updated but permissions still failing.", file=sys.stderr) |
| |
| |
| def user_main(out_dir, keep_sessions=False, set_permissions=False): |
| try: |
| run_command(["systemctl", "is-active", "--quiet", "gdm3"]) |
| except subprocess.CalledProcessError: |
| print("gdm3 service is not running.") |
| print("Please make sure gdm3.service is unmasked and started:") |
| print(" sudo systemctl unmask gdm3.service") |
| print(" sudo systemctl start gdm3.service") |
| sys.exit(1) |
| |
| print("Re-running script with sudo...") |
| script_path = os.path.abspath(__file__) |
| home_dir = os.path.expanduser("~") |
| |
| cmd = [ |
| "sudo", sys.executable, script_path, "--elevated", out_dir, |
| "--user-home", home_dir |
| ] |
| if keep_sessions: |
| cmd.append("--keep-sessions") |
| if set_permissions: |
| cmd.append("--set-permissions") |
| run_command(cmd) |
| |
| |
| def root_main(out_dir, |
| user_home, |
| keep_sessions=False, |
| set_permissions=False): |
| abs_out_dir = os.path.abspath(out_dir) |
| host_config_path = "/etc/chrome-remote-desktop/host.json" |
| |
| if not os.path.exists(host_config_path): |
| print(f"{host_config_path} not found. " |
| "Looking for the current user config...") |
| host_hash = hashlib.md5(socket.gethostname().encode()).hexdigest() |
| config_file = os.path.join( |
| user_home, f".config/chrome-remote-desktop/host#{host_hash}.json") |
| |
| if os.path.exists(config_file): |
| os.makedirs(os.path.dirname(host_config_path), exist_ok=True) |
| shutil.copy2(config_file, host_config_path) |
| print(f"Copied {config_file} to {host_config_path}") |
| else: |
| print("No suitable user config found") |
| sys.exit(1) |
| |
| print("Adding CRD system users...") |
| run_command(["adduser", "--system", "_crd_network"]) |
| run_command(["adduser", "--system", "_crd_peer_connection"]) |
| run_command(["adduser", "--system", "_crd_crashpad"]) |
| |
| remoting_host_path = os.path.join(abs_out_dir, "remoting_me2me_host") |
| remoting_core_path = os.path.join(abs_out_dir, "libremoting_core.so") |
| |
| daemon_command = [remoting_host_path, "--type=daemon"] |
| |
| # Initial state for monitoring |
| host_state = get_file_trigger_state(remoting_host_path) |
| core_state = get_file_trigger_state(remoting_core_path) |
| |
| proc = None |
| |
| def start_daemon(): |
| nonlocal proc |
| print(f"Starting CRD host daemon: {' '.join(daemon_command)}") |
| proc = subprocess.Popen(daemon_command) |
| |
| def stop_daemon(): |
| nonlocal proc |
| if proc and proc.poll() is None: |
| print("Stopping CRD host daemon...") |
| proc.terminate() |
| try: |
| proc.wait(timeout=5) |
| except subprocess.TimeoutExpired: |
| print("Daemon did not terminate, killing...") |
| proc.kill() |
| proc.wait() |
| print("CRD host daemon stopped.") |
| proc = None |
| |
| signal.signal(signal.SIGTERM, handle_signal) |
| signal.signal(signal.SIGINT, handle_signal) |
| signal.signal(signal.SIGHUP, handle_signal) |
| |
| if not keep_sessions: |
| atexit.register(terminate_remote_sessions) |
| |
| if host_state is not None and core_state is not None: |
| ensure_permissions(abs_out_dir, user_home, force=set_permissions) |
| start_daemon() |
| else: |
| print("Binaries are missing. Waiting for them to be built...") |
| |
| try: |
| while True: |
| if proc and proc.poll() is not None: |
| print(f"Host daemon exited with code {proc.returncode}") |
| break |
| |
| current_host_state = get_file_trigger_state(remoting_host_path) |
| current_core_state = get_file_trigger_state(remoting_core_path) |
| |
| changed = False |
| if current_host_state != host_state: |
| print(f"Detected change in {remoting_host_path}") |
| host_state = current_host_state |
| changed = True |
| if current_core_state != core_state: |
| print(f"Detected change in {remoting_core_path}") |
| core_state = current_core_state |
| changed = True |
| |
| if changed: |
| stop_daemon() |
| if host_state is not None and core_state is not None: |
| print("Binaries updated/restored. Starting daemon...") |
| ensure_permissions(abs_out_dir, |
| user_home, |
| force=set_permissions) |
| start_daemon() |
| else: |
| print( |
| "Binaries are missing (likely rebuilding). Waiting for " |
| "restore...") |
| |
| time.sleep(1) |
| except KeyboardInterrupt: |
| print("\nCaught KeyboardInterrupt. Stopping host daemon...") |
| finally: |
| stop_daemon() |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description="Run dev CRD multi-process host.") |
| |
| parser.add_argument("out_dir", |
| nargs="?", |
| help="Build output directory (e.g., out/debug)") |
| |
| parser.add_argument( |
| "--keep-sessions", |
| action="store_true", |
| help= |
| "Skip terminating remote sessions on exit. Graphical sessions created " |
| "by CRD will be kept even after the host has exited. This allows for " |
| "testing session recovery behavior. Run this script as root with the " |
| "--terminate-sessions flag to terminate these sessions.") |
| parser.add_argument( |
| "--set-permissions", |
| action="store_true", |
| help= |
| "Forcibly run the setfacl command to set permissions, bypassing the " |
| "permission check. Useful if you modified dependencies (e.g. WebRTC) " |
| "that aren't checked by default.") |
| parser.add_argument( |
| "--terminate-sessions", |
| action="store_true", |
| help="Only terminate remote sessions and exit. " |
| "Must be run with sudo.") |
| |
| # These should be set by the script itself. |
| parser.add_argument("--elevated", |
| action="store_true", |
| help=argparse.SUPPRESS) |
| parser.add_argument("--user-home", help=argparse.SUPPRESS) |
| |
| args = parser.parse_args() |
| |
| if args.terminate_sessions: |
| if os.geteuid() != 0: |
| print("Error: --terminate-sessions must be run with sudo.") |
| sys.exit(1) |
| terminate_remote_sessions() |
| sys.exit(0) |
| |
| if not args.out_dir: |
| parser.error("out_dir is required when not using --terminate-sessions") |
| |
| if not os.path.isdir(args.out_dir): |
| print(f"Error: Output directory not found: {args.out_dir}") |
| sys.exit(1) |
| |
| if args.elevated: |
| if os.geteuid() != 0: |
| print("Error: Must be run as root in elevated mode.") |
| sys.exit(1) |
| if not args.user_home: |
| print("Error: --user-home is required in elevated mode.") |
| sys.exit(1) |
| root_main(args.out_dir, args.user_home, args.keep_sessions, |
| args.set_permissions) |
| else: |
| if os.geteuid() == 0: |
| print("Error: Do not run this script directly with sudo. " |
| "It will re-invoke itself.") |
| sys.exit(1) |
| user_main(args.out_dir, args.keep_sessions, args.set_permissions) |
| |
| |
| if __name__ == "__main__": |
| main() |