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 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.
|
||||||
|
|||||||
26
install.sh
26
install.sh
@@ -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' \
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
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
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user