mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-08-24 07:41:45 +02:00
fix: support ptrace-restricted browser containers (#1242)
## ℹ️ Description - Link to the related issue(s): Fixes #1240 - Allow ptrace-restricted Docker and LXC users to omit Chromium's `--test-type` startup flag while preserving the existing default for other users. ## 📋 Changes Summary - Add `browser.suppress_unsupported_flag_warning`, defaulting to `true`. - Warn in recognized Linux containers without `CAP_SYS_PTRACE` when the flag remains enabled. - Document the container configuration, regenerate config artifacts, and cover default, opt-out, runtime propagation, and Linux-specific behavior. ### ⚙️ Type of Change - [x] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a browser setting to control Chromium warning suppression, enabled by default. - Added support for disabling the setting in ptrace-restricted Docker or LXC containers to help browser startup succeed. - Added diagnostics and warnings for environments missing the required system capability. - **Documentation** - Documented the new setting, container limitations, and configuration guidance. - **Tests** - Added coverage for configuration validation, container detection, capability handling, and browser launch warnings. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -44,6 +44,7 @@ reviews:
|
||||
# Root config files
|
||||
- "pyproject.toml"
|
||||
- "*.yaml"
|
||||
- "**/*.yaml"
|
||||
- "*.yml"
|
||||
- "**/*.md"
|
||||
|
||||
|
||||
@@ -465,6 +465,15 @@ browser:
|
||||
- **Root user**: Never run as root, use regular user
|
||||
- **Display**: Ensure X11 or Wayland is properly configured
|
||||
|
||||
#### ptrace-restricted containers
|
||||
|
||||
Chrome and Brave can exit before startup in Docker or LXC containers without `CAP_SYS_PTRACE` when launched with Chromium's internal `--test-type` flag. Keep the normal default for desktop use, but disable only this warning-suppression flag in affected containers:
|
||||
|
||||
```yaml
|
||||
browser:
|
||||
suppress_unsupported_flag_warning: false
|
||||
```
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
### Basic working configuration
|
||||
|
||||
@@ -299,6 +299,8 @@ browser:
|
||||
- --no-sandbox
|
||||
# --headless
|
||||
# --start-maximized
|
||||
# Set false in ptrace-restricted containers if --test-type prevents browser startup.
|
||||
suppress_unsupported_flag_warning: true
|
||||
binary_location: # path to custom browser executable, if not specified will be looked up on PATH
|
||||
extensions: [] # a list of .crx extension files to be loaded
|
||||
use_private_window: true
|
||||
@@ -313,6 +315,8 @@ browser:
|
||||
- `--headless` - Run browser in headless mode (no GUI)
|
||||
- `--start-maximized` - Start browser maximized
|
||||
|
||||
`suppress_unsupported_flag_warning` keeps Chromium's `--test-type` flag enabled by default to hide unsupported-command-line warnings. Set it to `false` in Docker or LXC environments without `CAP_SYS_PTRACE` if Chrome or Brave exits before the DevTools endpoint becomes available.
|
||||
|
||||
For detailed browser connection troubleshooting, including Chrome 136+ security requirements and remote debugging setup, see [Browser Troubleshooting](./BROWSER_TROUBLESHOOTING.md).
|
||||
|
||||
### update_check
|
||||
|
||||
@@ -206,6 +206,9 @@ browser:
|
||||
# - "--user-data-dir=/path/to/profile"
|
||||
arguments: []
|
||||
|
||||
# add Chromium's --test-type switch to suppress unsupported command-line flag warnings. Set to false in ptrace-restricted containers where this switch can prevent the browser from starting
|
||||
suppress_unsupported_flag_warning: true
|
||||
|
||||
# path to custom browser executable (optional). Leave empty to use system default
|
||||
binary_location: ''
|
||||
|
||||
|
||||
@@ -243,6 +243,12 @@
|
||||
"title": "Arguments",
|
||||
"type": "array"
|
||||
},
|
||||
"suppress_unsupported_flag_warning": {
|
||||
"default": true,
|
||||
"description": "add Chromium's --test-type switch to suppress unsupported command-line flag warnings. Set to false in ptrace-restricted containers where this switch can prevent the browser from starting",
|
||||
"title": "Suppress Unsupported Flag Warning",
|
||||
"type": "boolean"
|
||||
},
|
||||
"binary_location": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -223,6 +223,13 @@ class BrowserConfig(ContextualModel):
|
||||
),
|
||||
examples = ['"--headless"', '"--disable-dev-shm-usage"', '"--user-data-dir=/path/to/profile"'],
|
||||
)
|
||||
suppress_unsupported_flag_warning:bool = Field(
|
||||
default = True,
|
||||
description=(
|
||||
"add Chromium's --test-type switch to suppress unsupported command-line flag warnings. "
|
||||
"Set to false in ptrace-restricted containers where this switch can prevent the browser from starting"
|
||||
),
|
||||
)
|
||||
binary_location:str | None = Field(default = "", description = "path to custom browser executable (optional). Leave empty to use system default")
|
||||
extensions:list[str] = Field(
|
||||
default_factory = list,
|
||||
|
||||
@@ -849,6 +849,8 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
|
||||
"4. Check if any antivirus or security software is blocking the connection": "4. Überprüfen Sie, ob Antiviren- oder Sicherheitssoftware die Verbindung blockiert"
|
||||
|
||||
_build_new_browser_launch_args:
|
||||
? "Container without CAP_SYS_PTRACE detected. If the browser fails to start, set browser.suppress_unsupported_flag_warning: false to omit --test-type."
|
||||
: "Container ohne CAP_SYS_PTRACE erkannt. Falls der Browser nicht startet, setzen Sie browser.suppress_unsupported_flag_warning: false, um --test-type wegzulassen."
|
||||
" -> Browser profile name: %s": " -> Browser-Profilname: %s"
|
||||
" -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s"
|
||||
"Ignoring empty --user-data-dir= argument; falling back to configured user_data_dir.": "Ignoriere leeres --user-data-dir= Argument; verwende konfiguriertes user_data_dir."
|
||||
|
||||
@@ -148,6 +148,7 @@ def load_config(config_file_path:str, workspace:_xdg_paths.Workspace | None, com
|
||||
|
||||
def apply_browser_config(browser_config:Any, config:Config, workspace:_xdg_paths.Workspace | None, config_file_path:str) -> None:
|
||||
browser_config.arguments = config.browser.arguments
|
||||
browser_config.suppress_unsupported_flag_warning = config.browser.suppress_unsupported_flag_warning
|
||||
browser_config.binary_location = config.browser.binary_location
|
||||
browser_config.extensions = [abspath(item, relative_to = config_file_path) for item in config.browser.extensions]
|
||||
browser_config.use_private_window = config.browser.use_private_window
|
||||
|
||||
@@ -28,6 +28,8 @@ from .chrome_version_detector import (
|
||||
from .net import is_port_open
|
||||
|
||||
LOG:Final[loggers.Logger] = loggers.get_logger(__name__)
|
||||
_CAP_SYS_PTRACE:Final[int] = 19
|
||||
_CONTAINER_CGROUP_MARKERS:Final[tuple[str, ...]] = ("docker", "containerd", "kubepods", "libpod", "lxc")
|
||||
|
||||
|
||||
def _format_url_host(host:str) -> str:
|
||||
@@ -58,6 +60,36 @@ def _is_admin() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_linux_container() -> bool:
|
||||
"""Return whether common Linux container markers are present."""
|
||||
if os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv") or any(key.lower() == "container" for key in os.environ):
|
||||
return True
|
||||
try:
|
||||
with open("/proc/1/cgroup", encoding = "UTF-8") as fd: # noqa: PTH123
|
||||
return any(marker in fd.read().lower() for marker in _CONTAINER_CGROUP_MARKERS)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _has_linux_capability(capability:int) -> bool:
|
||||
"""Return whether the current Linux process has *capability*."""
|
||||
try:
|
||||
with open("/proc/self/status", encoding = "UTF-8") as fd: # noqa: PTH123
|
||||
for line in fd:
|
||||
if line.startswith("CapEff:"):
|
||||
effective_capabilities = int(line.split(maxsplit = 1)[1], 16)
|
||||
return bool(effective_capabilities & (1 << capability))
|
||||
except (OSError, ValueError, IndexError):
|
||||
# procfs can be unavailable in restricted containers; treat the capability as absent.
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _is_linux_container_without_sys_ptrace() -> bool:
|
||||
"""Return whether a recognized Linux container lacks CAP_SYS_PTRACE."""
|
||||
return platform.system() == "Linux" and _is_linux_container() and not _has_linux_capability(_CAP_SYS_PTRACE)
|
||||
|
||||
|
||||
def _remote_debugging_api_browser(remote_host:str, remote_port:int, probe_timeout:float) -> tuple[str | None, Exception | None]:
|
||||
"""Probe the remote debugging API.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class BrowserConfig:
|
||||
|
||||
Attributes:
|
||||
arguments: Additional browser command-line arguments.
|
||||
suppress_unsupported_flag_warning: Whether to add --test-type to suppress browser warnings.
|
||||
binary_location: Path to the browser executable, or None for auto-detection.
|
||||
extensions: List of extension paths to load.
|
||||
use_private_window: Whether to start in incognito/private mode.
|
||||
@@ -23,6 +24,7 @@ class BrowserConfig:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.arguments:list[str] = []
|
||||
self.suppress_unsupported_flag_warning:bool = True
|
||||
self.binary_location:str | None = None
|
||||
self.extensions:list[str] = []
|
||||
self.use_private_window:bool = True
|
||||
|
||||
@@ -22,7 +22,7 @@ from kleinanzeigen_bot.model.config_model import Config as BotConfig
|
||||
from kleinanzeigen_bot.model.config_model import HumanizationConfig, TimeoutConfig
|
||||
|
||||
from . import files, loggers, net, xdg_paths
|
||||
from .browser_diagnostics import _run_browser_diagnostics
|
||||
from .browser_diagnostics import _is_linux_container_without_sys_ptrace, _run_browser_diagnostics
|
||||
from .browser_runtime_config import BrowserConfig
|
||||
from .chrome_version_detector import (
|
||||
ChromeVersionInfo,
|
||||
@@ -788,11 +788,18 @@ class WebScrapingMixin: # noqa: PLR0904
|
||||
"--disable-search-engine-choice-screen",
|
||||
"--disable-features=MediaRouter",
|
||||
"--use-mock-keychain",
|
||||
"--test-type", # https://stackoverflow.com/a/36746675/5116073
|
||||
# https://chromium.googlesource.com/chromium/src/+/master/net/dns/README.md#request-remapping
|
||||
'--host-resolver-rules="MAP connect.facebook.net 127.0.0.1, MAP securepubads.g.doubleclick.net 127.0.0.1, MAP www.googletagmanager.com 127.0.0.1"',
|
||||
]
|
||||
|
||||
if self.browser_config.suppress_unsupported_flag_warning:
|
||||
browser_args.append("--test-type") # https://stackoverflow.com/a/36746675/5116073
|
||||
if _is_linux_container_without_sys_ptrace():
|
||||
LOG.warning(
|
||||
"Container without CAP_SYS_PTRACE detected. If the browser fails to start, "
|
||||
"set browser.suppress_unsupported_flag_warning: false to omit --test-type."
|
||||
)
|
||||
|
||||
is_edge = "edge" in (self.browser_config.binary_location or "").lower()
|
||||
|
||||
if is_edge:
|
||||
|
||||
@@ -57,6 +57,15 @@ def test_minimal_config_validation() -> None:
|
||||
config = Config.model_validate(minimal_cfg)
|
||||
assert config.login.username == "dummy"
|
||||
assert config.login.password == "dummy" # noqa: S105
|
||||
assert config.browser.suppress_unsupported_flag_warning is True
|
||||
|
||||
|
||||
def test_browser_config_allows_unsupported_flag_warning_to_be_shown() -> None:
|
||||
config = Config.model_validate({
|
||||
"login": {"username": "dummy", "password": "dummy"}, # noqa: S106
|
||||
"browser": {"suppress_unsupported_flag_warning": False},
|
||||
})
|
||||
assert config.browser.suppress_unsupported_flag_warning is False
|
||||
|
||||
|
||||
def test_publishing_local_path_renaming_defaults_to_off() -> None:
|
||||
|
||||
@@ -220,6 +220,7 @@ publishing:
|
||||
{
|
||||
"login": {"username": "user", "password": "pass"},
|
||||
"ad_defaults": {"contact": {"name": "Test User", "zipcode": "12345"}},
|
||||
"browser": {"suppress_unsupported_flag_warning": False},
|
||||
"publishing": {"delete_old_ads": "BEFORE_PUBLISH", "delete_old_ads_by_title": False},
|
||||
}
|
||||
)
|
||||
@@ -228,6 +229,7 @@ publishing:
|
||||
|
||||
assert browser_config.user_data_dir == str(workspace.browser_profile_dir)
|
||||
assert browser_config.profile_name == config.browser.profile_name
|
||||
assert browser_config.suppress_unsupported_flag_warning is False
|
||||
|
||||
def test_apply_browser_config_uses_custom_profile_dir(self, tmp_path:Path) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
|
||||
@@ -24,8 +24,8 @@ from nodriver.core.element import Element
|
||||
from nodriver.core.tab import Tab as Page
|
||||
|
||||
from kleinanzeigen_bot.model.config_model import Config
|
||||
from kleinanzeigen_bot.utils import files, loggers
|
||||
from kleinanzeigen_bot.utils.browser_diagnostics import _format_url_host, _is_admin # noqa: PLC2701
|
||||
from kleinanzeigen_bot.utils import browser_diagnostics, files, loggers
|
||||
from kleinanzeigen_bot.utils.browser_diagnostics import _format_url_host, _is_admin, _is_linux_container_without_sys_ptrace # noqa: PLC2701
|
||||
from kleinanzeigen_bot.utils.web_scraping_mixin import By, Is, WebScrapingMixin, _allocate_selector_group_budgets # noqa: PLC2701
|
||||
|
||||
|
||||
@@ -1443,6 +1443,52 @@ class TestWebScrolling:
|
||||
class TestWebScrapingBrowserConfiguration:
|
||||
"""Test browser configuration in WebScrapingMixin."""
|
||||
|
||||
def test_browser_args_suppress_unsupported_flag_warning_by_default(self) -> None:
|
||||
scraper = WebScrapingMixin()
|
||||
|
||||
args, _ = scraper._build_new_browser_launch_args()
|
||||
|
||||
assert "--test-type" in args
|
||||
|
||||
def test_browser_args_can_show_unsupported_flag_warning(self) -> None:
|
||||
scraper = WebScrapingMixin()
|
||||
scraper.browser_config.arguments = ["--custom-arg=value"]
|
||||
scraper.browser_config.suppress_unsupported_flag_warning = False
|
||||
|
||||
args, _ = scraper._build_new_browser_launch_args()
|
||||
|
||||
assert "--test-type" not in args
|
||||
assert "--custom-arg=value" in args
|
||||
assert "--incognito" in args
|
||||
|
||||
def test_linux_container_detection_uses_cgroup_marker(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(os.path, "exists", lambda _: False)
|
||||
with patch("builtins.open", mock_open(read_data = "0::/docker/test-container\n")):
|
||||
assert browser_diagnostics._is_linux_container() is True # noqa: SLF001
|
||||
|
||||
def test_linux_capability_detection_handles_procfs_errors(self) -> None:
|
||||
with patch("builtins.open", side_effect = OSError("procfs unavailable")):
|
||||
assert browser_diagnostics._has_linux_capability(19) is False # noqa: SLF001
|
||||
|
||||
def test_linux_capability_detection_reads_effective_capabilities(self) -> None:
|
||||
with patch("builtins.open", mock_open(read_data = "CapEff:\t0000000000080000\n")):
|
||||
assert browser_diagnostics._has_linux_capability(19) is True # noqa: SLF001
|
||||
|
||||
@pytest.mark.skipif(platform.system() != "Linux", reason = "Linux-specific container capability test")
|
||||
def test_linux_container_without_sys_ptrace_is_detected(self, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(browser_diagnostics, "_is_linux_container", lambda: True)
|
||||
monkeypatch.setattr(browser_diagnostics, "_has_linux_capability", lambda capability: capability != 19)
|
||||
|
||||
assert _is_linux_container_without_sys_ptrace() is True
|
||||
|
||||
def test_ptrace_restricted_container_warns_about_test_type(self, monkeypatch:pytest.MonkeyPatch, caplog:pytest.LogCaptureFixture) -> None:
|
||||
scraper = WebScrapingMixin()
|
||||
monkeypatch.setattr("kleinanzeigen_bot.utils.web_scraping_mixin._is_linux_container_without_sys_ptrace", lambda: True)
|
||||
|
||||
scraper._build_new_browser_launch_args()
|
||||
|
||||
assert "Container without CAP_SYS_PTRACE detected" in caplog.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_binary_location_detection(self, tmp_path:Path, monkeypatch:pytest.MonkeyPatch) -> None:
|
||||
"""Test browser binary location detection on different platforms."""
|
||||
|
||||
Reference in New Issue
Block a user