From 5be4ace6c2adf27edad46e3bc14697146d2b0429 Mon Sep 17 00:00:00 2001 From: m4ko Date: Thu, 3 Sep 2026 22:16:43 +0200 Subject: [PATCH] Added bubblewrap / apparmor fixes and diagnostic tools for troubleshooting it on related distros. --- RELEASE_README.md | 13 +- install.sh | 26 + packaging/install-dependencies.sh | 27 + ptlinux/__main__.py | 14 + ptlinux/cli.py | 89 +++- ptlinux/container_host.py | 822 ++++++++++++++++++++++++++++++ ptlinux/doctor.py | 36 +- ptlinux/paths.py | 6 + ptlinux/runtime.py | 15 + 9 files changed, 1039 insertions(+), 9 deletions(-) create mode 100644 ptlinux/container_host.py diff --git a/RELEASE_README.md b/RELEASE_README.md index 0333f7f..c766d82 100644 --- a/RELEASE_README.md +++ b/RELEASE_README.md @@ -57,6 +57,8 @@ pristontale-eu --help Show all commands and options pristontale-eu gui Open the native dashboard pristontale-eu doctor Check required host components pristontale-eu runtime-bootstrap Prepare the verified UMU helper +pristontale-eu container-host Test the Bubblewrap sandbox +pristontale-eu container-host --repair Repair recognized Ubuntu AppArmor states pristontale-eu login-diagnostics Check the login route without credentials pristontale-eu login-diagnostics --runtime proton7 Compare the direct-Proton prefix read-only @@ -84,4 +86,13 @@ pristontale-eu cache-compatibility PATH Cache a verified compatibility pai ``` Use `./install.sh --dry-run` to preview dependency installation or -`./install.sh --skip-deps` to manage distribution packages manually. +`./install.sh --skip-deps` to manage distribution packages and the host sandbox +manually. + +On Ubuntu, install and launcher-update operations run a harmless Bubblewrap +user-namespace probe. If AppArmor produces the exact bwrap UID-map permission +denial, the launcher can install and load a pinned upstream restrictive bwrap +profile. It also repairs only UMU's known broken +`bwrap-userns-restrict-umu` link. Authentication is required; the repair does +not disable AppArmor or change global kernel security settings, and it refuses +to install a competing profile when another bwrap policy is present. diff --git a/install.sh b/install.sh index 1cc27c1..252d82c 100755 --- a/install.sh +++ b/install.sh @@ -44,6 +44,7 @@ usage() "" \ " --install-dir PATH Store the app, managed game, and runtime data at PATH." \ " Default: ~/Games/PristontaleEU" \ + " --skip-deps Do not install packages or repair host AppArmor profiles." \ " --skip-runtime-bootstrap" \ " Do not prepare the verified UMU helper during install." } @@ -184,6 +185,11 @@ fi if (( DRY_RUN == 1 )) then + if (( INSTALL_DEPENDENCIES == 1 )) + then + printf '%s\n' \ + "Dry run: installation would probe Bubblewrap and offer only the guarded Ubuntu AppArmor repair." + fi exit 0 fi @@ -339,6 +345,18 @@ then fi printf '%s\n' "Launcher files installed." +CONTAINER_HOST_READY=1 +if (( INSTALL_DEPENDENCIES == 1 )) +then + printf '%s\n' "Checking Steam Runtime sandbox permissions." + if ! "${BIN_HOME}/pristontale-eu" container-host --repair + then + CONTAINER_HOST_READY=0 + fi +else + printf '%s\n' "Steam Runtime sandbox repair skipped with host dependencies." +fi + RUNTIME_BOOTSTRAP_READY=1 if (( RUN_RUNTIME_BOOTSTRAP == 1 )) then @@ -381,6 +399,14 @@ case ":${PATH:-}:" in ;; esac +if (( CONTAINER_HOST_READY == 0 )) +then + printf '%s\n' \ + "Launcher files were installed, but Bubblewrap cannot create the Steam Runtime sandbox." \ + "Retry with: ${BIN_HOME}/pristontale-eu container-host --repair" >&2 + exit 1 +fi + if (( RUNTIME_BOOTSTRAP_READY == 0 )) then printf '%s\n' \ diff --git a/packaging/install-dependencies.sh b/packaging/install-dependencies.sh index d89a57a..b7731c1 100755 --- a/packaging/install-dependencies.sh +++ b/packaging/install-dependencies.sh @@ -79,6 +79,12 @@ ptlinux_resolve_dependency_packages() debian:openssl) candidates=(openssl) ;; + debian:bubblewrap) + candidates=(bubblewrap) + ;; + debian:apparmor) + candidates=(apparmor) + ;; fedora:python) candidates=(python3) ;; @@ -100,6 +106,9 @@ ptlinux_resolve_dependency_packages() fedora:openssl) candidates=(openssl) ;; + fedora:bubblewrap) + candidates=(bubblewrap) + ;; arch:python) candidates=(python) ;; @@ -121,6 +130,9 @@ ptlinux_resolve_dependency_packages() arch:openssl) candidates=(openssl) ;; + arch:bubblewrap) + candidates=(bubblewrap) + ;; arch:umu) candidates=(umu-launcher) ;; @@ -145,6 +157,9 @@ ptlinux_resolve_dependency_packages() suse:openssl) candidates=(openssl) ;; + suse:bubblewrap) + candidates=(bubblewrap) + ;; *) printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2 return 1 @@ -239,6 +254,18 @@ ptlinux_detect_missing_dependencies() then PTLINUX_MISSING_DEPENDENCIES+=(openssl) fi + if [[ ! -x /usr/bin/bwrap ]] + then + PTLINUX_MISSING_DEPENDENCIES+=(bubblewrap) + fi + if [[ "$family" == "debian" \ + && ( "${PTLINUX_DISTRIBUTION_ID,,}" == "ubuntu" \ + || " ${PTLINUX_DISTRIBUTION_LIKE,,} " == *" ubuntu "* ) \ + && -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns \ + && ! -x /usr/sbin/apparmor_parser ]] + then + PTLINUX_MISSING_DEPENDENCIES+=(apparmor) + fi if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1 then PTLINUX_MISSING_DEPENDENCIES+=(umu) diff --git a/ptlinux/__main__.py b/ptlinux/__main__.py index 9c70f6d..04ff322 100644 --- a/ptlinux/__main__.py +++ b/ptlinux/__main__.py @@ -8,6 +8,10 @@ def main() -> int: arguments = sys.argv[1:] if not arguments or arguments == ["gui"]: + from .container_host import ( + container_host_error, + repair_container_host_after_update, + ) from .paths import AppPaths from .umu import provision_umu_for_startup @@ -16,6 +20,16 @@ def main() -> int: # Older installed releases cannot run a bootstrap step that only exists # in their update payload. Their automatic restart enters here instead. provision_umu_for_startup(paths) + container_host = repair_container_host_after_update(paths) + if container_host.repair_attempted and ( + not container_host.ready + or container_host.broken_umu_profile_link + or container_host.repair_error is not None + ): + print( + f"Warning: {container_host_error(container_host)}", + file=sys.stderr, + ) from .ui import run_dashboard run_dashboard(paths) diff --git a/ptlinux/cli.py b/ptlinux/cli.py index 8e58a15..fcd57ae 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -22,6 +22,13 @@ from ptdiag.logs import ( from . import __version__ from .command import CommandError, CommandOutcome, CommandRunner +from .container_host import ( + ContainerHostReport, + container_host_error, + inspect_container_host, + repair_container_host, + repair_container_host_after_update, +) from .depot import ( DownloadOptions, SyncProgress, @@ -83,6 +90,15 @@ def build_parser() -> argparse.ArgumentParser: subparsers.add_parser("gui", help="open the native Linux dashboard") subparsers.add_parser("doctor", help="check cross-distribution dependencies") + container_host = subparsers.add_parser( + "container-host", + help="check Bubblewrap and Ubuntu AppArmor for the Steam Runtime", + ) + container_host.add_argument( + "--repair", + action="store_true", + help="repair only recognized Ubuntu AppArmor profile states", + ) subparsers.add_parser( "runtime-bootstrap", help="download and verify the Steam Runtime launcher helper", @@ -250,12 +266,15 @@ def main(argv: Sequence[str] | None = None) -> int: try: if command == "gui": provision_umu_for_startup(paths) + _container_host_startup_migration(paths) from .ui import run_dashboard run_dashboard(paths) return 0 if command == "doctor": return _doctor(arguments.json) + if command == "container-host": + return _container_host(arguments.repair, arguments.json) if command == "runtime-bootstrap": return _runtime_bootstrap(paths, arguments.json) if command == "login-diagnostics": @@ -384,6 +403,46 @@ def _doctor(as_json: bool) -> int: return 0 if report.ok else 1 +def _container_host(repair: bool, as_json: bool) -> int: + report = repair_container_host() if repair else inspect_container_host() + ok = _container_host_ok(report) + payload = {"ok": ok, "container_host": report.to_dict()} + if as_json: + _print_json(payload) + else: + status = "ready" if report.ready else "blocked" + print(f"Bubblewrap: {status} ({report.bwrap_path or 'not found'})") + if report.active_profile_source: + print(f"AppArmor: {report.active_profile_source}") + elif report.apparmor_restrict_unprivileged_userns == "1": + print( + "AppArmor: unprivileged user namespaces are restricted; " + "no bwrap profile found" + ) + if report.repair_actions: + for action in report.repair_actions: + print(f"Repair: {action}") + if report.repair_error: + print(f"Repair failed: {report.repair_error}", file=sys.stderr) + elif report.recommendation: + print(f"Action: {report.recommendation}", file=sys.stderr) + return 0 if ok else 1 + + +def _container_host_ok(report: ContainerHostReport) -> bool: + return ( + report.ready + and not report.broken_umu_profile_link + and report.repair_error is None + ) + + +def _container_host_startup_migration(paths: AppPaths) -> None: + report = repair_container_host_after_update(paths) + if report.repair_attempted and not _container_host_ok(report): + print(f"Warning: {container_host_error(report)}", file=sys.stderr) + + def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int: _print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…") result = provision_umu(paths) @@ -1018,6 +1077,7 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]: futex_message = "the futex facility returned an unexpected error code" wrong_elf_message = "wrong elf class: elfclass64" dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred" + bwrap_uid_map_denied = "bwrap: setting up uid map: permission denied" missing_libraries = sorted( { library @@ -1038,6 +1098,7 @@ def _runtime_issue_summary(stderr: str) -> dict[str, object]: "mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded, "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded, "dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion), + "bubblewrap_uid_map_permission_denied": bwrap_uid_map_denied in folded, } @@ -1079,6 +1140,12 @@ def _print_game_outcome( "can mask the client failure that occurred immediately before it.", file=sys.stderr, ) + if runtime_issues["bubblewrap_uid_map_permission_denied"]: + print( + "Ubuntu denied Bubblewrap's UID map before Proton started. Run " + "`pristontale-eu container-host --repair` and retry.", + file=sys.stderr, + ) recommendation = runtime_issues.get("recommended_one_variable_comparison") if isinstance(recommendation, str): print( @@ -1188,7 +1255,7 @@ def _launcher_update( as_json, ) return 0 - _print_operation_status(as_json, 1, 3, "Verifying the signed launcher release…") + _print_operation_status(as_json, 1, 4, "Verifying the signed launcher release…") result = install_launcher_update( paths, manifest_url=manifest_url, @@ -1197,7 +1264,7 @@ def _launcher_update( _print_operation_status( as_json, 2, - 3, + 4, "Launcher update installed; preparing the Steam Runtime helper…", ) umu_result: UmuBootstrapResult | None = None @@ -1216,7 +1283,19 @@ def _launcher_update( umu_warning = warning_message if not as_json: print(f"Warning: {warning_message}", file=sys.stderr) - _print_operation_status(as_json, 3, 3, "Launcher update and runtime check complete.") + _print_operation_status( + as_json, + 3, + 4, + "Checking Steam Runtime sandbox permissions (authentication may be requested)…", + ) + container_report = repair_container_host() + container_warning = container_report.repair_error + if not container_report.ready and container_warning is None: + container_warning = container_report.recommendation + if container_warning and not as_json: + print(f"Warning: {container_warning}", file=sys.stderr) + _print_operation_status(as_json, 4, 4, "Launcher update and runtime check complete.") _emit( { "ok": True, @@ -1226,6 +1305,10 @@ def _launcher_update( "managed_download": asdict(umu_result) if umu_result else None, "warning": umu_warning, }, + "container_host": { + **container_report.to_dict(), + "warning": container_warning, + }, }, as_json, ) diff --git a/ptlinux/container_host.py b/ptlinux/container_host.py new file mode 100644 index 0000000..c453fbc --- /dev/null +++ b/ptlinux/container_host.py @@ -0,0 +1,822 @@ +"""Diagnose and narrowly repair the Bubblewrap host sandbox used by UMU.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +from datetime import datetime +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +from typing import Callable, Mapping, Sequence + +from . import __version__ +from .paths import AppPaths + + +UID_MAP_PERMISSION_DENIED = "bwrap: setting up uid map: permission denied" +_BWRAP_ATTACHMENT_PATTERN = re.compile( + r"(?m)^[ \t]*(?:profile[ \t]+[^\s{]+[ \t]+)?/usr/bin/bwrap(?=[ \t{])" +) +_MAX_APPARMOR_PROFILE_BYTES = 1024 * 1024 + +# AppArmor upstream's restrictive Bubblewrap profile, pinned to a commit and +# its AppArmor 4.0 ABI so it works on Ubuntu 24.04. It grants Bubblewrap the +# setup capabilities it needs, then stacks children under unpriv_bwrap, where +# capabilities are explicitly denied. This is intentionally not an unconfined +# profile and does not disable Ubuntu's global user-namespace restriction. +BWRAP_APPARMOR_PROFILE = """# Installed by the PristonTale EU Linux launcher. +# Upstream source: +# https://gitlab.com/apparmor/apparmor/-/blob/8e431ebcd915216a03ebc8d01e72b1741bb2f855/profiles/apparmor/profiles/extras/bwrap-userns-restrict +# +# This profile allows almost everything and only exists to allow bwrap +# to work on a system with user namespace restrictions being enforced. +# bwrap is allowed access to user namespaces and capabilities within +# the user namespace, but its children do not have capabilities, +# blocking bwrap from being able to be used to arbitrarily by-pass the +# user namespace restrictions. + +# Note: the bwrap child is stacked against the bwrap profile due to +# bwraps use of no-new-privs. + +abi , + +include + +profile bwrap /usr/bin/bwrap flags=(attach_disconnected,mediate_deleted) { + allow capability, + # not allow all, to allow for pix stack on systems that don't support + # rule priority. + # + # sadly we have to allow 'm' every where to allow children to work under + # profile stacking atm. + allow file rwlkm /{**,}, + allow network, + allow unix, + allow ptrace, + allow signal, + allow mqueue, + allow io_uring, + allow userns, + allow mount, + allow umount, + allow pivot_root, + allow dbus, + + # stacked like this due to no-new-privs restriction + # this will stack a target profile against bwrap and unpriv_bwrap + # Ideally + # - there would be a transition at userns creation first. This would allow + # for the bwrap profile to be tighter, and looser within the user + # ns. bwrap will still have to fairly loose until a transition at + # namespacing in general (not just user ns) is available. + # - there would be an independent second target as fallback + # This would allow for select target profiles to be used, and not + # necessarily stack the unpriv_bwrap in cases where this is desired + # + # the ix works here because stack will apply to ix fallback + # Ideally we would sanitize the environment across a privilege boundry + # (leaving bwarp into application) but flatpak etc use environment glibc + # sanitized environment variables as part of the sandbox setup. + allow pix /** -> &bwrap//&unpriv_bwrap, + + # the local include should not be used without understanding the userns + # restriction. + # Site-specific additions and overrides. See local/README for details. + include if exists +} + +# The unpriv_bwrap profile is used to strip capabilities within the userns +profile unpriv_bwrap flags=(attach_disconnected,mediate_deleted) { + # not allow all, to allow for pix stack + allow file rwlkm /{**,}, + allow network, + allow unix, + allow ptrace, + allow signal, + allow mqueue, + allow io_uring, + allow userns, + allow mount, + allow umount, + allow pivot_root, + allow dbus, + + # bwrap profile does stacking against itself this will keep the target + # profile from having elevated privileges in the container. + # If done recursively the stack will remove any duplicate + allow pix /** -> &unpriv_bwrap, + + audit deny capability, + + # the local include should not be used without understanding the userns + # restriction. + # Site-specific additions and overrides. See local/README for details. + include if exists +} +""" +BWRAP_APPARMOR_PROFILE_SHA256 = hashlib.sha256( + BWRAP_APPARMOR_PROFILE.encode("utf-8") +).hexdigest() + + +@dataclass(frozen=True) +class ContainerHostFiles: + """Fixed host paths, injectable so repair logic can be tested safely.""" + + bwrap: Path = Path("/usr/bin/bwrap") + probe_program: Path = Path("/usr/bin/true") + os_release: Path = Path("/etc/os-release") + apparmor_parser: Path = Path("/usr/sbin/apparmor_parser") + apparmor_directory: Path = Path("/etc/apparmor.d") + install: Path = Path("/usr/bin/install") + link: Path = Path("/usr/bin/ln") + remove: Path = Path("/usr/bin/rm") + obsolete_umu_profile_target: Path = Path( + "/usr/share/apparmor/extra-profiles/bwrap-userns-restrict" + ) + apparmor_restrict_userns: Path = Path( + "/proc/sys/kernel/apparmor_restrict_unprivileged_userns" + ) + unprivileged_userns_clone: Path = Path( + "/proc/sys/kernel/unprivileged_userns_clone" + ) + max_user_namespaces: Path = Path("/proc/sys/user/max_user_namespaces") + + @property + def distribution_profile(self) -> Path: + return self.apparmor_directory / "bwrap-userns-restrict" + + @property + def managed_profile(self) -> Path: + return self.apparmor_directory / "bwrap-userns-restrict-ptlinux" + + @property + def umu_profile_link(self) -> Path: + return self.apparmor_directory / "bwrap-userns-restrict-umu" + + +DEFAULT_CONTAINER_HOST_FILES = ContainerHostFiles() +CommandExecutor = Callable[[Sequence[str], float], subprocess.CompletedProcess[str]] + + +@dataclass(frozen=True) +class ContainerHostReport: + ready: bool + issue: str | None + distribution: str + bwrap_path: str | None + probe_returncode: int | None + probe_stderr: str + apparmor_restrict_unprivileged_userns: str | None + unprivileged_userns_clone: str | None + max_user_namespaces: str | None + active_profile_source: str | None + conflicting_bwrap_profile: str | None + broken_umu_profile_link: bool + repairable: bool + recommendation: str | None + repair_attempted: bool = False + repaired: bool = False + repair_actions: tuple[str, ...] = () + repair_error: str | None = None + + def to_dict(self) -> dict[str, object]: + return asdict(self) + + +def inspect_container_host( + *, + environment: Mapping[str, str] | None = None, + files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES, + command_executor: CommandExecutor | None = None, + timeout_seconds: float = 5.0, +) -> ContainerHostReport: + """Run a harmless user-namespace probe and classify the exact failure.""" + + _ = environment + distribution_data = _read_os_release(files.os_release) + distribution = distribution_data.get("ID", "unknown") + bwrap_available = files.bwrap.is_file() and os.access(files.bwrap, os.X_OK) + restrict_userns = _read_setting(files.apparmor_restrict_userns) + userns_clone = _read_setting(files.unprivileged_userns_clone) + max_user_namespaces = _read_setting(files.max_user_namespaces) + profile = _active_profile_source(files) + broken_link = _known_broken_umu_profile_link(files) + returncode: int | None = None + stderr = "" + issue: str | None = None + if not bwrap_available: + issue = "bubblewrap-missing" + else: + command = ( + str(files.bwrap), + "--ro-bind", + "/", + "/", + "--unshare-user", + "--", + str(files.probe_program), + ) + try: + result = _run(command, timeout_seconds, command_executor) + returncode = result.returncode + stderr = _bounded_output(result.stderr) + except subprocess.TimeoutExpired as exc: + issue = "bubblewrap-probe-timeout" + stderr = _bounded_output(_timeout_stderr(exc)) + except OSError as exc: + issue = "bubblewrap-probe-error" + stderr = str(exc) + if issue is None and returncode != 0: + folded = stderr.casefold() + if UID_MAP_PERMISSION_DENIED in folded: + issue = "uid-map-permission-denied" + elif userns_clone == "0": + issue = "unprivileged-userns-disabled" + elif max_user_namespaces == "0": + issue = "user-namespace-limit-zero" + else: + issue = "bubblewrap-probe-failed" + + conflicting_profile = ( + _find_conflicting_bwrap_profile(files) + if issue == "uid-map-permission-denied" or broken_link + else None + ) + ubuntu_family = _is_ubuntu_family(distribution_data) + managed_collision = _managed_profile_collision(files) + repairable = ( + ubuntu_family + and restrict_userns == "1" + and files.apparmor_parser.is_file() + and os.access(files.apparmor_parser, os.X_OK) + and bwrap_available + and not managed_collision + and conflicting_profile is None + and (issue == "uid-map-permission-denied" or broken_link) + ) + recommendation = _recommendation( + issue, + repairable=repairable, + broken_link=broken_link, + conflicting_profile=conflicting_profile, + stderr=stderr, + ) + return ContainerHostReport( + ready=issue is None, + issue=issue, + distribution=distribution, + bwrap_path=str(files.bwrap) if bwrap_available else None, + probe_returncode=returncode, + probe_stderr=stderr, + apparmor_restrict_unprivileged_userns=restrict_userns, + unprivileged_userns_clone=userns_clone, + max_user_namespaces=max_user_namespaces, + active_profile_source=str(profile) if profile else None, + conflicting_bwrap_profile=( + str(conflicting_profile) if conflicting_profile else None + ), + broken_umu_profile_link=broken_link, + repairable=repairable, + recommendation=recommendation, + ) + + +def repair_container_host( + *, + environment: Mapping[str, str] | None = None, + files: ContainerHostFiles = DEFAULT_CONTAINER_HOST_FILES, + command_executor: CommandExecutor | None = None, + privileged_executor: CommandExecutor | None = None, +) -> ContainerHostReport: + """Repair only known Ubuntu AppArmor states, then repeat the probe.""" + + before = inspect_container_host( + environment=environment, + files=files, + command_executor=command_executor, + ) + if before.ready and not before.broken_umu_profile_link: + return before + if not before.repairable: + return replace( + before, + repair_attempted=True, + repair_error=( + before.recommendation or "No safe automatic repair is available." + ), + ) + actions: list[str] = [] + created_managed_profile = False + profile_loaded = False + selected_profile = _active_profile_source(files) + try: + if selected_profile is None: + selected_profile = files.managed_profile + _validate_and_install_managed_profile( + files, + command_executor=command_executor, + privileged_executor=privileged_executor, + ) + created_managed_profile = True + actions.append(f"installed {selected_profile}") + else: + _validate_profile( + selected_profile, + files, + command_executor=command_executor, + ) + + loaded = _run_privileged( + (str(files.apparmor_parser), "-r", str(selected_profile)), + 120.0, + privileged_executor, + ) + _require_success(loaded, f"could not load AppArmor profile {selected_profile}") + profile_loaded = True + actions.append(f"loaded {selected_profile}") + + if _known_broken_umu_profile_link(files): + linked = _run_privileged( + ( + str(files.link), + "-sfn", + "--", + str(selected_profile), + str(files.umu_profile_link), + ), + 120.0, + privileged_executor, + ) + _require_success( + linked, + "could not repair UMU's broken AppArmor profile link", + ) + actions.append(f"repaired {files.umu_profile_link}") + except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: + rollback_error = None + if created_managed_profile and not profile_loaded: + rollback_error = _rollback_managed_profile(files, privileged_executor) + if rollback_error is None: + actions.append(f"rolled back {files.managed_profile}") + after_failure = inspect_container_host( + environment=environment, + files=files, + command_executor=command_executor, + ) + error = str(exc) + if rollback_error: + error = f"{error}; rollback also failed: {rollback_error}" + return replace( + after_failure, + repair_attempted=True, + repair_actions=tuple(actions), + repair_error=error, + ) + + after = inspect_container_host( + environment=environment, + files=files, + command_executor=command_executor, + ) + error = None + if not after.ready: + error = ( + "The AppArmor profile loaded, but the Bubblewrap user-namespace probe " + "still fails. " + + (after.recommendation or "Inspect the host security policy.") + ) + return replace( + after, + repair_attempted=True, + repaired=after.ready, + repair_actions=tuple(actions), + repair_error=error, + ) + + +def repair_container_host_after_update(paths: AppPaths) -> ContainerHostReport: + """Run the repair once after activation, including upgrades by older code.""" + + status = inspect_container_host() + if status.ready and not status.broken_umu_profile_link: + return status + if not _launcher_was_updated_to_current_version(paths): + return status + marker = paths.container_host_repair_state + if _repair_was_attempted(marker, status): + return status + print( + "Launcher update: checking Steam Runtime sandbox permissions; " + "authentication may be requested for a narrow AppArmor repair.", + file=sys.stderr, + ) + report = repair_container_host() + try: + _write_repair_marker(marker, report) + except OSError as exc: + print( + f"Warning: could not record the container-host repair attempt: {exc}", + file=sys.stderr, + ) + return report + + +def container_host_error(report: ContainerHostReport) -> str: + if report.ready and report.broken_umu_profile_link: + return report.repair_error or report.recommendation or ( + "UMU's AppArmor profile link remains broken." + ) + message = ( + "The Steam Runtime container cannot create its Bubblewrap user namespace." + ) + if report.repair_error: + return f"{message} Host repair failed: {report.repair_error}" + if report.recommendation: + return f"{message} {report.recommendation}" + if report.probe_stderr: + return f"{message} Bubblewrap reported: {report.probe_stderr}" + return message + + +def _validate_and_install_managed_profile( + files: ContainerHostFiles, + *, + command_executor: CommandExecutor | None, + privileged_executor: CommandExecutor | None, +) -> None: + if files.managed_profile.exists() or files.managed_profile.is_symlink(): + if _is_managed_profile(files.managed_profile): + _validate_profile( + files.managed_profile, + files, + command_executor=command_executor, + ) + return + raise RuntimeError( + f"refusing to overwrite an unmanaged AppArmor file: {files.managed_profile}" + ) + with tempfile.TemporaryDirectory(prefix="ptlinux-bwrap-profile-") as temporary: + candidate = Path(temporary) / "bwrap-userns-restrict-ptlinux" + candidate.write_text(BWRAP_APPARMOR_PROFILE, encoding="utf-8") + candidate.chmod(0o600) + _validate_profile(candidate, files, command_executor=command_executor) + installed = _run_privileged( + ( + str(files.install), + "-m", + "0644", + "--", + str(candidate), + str(files.managed_profile), + ), + 120.0, + privileged_executor, + ) + _require_success(installed, "could not install the managed AppArmor profile") + + +def _validate_profile( + profile: Path, + files: ContainerHostFiles, + *, + command_executor: CommandExecutor | None, +) -> None: + validated = _run( + (str(files.apparmor_parser), "-Q", "-K", str(profile)), + 30.0, + command_executor, + ) + _require_success(validated, f"AppArmor rejected profile {profile}") + + +def _rollback_managed_profile( + files: ContainerHostFiles, + privileged_executor: CommandExecutor | None, +) -> str | None: + try: + removed = _run_privileged( + (str(files.remove), "--", str(files.managed_profile)), + 120.0, + privileged_executor, + ) + _require_success(removed, "could not remove the newly installed profile") + except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc: + return str(exc) + return None + + +def _run( + command: Sequence[str], + timeout_seconds: float, + executor: CommandExecutor | None, +) -> subprocess.CompletedProcess[str]: + if executor is not None: + return executor(tuple(command), timeout_seconds) + return subprocess.run( + list(command), + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + + +def _run_privileged( + command: Sequence[str], + timeout_seconds: float, + executor: CommandExecutor | None, +) -> subprocess.CompletedProcess[str]: + if executor is not None: + return executor(tuple(command), timeout_seconds) + prefix = _privilege_prefix() + return subprocess.run( + [*prefix, *command], + capture_output=True, + text=True, + timeout=timeout_seconds, + check=False, + ) + + +def _privilege_prefix() -> tuple[str, ...]: + if os.geteuid() == 0: + return () + if sys.stdin.isatty(): + for candidate in (Path("/usr/bin/sudo"), Path("/usr/bin/doas")): + if candidate.is_file() and os.access(candidate, os.X_OK): + return (str(candidate),) + pkexec = Path("/usr/bin/pkexec") + if pkexec.is_file() and os.access(pkexec, os.X_OK): + return (str(pkexec),) + sudo = Path("/usr/bin/sudo") + if sudo.is_file() and os.access(sudo, os.X_OK): + return str(sudo), "-n" + raise RuntimeError( + "root access is required for the AppArmor repair, but no usable " + "sudo, doas, or pkexec command was found" + ) + + +def _require_success(result: subprocess.CompletedProcess[str], message: str) -> None: + if result.returncode == 0: + return + detail = _bounded_output(result.stderr) or _bounded_output(result.stdout) + if detail: + raise RuntimeError(f"{message}: {detail}") + raise RuntimeError(f"{message} (exit status {result.returncode})") + + +def _active_profile_source(files: ContainerHostFiles) -> Path | None: + if files.distribution_profile.is_file(): + return files.distribution_profile + if _trusted_umu_profile_link(files): + return files.umu_profile_link + if _is_managed_profile(files.managed_profile): + return files.managed_profile + return None + + +def _is_managed_profile(path: Path) -> bool: + if not path.is_file() or path.is_symlink(): + return False + try: + content = path.read_bytes() + except OSError: + return False + return hashlib.sha256(content).hexdigest() == BWRAP_APPARMOR_PROFILE_SHA256 + + +def _managed_profile_collision(files: ContainerHostFiles) -> bool: + path = files.managed_profile + return (path.exists() or path.is_symlink()) and not _is_managed_profile(path) + + +def _find_conflicting_bwrap_profile(files: ContainerHostFiles) -> Path | None: + known_names = { + files.distribution_profile.name, + files.managed_profile.name, + } + if _trusted_umu_profile_link(files): + known_names.add(files.umu_profile_link.name) + try: + candidates = sorted( + files.apparmor_directory.iterdir(), + key=lambda path: path.name, + ) + except OSError: + return None + for candidate in candidates: + if candidate.name in known_names or not candidate.is_file(): + continue + try: + if candidate.stat().st_size > _MAX_APPARMOR_PROFILE_BYTES: + continue + content = candidate.read_text(encoding="utf-8") + except (OSError, UnicodeError): + continue + policy = "\n".join(line.split("#", 1)[0] for line in content.splitlines()) + if _BWRAP_ATTACHMENT_PATTERN.search(policy): + return candidate + return None + + +def _trusted_umu_profile_link(files: ContainerHostFiles) -> bool: + link = files.umu_profile_link + if not link.is_symlink() or not link.is_file(): + return False + try: + target_text = os.readlink(link) + target = Path(target_text) + if not target.is_absolute(): + target = link.parent / target + target = target.resolve() + allowed = { + files.distribution_profile.resolve(), + files.managed_profile.resolve(), + files.obsolete_umu_profile_target.resolve(), + } + except OSError: + return False + return target in allowed + + +def _known_broken_umu_profile_link(files: ContainerHostFiles) -> bool: + link = files.umu_profile_link + if not link.is_symlink(): + return False + try: + target_text = os.readlink(link) + except OSError: + return False + if target_text != str(files.obsolete_umu_profile_target): + return False + return not files.obsolete_umu_profile_target.exists() + + +def _read_os_release(path: Path) -> dict[str, str]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return {} + result: dict[str, str] = {} + for line in lines: + if "=" not in line or line.startswith("#"): + continue + key, value = line.split("=", 1) + result[key] = value.strip().strip('"').strip("'") + return result + + +def _is_ubuntu_family(os_release: Mapping[str, str]) -> bool: + distribution = os_release.get("ID", "").casefold() + like = set(os_release.get("ID_LIKE", "").casefold().split()) + return distribution == "ubuntu" or "ubuntu" in like + + +def _read_setting(path: Path) -> str | None: + try: + return path.read_text(encoding="ascii").strip()[:64] + except (OSError, UnicodeError): + return None + + +def _recommendation( + issue: str | None, + *, + repairable: bool, + broken_link: bool, + conflicting_profile: Path | None, + stderr: str, +) -> str | None: + if issue is None: + if broken_link: + return ( + "UMU's AppArmor profile link is broken; run " + "`pristontale-eu container-host --repair` before rebooting." + ) + return None + if issue == "bubblewrap-missing": + return ( + "Install the distribution's bubblewrap package and run the check again." + ) + if issue == "uid-map-permission-denied": + if repairable: + return ( + "Ubuntu AppArmor denied Bubblewrap's UID map. Run " + "`pristontale-eu container-host --repair`; authentication is " + "required." + ) + if conflicting_profile is not None: + return ( + "Bubblewrap's UID map was denied, but an unrelated AppArmor profile " + f"already attaches to /usr/bin/bwrap: {conflicting_profile}. The " + "launcher will not install a competing profile automatically." + ) + return ( + "Bubblewrap's UID map was denied, but this host does not match the " + "narrow Ubuntu AppArmor repair. Inspect the loaded bwrap policy." + ) + if issue == "unprivileged-userns-disabled": + return ( + "kernel.unprivileged_userns_clone is disabled. The launcher will not " + "weaken this global security setting automatically." + ) + if issue == "user-namespace-limit-zero": + return ( + "user.max_user_namespaces is zero. The launcher will not change this " + "global limit automatically." + ) + if issue == "bubblewrap-probe-timeout": + return ( + "The Bubblewrap sandbox probe timed out; inspect the host sandbox policy." + ) + if stderr: + return f"Bubblewrap's sandbox probe failed: {stderr}" + return "Bubblewrap's sandbox probe failed without a diagnostic message." + + +def _timeout_stderr(error: subprocess.TimeoutExpired) -> str: + value = error.stderr + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value or "" + + +def _bounded_output(value: str | None, limit: int = 2048) -> str: + if not value: + return "" + cleaned = value.strip() + if len(cleaned) <= limit: + return cleaned + return cleaned[:limit] + "…" + + +def _launcher_was_updated_to_current_version(paths: AppPaths) -> bool: + try: + data = paths.launcher_update_state.read_bytes() + if len(data) > 64 * 1024: + return False + payload = json.loads(data) + except (OSError, UnicodeError, json.JSONDecodeError): + return False + return isinstance(payload, dict) and payload.get("to_version") == __version__ + + +def _repair_was_attempted(path: Path, status: ContainerHostReport) -> bool: + try: + data = path.read_bytes() + if len(data) > 64 * 1024: + return False + payload = json.loads(data) + except (OSError, UnicodeError, json.JSONDecodeError): + return False + return ( + isinstance(payload, dict) + and payload.get("schema") == 1 + and payload.get("launcher_version") == __version__ + and payload.get("profile_sha256") == BWRAP_APPARMOR_PROFILE_SHA256 + and payload.get("issue") == status.issue + and payload.get("broken_umu_profile_link") == status.broken_umu_profile_link + ) + + +def _write_repair_marker(path: Path, report: ContainerHostReport) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema": 1, + "attempted_at": datetime.now().astimezone().isoformat(), + "launcher_version": __version__, + "profile_sha256": BWRAP_APPARMOR_PROFILE_SHA256, + "issue": report.issue, + "broken_umu_profile_link": report.broken_umu_profile_link, + "ready": report.ready, + "repair_error": report.repair_error, + } + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=f".{path.name}.", + delete=False, + ) as handle: + temporary = Path(handle.name) + try: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + except BaseException: + temporary.unlink(missing_ok=True) + raise + try: + temporary.chmod(0o600) + temporary.replace(path) + except BaseException: + temporary.unlink(missing_ok=True) + raise diff --git a/ptlinux/doctor.py b/ptlinux/doctor.py index 3bd9522..2c43ce7 100644 --- a/ptlinux/doctor.py +++ b/ptlinux/doctor.py @@ -10,6 +10,7 @@ import platform import shutil from typing import Mapping +from .container_host import ContainerHostReport, inspect_container_host from .launcher import find_wine_tools from .paths import AppPaths from .runtime import find_steam_proton7 @@ -37,6 +38,7 @@ class DoctorReport: architecture: str display_backend: str tools: list[ToolStatus] + container_host: ContainerHostReport package_hint: str | None warnings: list[str] @@ -51,6 +53,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: version = os_release.get("VERSION_ID", "unknown") wine_tools = find_wine_tools() umu_path = _umu_path(env) + container_host = inspect_container_host(environment=env) + container_required = umu_path is not None tools = [ ToolStatus( "python3", @@ -100,6 +104,12 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: distribution in ARCH_FAMILY_DISTRIBUTIONS, "Steam Runtime container; managed automatically when needed", ), + ToolStatus( + "bubblewrap", + container_host.bwrap_path, + container_required, + "user-namespace sandbox required by UMU and pressure-vessel", + ), ToolStatus( "vulkaninfo", shutil.which("vulkaninfo"), @@ -118,15 +128,30 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport: "No local game runtime is installed yet; run runtime-bootstrap to fetch the " "pinned official UMU helper. Startup and Play also retry automatically." ) + if container_required and not container_host.ready: + warnings.append( + container_host.recommendation + or "The Steam Runtime Bubblewrap sandbox is unavailable." + ) + if container_host.broken_umu_profile_link: + warnings.append( + "UMU's AppArmor profile link is broken and may prevent profiles from " + "loading after reboot; run `pristontale-eu container-host --repair`." + ) warnings.extend(_arch_runtime_warnings(distribution, platform.machine())) required_ok = all(item.path is not None for item in tools if item.required) return DoctorReport( - ok=required_ok, + ok=( + required_ok + and (not container_required or container_host.ready) + and not container_host.broken_umu_profile_link + ), distribution=distribution, version=version, architecture=platform.machine(), display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11", tools=tools, + container_host=container_host, package_hint=_package_hint(distribution), warnings=warnings, ) @@ -160,21 +185,22 @@ def _package_hint(distribution: str) -> str | None: if distribution in {"ubuntu", "debian", "linuxmint", "pop"}: return ( "Install Python/Tk, Wine (64- and 32-bit), Winetricks, " - "Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT." + "Cabextract, OpenSSL, Bubblewrap, and both 64- and 32-bit Vulkan " + "drivers with APT. Ubuntu also needs its AppArmor tools." ) if distribution in {"fedora", "nobara"}: return ( - "Install python3-tkinter, wine, winetricks, cabextract, openssl, " + "Install python3-tkinter, wine, winetricks, bubblewrap, cabextract, openssl, " "and the matching 32-bit Vulkan driver with DNF." ) if distribution in ARCH_FAMILY_DISTRIBUTIONS: return ( - "Install python, tk, wine, winetricks, umu-launcher, cabextract, " + "Install python, tk, wine, winetricks, umu-launcher, bubblewrap, cabextract, " "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman." ) if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: return ( - "Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and " + "Install Python/Tk, Wine, Winetricks, Bubblewrap, Cabextract, OpenSSL, and " "32-bit Vulkan support with Zypper." ) return None diff --git a/ptlinux/paths.py b/ptlinux/paths.py index 5353317..95edd99 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -120,6 +120,12 @@ class AppPaths: return self.cache / "launcher-update-check.json" + @property + def container_host_repair_state(self) -> Path: + """One-shot migration state for the Ubuntu Bubblewrap host repair.""" + + return self.state / "container-host-repair-v1.json" + def launcher_update_archive(self, version: str) -> Path: return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz" diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index 8f2c33d..75dafb5 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -16,6 +16,7 @@ import sys from typing import Mapping, Sequence from .command import CommandOutcome, CommandRunner, OutputLineFilter +from .container_host import container_host_error, inspect_container_host from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption from .gamebox import build_gamebox_command from .paths import AppPaths @@ -255,6 +256,7 @@ def prepare_game_runtime( selection: RuntimeSelection, runner: CommandRunner, ) -> list[CommandOutcome]: + _require_container_host(selection, runner) if not runner.dry_run: paths.create_base_directories() results: list[CommandOutcome] = [] @@ -388,6 +390,7 @@ def run_game( extra_environment: Mapping[str, str] | None = None, output_line_filter: OutputLineFilter | None = None, ) -> CommandOutcome: + _require_container_host(selection, runner) if not paths.runtime_view.is_dir() and not runner.dry_run: raise RuntimeErrorPT("runtime view is missing; prepare the game first") native_executable, windows_executable, game_arguments = _game_start_command( @@ -520,6 +523,7 @@ def run_runtime_prefix_command( if not arguments: raise ValueError("runtime-prefix command arguments cannot be empty") + _require_container_host(selection, runner) if selection.kind == "umu": command = [selection.executable, *arguments] environment = _umu_environment(paths, selection) @@ -550,6 +554,17 @@ def run_runtime_prefix_command( ) +def _require_container_host( + selection: RuntimeSelection, + runner: CommandRunner, +) -> None: + if runner.dry_run or selection.kind not in {"umu", "proton7-container"}: + return + report = inspect_container_host() + if not report.ready: + raise RuntimeErrorPT(container_host_error(report)) + + def _runtime_python_path(existing: str | None) -> str: """Make the installed package importable after changing into runtime_view.""" package_root = str(Path(__file__).resolve().parent.parent)