Added bubblewrap / apparmor fixes and diagnostic tools for troubleshooting it on related distros.

This commit is contained in:
2026-09-03 22:16:43 +02:00
parent ecdea827ac
commit 5be4ace6c2
9 changed files with 1039 additions and 9 deletions

View File

@@ -57,6 +57,8 @@ pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components pristontale-eu doctor Check required host components
pristontale-eu runtime-bootstrap Prepare the verified UMU helper 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 Check the login route without credentials
pristontale-eu login-diagnostics --runtime proton7 pristontale-eu login-diagnostics --runtime proton7
Compare the direct-Proton prefix read-only 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 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.

View File

@@ -44,6 +44,7 @@ usage()
"" \ "" \
" --install-dir PATH Store the app, managed game, and runtime data at PATH." \ " --install-dir PATH Store the app, managed game, and runtime data at PATH." \
" Default: ~/Games/PristontaleEU" \ " Default: ~/Games/PristontaleEU" \
" --skip-deps Do not install packages or repair host AppArmor profiles." \
" --skip-runtime-bootstrap" \ " --skip-runtime-bootstrap" \
" Do not prepare the verified UMU helper during install." " Do not prepare the verified UMU helper during install."
} }
@@ -184,6 +185,11 @@ fi
if (( DRY_RUN == 1 )) if (( DRY_RUN == 1 ))
then 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 exit 0
fi fi
@@ -339,6 +345,18 @@ then
fi fi
printf '%s\n' "Launcher files installed." 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 RUNTIME_BOOTSTRAP_READY=1
if (( RUN_RUNTIME_BOOTSTRAP == 1 )) if (( RUN_RUNTIME_BOOTSTRAP == 1 ))
then then
@@ -381,6 +399,14 @@ case ":${PATH:-}:" in
;; ;;
esac 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 )) if (( RUNTIME_BOOTSTRAP_READY == 0 ))
then then
printf '%s\n' \ printf '%s\n' \

View File

@@ -79,6 +79,12 @@ ptlinux_resolve_dependency_packages()
debian:openssl) debian:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
debian:bubblewrap)
candidates=(bubblewrap)
;;
debian:apparmor)
candidates=(apparmor)
;;
fedora:python) fedora:python)
candidates=(python3) candidates=(python3)
;; ;;
@@ -100,6 +106,9 @@ ptlinux_resolve_dependency_packages()
fedora:openssl) fedora:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
fedora:bubblewrap)
candidates=(bubblewrap)
;;
arch:python) arch:python)
candidates=(python) candidates=(python)
;; ;;
@@ -121,6 +130,9 @@ ptlinux_resolve_dependency_packages()
arch:openssl) arch:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
arch:bubblewrap)
candidates=(bubblewrap)
;;
arch:umu) arch:umu)
candidates=(umu-launcher) candidates=(umu-launcher)
;; ;;
@@ -145,6 +157,9 @@ ptlinux_resolve_dependency_packages()
suse:openssl) suse:openssl)
candidates=(openssl) candidates=(openssl)
;; ;;
suse:bubblewrap)
candidates=(bubblewrap)
;;
*) *)
printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2 printf 'No package mapping for %s on %s systems.\n' "$dependency" "$family" >&2
return 1 return 1
@@ -239,6 +254,18 @@ ptlinux_detect_missing_dependencies()
then then
PTLINUX_MISSING_DEPENDENCIES+=(openssl) PTLINUX_MISSING_DEPENDENCIES+=(openssl)
fi 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 if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
then then
PTLINUX_MISSING_DEPENDENCIES+=(umu) PTLINUX_MISSING_DEPENDENCIES+=(umu)

View File

@@ -8,6 +8,10 @@ def main() -> int:
arguments = sys.argv[1:] arguments = sys.argv[1:]
if not arguments or arguments == ["gui"]: if not arguments or arguments == ["gui"]:
from .container_host import (
container_host_error,
repair_container_host_after_update,
)
from .paths import AppPaths from .paths import AppPaths
from .umu import provision_umu_for_startup 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 # Older installed releases cannot run a bootstrap step that only exists
# in their update payload. Their automatic restart enters here instead. # in their update payload. Their automatic restart enters here instead.
provision_umu_for_startup(paths) 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 from .ui import run_dashboard
run_dashboard(paths) run_dashboard(paths)

View File

