mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:28:27 +00:00
ffmpeg is in the default image for one thing, the Debug mode screen recording (`_start_ffmpeg_recording`). Nothing else calls it, SeleniumBase included, and it brings 93 packages with it. On real builds that's 406 Trivy findings (149 High) and 78 MB of the gzipped amd64 image. This takes it out of the image and installs it when Debug mode needs it. It came out of the base image discussion on #1616 and helps on any base, though on Wolfi the install line would need `apk`. ## What changed - **Dockerfile:** ffmpeg is gone from the full image's install. - **Guard:** `_start_ffmpeg_recording` checks `shutil.which("ffmpeg")` first. If it's missing it logs a warning and returns, and the bypass carries on. Without this, Debug on with no ffmpeg fails every bypass with `FileNotFoundError`. There's a new test for it. - **Entrypoint:** when Debug is on, the internal bypasser is in use and ffmpeg isn't already there, it installs ffmpeg in the background so the app doesn't wait for it. The download and the install each run under a 120 s `timeout` with `DEBIAN_FRONTEND=noninteractive`. Downloading first means a hung mirror can only be killed before dpkg starts, and the next start runs `dpkg --configure -a` in case a restart cut dpkg off. It logs when it starts, succeeds, times out or fails. - **Docs:** a short paragraph in `docs/troubleshooting.md` covering the install and the non-root limit. ## Debug has two sources Debug can come from the `DEBUG` env var or from the Debug Mode setting in the UI, which is saved to `/config/plugins/advanced.json`. The existing block in `entrypoint.sh` only reads the env var, so an install keyed on `$DEBUG` would miss anyone who turned it on in the UI. The new check asks the app instead (`shelfmark.config.env.DEBUG`), which reads both. The UI setting is `requires_restart`, so the restart it already asks for is what brings it through the entrypoint. ## Non-root mode A container started as `1000:1000` can't run `apt-get`, so the entrypoint logs that it can't install ffmpeg, the guard warns on each bypass, and there's no recording. Everything else works. The workaround for a debugging session is to start as root with `PUID`/`PGID`. ## Startup cost None that I could measure. With Debug on, `/api/health` answers in the same time as main (3-4 s with Debug only in config, 9-10 s from env), and ffmpeg was ready 6-8 s later on the runners. The trade-off is that the first bypass after startup may be unrecorded while the install runs. It logs the warning and passes. I saw both outcomes on a bypass started right after health. From reading the code: the install lives in the container, so recreating the container installs it again. It also runs after `tor.sh` and `wireguard.sh`, so apt goes through whichever egress is active. ## Testing Real Docker builds on GitHub Actions on my fork, amd64 and arm64, with the same result on both for every case below. | Case | Result | |---|---| | Debug off | No install, `/api/health` in 3-4 s, same as main | | Debug only in config | Health in 3-4 s, ffmpeg ready 6-8 s later, real mp4 after a bypass | | Debug from env | Health in 9-10 s (same as main), real mp4 after a bypass | | Started as root, app dropped to uid 1000 | Smoke and bypass pass | | Started with `--user 1000`, Debug on | Logs both messages, bypass passes, no mp4 | | apt blocked (closed proxy) | Logs `Could not install ffmpeg`, bypass passes, no mp4 | | apt's proxy on a dropped address, so no TCP reply at all | Health in 9-10 s, a bypass during the install passes with the warning and no mp4. apt gives up by itself in under a minute. Nothing left running, `dpkg --audit` clean | | DNS server on a dropped address | Health in 9-10 s, `Timed out installing ffmpeg after 120s`. Nothing left running, `dpkg --audit` clean | | `--network none`, Debug on | No hang, health 200 from inside the container | | Container restarted while dpkg was running | Next start finishes the install, `dpkg --audit` clean, real mp4 | | Fresh named volume, Debug only in config | Recording made, uid 1000 can write | | Existing uid-1000 volume, Debug only in config | Same, nothing root-owned afterwards | | `find /config /var/log/shelfmark -user root` | 0 files, same as main | | `find /app -user root` | 245, same count as main | | `tests/e2e/platform/run-e2e.sh` with `dns-blocked` and `baseline` | Pass | The bypass checks ran against example.com. A real Cloudflare challenge was served by a demo page in the same runs, and no build solved it, including main, so that part doesn't say anything about this change. Locally: 5494 Python tests pass, and `ruff`, `basedpyright`, `vulture` and `shellcheck` are clean. The full results are in [the base image report](https://github.com/splitsec2/shelfmark/blob/7afcecfb76727724a71561cbc078f091569b58dc/docs/research/2026-11-01-base-image-report.md).
162 lines
5.4 KiB
Python
162 lines
5.4 KiB
Python
"""A recording that never happened must say why.
|
|
|
|
Issue #1276: the debug bundle's recording/ directory was empty, and the only trace was
|
|
three "FFmpeg already stopped" debug lines - one per bypass, each logged 20-56s after
|
|
the recorder was started, meaning ffmpeg had exited almost immediately every time. It ran
|
|
with `-loglevel 0` and no stderr capture, so nothing anywhere recorded the reason. The
|
|
screen recording is the single most useful artifact for diagnosing a bypass failure.
|
|
"""
|
|
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
import shelfmark.bypass.internal_bypasser as ib
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_display():
|
|
before = dict(ib.DISPLAY)
|
|
ib.DISPLAY["ffmpeg"] = None
|
|
ib.DISPLAY["ffmpeg_output"] = None
|
|
ib.DISPLAY["ffmpeg_error_log"] = None
|
|
yield
|
|
ib.DISPLAY.update(before)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ffmpeg_on_path(monkeypatch):
|
|
# The recorder checks for the binary first; these tests stub the process itself.
|
|
monkeypatch.setattr(ib.shutil, "which", lambda _name: "/usr/bin/ffmpeg")
|
|
|
|
|
|
class _Proc:
|
|
def __init__(self, returncode):
|
|
self.returncode = returncode
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
|
|
def test_ffmpeg_errors_are_captured_to_a_file_beside_the_recording(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(ib, "RECORDING_DIR", tmp_path)
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_popen(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["stderr"] = kwargs.get("stderr")
|
|
return _Proc(None)
|
|
|
|
monkeypatch.setattr(ib.subprocess, "Popen", fake_popen)
|
|
|
|
ib._start_ffmpeg_recording(display=":99")
|
|
|
|
cmd = captured["cmd"]
|
|
# Errors must not be thrown away any more.
|
|
assert "-loglevel" in cmd
|
|
assert cmd[cmd.index("-loglevel") + 1] == "error"
|
|
# stderr goes to a real file, not a pipe nothing would drain.
|
|
assert captured["stderr"] is not None
|
|
assert captured["stderr"] is not subprocess.PIPE
|
|
|
|
error_log = ib.DISPLAY["ffmpeg_error_log"]
|
|
assert error_log is not None
|
|
assert error_log.parent == tmp_path
|
|
# It sits beside the mp4, so it travels in the debug bundle.
|
|
assert error_log.name.startswith("screen_recording_")
|
|
|
|
|
|
def test_capture_is_not_pinned_to_the_fingerprint_size(monkeypatch, tmp_path):
|
|
"""Issue #1364: every recording died on "Capture area ... outside the screen size".
|
|
|
|
The capture size was the fingerprint size plus margin, but the Xvfb is not built at
|
|
that size (SeleniumBase falls back to a fixed 1440x1880 screen when xauth is missing),
|
|
so any fingerprint wider than 1340px asked for more than the screen had. Left unset,
|
|
x11grab records the whole screen, whatever it turned out to be.
|
|
"""
|
|
monkeypatch.setattr(ib, "RECORDING_DIR", tmp_path)
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_popen(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
return _Proc(None)
|
|
|
|
monkeypatch.setattr(ib.subprocess, "Popen", fake_popen)
|
|
|
|
ib._start_ffmpeg_recording(display=":99")
|
|
|
|
cmd = captured["cmd"]
|
|
assert "-video_size" not in cmd
|
|
assert cmd[cmd.index("-f") + 1] == "x11grab"
|
|
assert cmd[cmd.index("-i") + 1] == ":99"
|
|
|
|
|
|
def test_an_early_exit_is_reported_with_ffmpegs_own_reason(monkeypatch, tmp_path, caplog):
|
|
reason = "[x11grab @ 0x1] Cannot open display :99, error 1."
|
|
error_log = tmp_path / "screen_recording_x.ffmpeg.log"
|
|
error_log.write_text(reason, encoding="utf-8")
|
|
|
|
ib.DISPLAY["ffmpeg"] = _Proc(1)
|
|
ib.DISPLAY["ffmpeg_output"] = tmp_path / "screen_recording_x.mp4"
|
|
ib.DISPLAY["ffmpeg_error_log"] = error_log
|
|
|
|
messages: list[str] = []
|
|
|
|
class _Capture:
|
|
def emit(self, record):
|
|
messages.append(record.getMessage())
|
|
|
|
import logging
|
|
|
|
handler = logging.Handler()
|
|
handler.emit = _Capture().emit # type: ignore[method-assign]
|
|
ib.logger.addHandler(handler)
|
|
previous = ib.logger.level
|
|
ib.logger.setLevel(logging.DEBUG)
|
|
ib.logger._cache.clear()
|
|
try:
|
|
ib._stop_ffmpeg_recording()
|
|
finally:
|
|
ib.logger.removeHandler(handler)
|
|
ib.logger.setLevel(previous)
|
|
|
|
line = next((m for m in messages if "exited early" in m), None)
|
|
assert line is not None, messages
|
|
assert "code 1" in line
|
|
assert "Cannot open display" in line
|
|
assert ib.DISPLAY["ffmpeg"] is None
|
|
|
|
|
|
def test_summary_is_explicit_when_ffmpeg_logged_nothing(tmp_path):
|
|
empty = tmp_path / "screen_recording_y.ffmpeg.log"
|
|
empty.write_text("", encoding="utf-8")
|
|
ib.DISPLAY["ffmpeg_error_log"] = empty
|
|
|
|
assert "logged nothing" in ib._ffmpeg_error_summary()
|
|
|
|
|
|
def test_summary_survives_a_missing_log():
|
|
ib.DISPLAY["ffmpeg_error_log"] = None
|
|
|
|
assert "No FFmpeg error log" in ib._ffmpeg_error_summary()
|
|
|
|
|
|
def test_debug_mode_without_ffmpeg_skips_the_recording_and_says_so(monkeypatch, tmp_path):
|
|
"""Debug mode can be on with no ffmpeg in the image; that must not fail the bypass."""
|
|
monkeypatch.setattr(ib, "RECORDING_DIR", tmp_path / "recording")
|
|
monkeypatch.setattr(ib.shutil, "which", lambda _name: None)
|
|
|
|
def popen_must_not_run(*_args, **_kwargs):
|
|
raise AssertionError("ffmpeg should not be started when it is not installed")
|
|
|
|
monkeypatch.setattr(ib.subprocess, "Popen", popen_must_not_run)
|
|
|
|
warnings: list[str] = []
|
|
monkeypatch.setattr(ib.logger, "warning", lambda msg, *_args: warnings.append(msg))
|
|
|
|
ib._start_ffmpeg_recording(display=":99")
|
|
|
|
assert ib.DISPLAY["ffmpeg"] is None
|
|
assert any("ffmpeg is not installed" in message for message in warnings)
|
|
assert not (tmp_path / "recording").exists()
|