Added bubblewrap / apparmor fixes and diagnostic tools for troubleshooting it on related distros.
This commit is contained in:
@@ -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.
|
||||
|
||||
26
install.sh
26
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' \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
822
ptlinux/container_host.py
Normal file
822
ptlinux/container_host.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user