@@ -22,6 +22,13 @@ from ptdiag.logs import (
from . import __version__ from . import __version__
from .command import CommandError, CommandOutcome, CommandRunner 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 ( from .depot import (
DownloadOptions, DownloadOptions,
SyncProgress, SyncProgress,
@@ -83,6 +90,15 @@ def build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("gui", help="open the native Linux dashboard") subparsers.add_parser("gui", help="open the native Linux dashboard")
subparsers.add_parser("doctor", help="check cross-distribution dependencies") 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( subparsers.add_parser(
"runtime-bootstrap", "runtime-bootstrap",
help="download and verify the Steam Runtime launcher helper", help="download and verify the Steam Runtime launcher helper",
@@ -250,12 +266,15 @@ def main(argv: Sequence[str] | None = None) -> int:
try: try:
if command == "gui": if command == "gui":
provision_umu_for_startup(paths) provision_umu_for_startup(paths)
_container_host_startup_migration(paths)
from .ui import run_dashboard from .ui import run_dashboard
run_dashboard(paths) run_dashboard(paths)
return 0 return 0
if command == "doctor": if command == "doctor":
return _doctor(arguments.json) return _doctor(arguments.json)
if command == "container-host":
return _container_host(arguments.repair, arguments.json)
if command == "runtime-bootstrap": if command == "runtime-bootstrap":
return _runtime_bootstrap(paths, arguments.json) return _runtime_bootstrap(paths, arguments.json)
if command == "login-diagnostics": if command == "login-diagnostics":
@@ -384,6 +403,46 @@ def _doctor(as_json: bool) -> int:
return 0 if report.ok else 1 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: def _runtime_bootstrap(paths: AppPaths, as_json: bool) -> int:
_print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…") _print_operation_status(as_json, 1, 1, "Preparing the Steam Runtime helper…")
result = provision_umu(paths) 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" futex_message = "the futex facility returned an unexpected error code"
wrong_elf_message = "wrong elf class: elfclass64" wrong_elf_message = "wrong elf class: elfclass64"
dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred" dbghelp_assertion = "assertion failed: module->module.symtype != symdeferred"
bwrap_uid_map_denied = "bwrap: setting up uid map: permission denied"
missing_libraries = sorted( missing_libraries = sorted(
{ {
library 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, "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, "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
"dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion), "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.", "can mask the client failure that occurred immediately before it.",
file=sys.stderr, 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") recommendation = runtime_issues.get("recommended_one_variable_comparison")
if isinstance(recommendation, str): if isinstance(recommendation, str):
print( print(
@@ -1188,7 +1255,7 @@ def _launcher_update(
as_json, as_json,
) )
return 0 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( result = install_launcher_update(
paths, paths,
manifest_url=manifest_url, manifest_url=manifest_url,
@@ -1197,7 +1264,7 @@ def _launcher_update(
_print_operation_status( _print_operation_status(
as_json, as_json,
2, 2,
3, 4,
"Launcher update installed; preparing the Steam Runtime helper…", "Launcher update installed; preparing the Steam Runtime helper…",
) )
umu_result: UmuBootstrapResult | None = None umu_result: UmuBootstrapResult | None = None
@@ -1216,7 +1283,19 @@ def _launcher_update(
umu_warning = warning_message umu_warning = warning_message
if not as_json: if not as_json:
print(f"Warning: {warning_message}", file=sys.stderr) 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( _emit(
{ {
"ok": True, "ok": True,
@@ -1226,6 +1305,10 @@ def _launcher_update(
"managed_download": asdict(umu_result) if umu_result else None, "managed_download": asdict(umu_result) if umu_result else None,
"warning": umu_warning, "warning": umu_warning,
}, },
"container_host": {
**container_report.to_dict(),
"warning": container_warning,
},
}, },
as_json, as_json,
) )

822
ptlinux/container_host.py Normal file
View File

@@ -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 <abi/4.0>,
include <tunables/global>
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 <local/bwrap-userns-restrict>
}
# 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 <local/unpriv_bwrap>
}
"""
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

View File

@@ -10,6 +10,7 @@ import platform
import shutil import shutil
from typing import Mapping from typing import Mapping
from .container_host import ContainerHostReport, inspect_container_host
from .launcher import find_wine_tools from .launcher import find_wine_tools
from .paths import AppPaths from .paths import AppPaths
from .runtime import find_steam_proton7 from .runtime import find_steam_proton7
@@ -37,6 +38,7 @@ class DoctorReport:
architecture: str architecture: str
display_backend: str display_backend: str
tools: list[ToolStatus] tools: list[ToolStatus]
container_host: ContainerHostReport
package_hint: str | None package_hint: str | None
warnings: list[str] warnings: list[str]
@@ -51,6 +53,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
version = os_release.get("VERSION_ID", "unknown") version = os_release.get("VERSION_ID", "unknown")
wine_tools = find_wine_tools() wine_tools = find_wine_tools()
umu_path = _umu_path(env) umu_path = _umu_path(env)
container_host = inspect_container_host(environment=env)
container_required = umu_path is not None
tools = [ tools = [
ToolStatus( ToolStatus(
"python3", "python3",
@@ -100,6 +104,12 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
distribution in ARCH_FAMILY_DISTRIBUTIONS, distribution in ARCH_FAMILY_DISTRIBUTIONS,
"Steam Runtime container; managed automatically when needed", "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( ToolStatus(
"vulkaninfo", "vulkaninfo",
shutil.which("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 " "No local game runtime is installed yet; run runtime-bootstrap to fetch the "
"pinned official UMU helper. Startup and Play also retry automatically." "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())) warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
required_ok = all(item.path is not None for item in tools if item.required) required_ok = all(item.path is not None for item in tools if item.required)
return DoctorReport( 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, distribution=distribution,
version=version, version=version,
architecture=platform.machine(), architecture=platform.machine(),
display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11", display_backend="wayland" if env.get("WAYLAND_DISPLAY") else "x11",
tools=tools, tools=tools,
container_host=container_host,
package_hint=_package_hint(distribution), package_hint=_package_hint(distribution),
warnings=warnings, warnings=warnings,
) )
@@ -160,21 +185,22 @@ def _package_hint(distribution: str) -> str | None:
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}: if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
return ( return (
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, " "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"}: if distribution in {"fedora", "nobara"}:
return ( 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." "and the matching 32-bit Vulkan driver with DNF."
) )
if distribution in ARCH_FAMILY_DISTRIBUTIONS: if distribution in ARCH_FAMILY_DISTRIBUTIONS:
return ( 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." "openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
) )
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}: if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
return ( return (
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and " "Install Python/Tk, Wine, Winetricks, Bubblewrap, Cabextract, OpenSSL, and "
"32-bit Vulkan support with Zypper." "32-bit Vulkan support with Zypper."
) )
return None return None

View File

@@ -120,6 +120,12 @@ class AppPaths:
return self.cache / "launcher-update-check.json" 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: def launcher_update_archive(self, version: str) -> Path:
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz" return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"

View File

@@ -16,6 +16,7 @@ import sys
from typing import Mapping, Sequence from typing import Mapping, Sequence
from .command import CommandOutcome, CommandRunner, OutputLineFilter 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 .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
from .gamebox import build_gamebox_command from .gamebox import build_gamebox_command
from .paths import AppPaths from .paths import AppPaths
@@ -255,6 +256,7 @@ def prepare_game_runtime(
selection: RuntimeSelection, selection: RuntimeSelection,
runner: CommandRunner, runner: CommandRunner,
) -> list[CommandOutcome]: ) -> list[CommandOutcome]:
_require_container_host(selection, runner)
if not runner.dry_run: if not runner.dry_run:
paths.create_base_directories() paths.create_base_directories()
results: list[CommandOutcome] = [] results: list[CommandOutcome] = []
@@ -388,6 +390,7 @@ def run_game(
extra_environment: Mapping[str, str] | None = None, extra_environment: Mapping[str, str] | None = None,
output_line_filter: OutputLineFilter | None = None, output_line_filter: OutputLineFilter | None = None,
) -> CommandOutcome: ) -> CommandOutcome:
_require_container_host(selection, runner)
if not paths.runtime_view.is_dir() and not runner.dry_run: if not paths.runtime_view.is_dir() and not runner.dry_run:
raise RuntimeErrorPT("runtime view is missing; prepare the game first") raise RuntimeErrorPT("runtime view is missing; prepare the game first")
native_executable, windows_executable, game_arguments = _game_start_command( native_executable, windows_executable, game_arguments = _game_start_command(
@@ -520,6 +523,7 @@ def run_runtime_prefix_command(
if not arguments: if not arguments:
raise ValueError("runtime-prefix command arguments cannot be empty") raise ValueError("runtime-prefix command arguments cannot be empty")
_require_container_host(selection, runner)
if selection.kind == "umu": if selection.kind == "umu":
command = [selection.executable, *arguments] command = [selection.executable, *arguments]
environment = _umu_environment(paths, selection) 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: def _runtime_python_path(existing: str | None) -> str:
"""Make the installed package importable after changing into runtime_view.""" """Make the installed package importable after changing into runtime_view."""
package_root = str(Path(__file__).resolve().parent.parent) package_root = str(Path(__file__).resolve().parent.parent)