Skip to content

Commit f5f26bd

Browse files
committed
updater: security hardening, apt rollback, cancel button, elapsed timer, error toasts, +15 tests
1 parent 0385002 commit f5f26bd

11 files changed

Lines changed: 455 additions & 38 deletions

File tree

BlocksScreen/lib/panels/mainWindow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,17 @@ def __init__(self):
236236
self.ws.connected_signal.connect(self._on_moonraker_connected_post_update)
237237
self.update_page.request_update.connect(self.updater_worker.trigger_update)
238238
self.update_page.request_status.connect(self.updater_worker.trigger_status)
239+
self.update_page.request_cancel.connect(self.updater_worker.trigger_cancel)
239240
self.update_page.update_available.connect(self.on_update_available)
240241
self.update_page.call_load_panel.connect(self.show_LoadScreen)
241242
self.update_page.disable_popups.connect(self.popup_toggle)
242243
self.update_page.update_back_btn.clicked.connect(self.update_page.hide)
243244
self.updater_worker.step_complete.connect(self.update_page.handle_step_complete)
245+
self.updater_worker.error_occurred.connect(
246+
self.update_page.handle_error_occurred
247+
)
248+
self.updater_worker.rollback_done.connect(self.update_page.handle_rollback_done)
249+
self.updater_worker.recover_done.connect(self.update_page.handle_recover_done)
244250
self.ws.klippy_state_signal.connect(self._on_klippy_state)
245251
self.utilitiesPanel.show_update_page.connect(self.show_update_page)
246252
self.conn_window.update_button_clicked.connect(self.show_update_page)

BlocksScreen/lib/panels/widgets/updatePage.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class UpdatePage(QtWidgets.QWidget):
2525
request_status: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
2626
name="request-status"
2727
)
28+
request_cancel: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
29+
name="request-cancel"
30+
)
2831
update_available: typing.ClassVar[QtCore.pyqtSignal] = QtCore.pyqtSignal(
2932
bool, name="update-available"
3033
)
@@ -63,17 +66,44 @@ def __init__(self) -> None:
6366
self._busy: bool = False
6467
self._update_avail: bool = False
6568
self._post_update_status_pending: bool = False
69+
self._elapsed_time_seconds: int = 0
70+
self._elapsed_timer: QtCore.QTimer = QtCore.QTimer(self)
71+
self._elapsed_timer.setSingleShot(False)
72+
self._elapsed_timer.setInterval(1000)
73+
self._elapsed_timer.timeout.connect(self._update_elapsed_time)
6674
self.show_loading(True)
6775

6876
def _request_status_debounced(self) -> None:
6977
self._status_debounce.start(500)
7078

79+
def _update_elapsed_time(self) -> None:
80+
"""Update and display elapsed time counter."""
81+
self._elapsed_time_seconds += 1
82+
minutes = self._elapsed_time_seconds // 60
83+
seconds = self._elapsed_time_seconds % 60
84+
self._elapsed_time_label.setText(f"{minutes:02d}:{seconds:02d}")
85+
7186
def showEvent(self, a0: QtGui.QShowEvent | None) -> None:
7287
self.build_cards()
7388
self._post_update_status_pending = True
7489
self._request_status_debounced()
7590
return super().showEvent(a0)
7691

92+
def resizeEvent(self, a0: QtGui.QResizeEvent | None) -> None:
93+
"""Position elapsed time label and cancel button within the load widget area."""
94+
if self._loadwidget.isVisible():
95+
load_widget_height = self._loadwidget.height()
96+
load_widget_width = self._loadwidget.width()
97+
# Position elapsed time label below the spinner (around 70% down)
98+
elapsed_y = int(load_widget_height * 0.70)
99+
elapsed_x = (load_widget_width - 100) // 2
100+
self._elapsed_time_label.setGeometry(elapsed_x, elapsed_y, 100, 40)
101+
# Position cancel button below the elapsed time label
102+
cancel_y = int(load_widget_height * 0.80)
103+
cancel_x = (load_widget_width - 240) // 2
104+
self._cancel_btn.setGeometry(cancel_x, cancel_y, 240, 50)
105+
return super().resizeEvent(a0)
106+
77107
def _needs_update(self, status: ComponentStatus) -> bool:
78108
return bool(
79109
status.commits_behind
@@ -253,7 +283,15 @@ def handle_busy_changed(self, busy: bool) -> None:
253283
_log.info("handle_busy_changed: %s", busy)
254284
self._busy = busy
255285
self.show_loading(busy)
256-
if not busy:
286+
if busy:
287+
self._elapsed_time_seconds = 0
288+
self._elapsed_timer.start()
289+
self._elapsed_time_label.show()
290+
self._cancel_btn.show()
291+
else:
292+
self._elapsed_timer.stop()
293+
self._elapsed_time_label.hide()
294+
self._cancel_btn.hide()
257295
self._post_update_status_pending = True
258296
self._request_status_debounced()
259297
# Don't dismiss the overlay yet — wait for status_ready to refresh
@@ -263,6 +301,12 @@ def handle_busy_changed(self, busy: bool) -> None:
263301
def on_update_all_clicked(self) -> None:
264302
self._do_update()
265303

304+
@QtCore.pyqtSlot(name="on-cancel-clicked")
305+
def _on_cancel_clicked(self) -> None:
306+
"""Emit cancel signal when user clicks cancel button."""
307+
_log.info("Cancel button clicked")
308+
self.request_cancel.emit()
309+
266310
@QtCore.pyqtSlot(name="do-update")
267311
def _do_update(self) -> None:
268312
self.request_update.emit("")
@@ -280,6 +324,30 @@ def handle_step_complete(self, name: str, step: int, total: int) -> None:
280324
_log.info("step_complete: %s %d/%d (%s)", name, step, total, label)
281325
self.call_load_panel.emit(True, f"{name}: {label} ({step}/{total})")
282326

327+
def _show_toast(self, message: str, *, success: bool = False) -> None:
328+
color = "#4caf50" if success else "#ef5350"
329+
self._toast.setStyleSheet(
330+
f"background: {color}; color: #fff; padding: 4px 12px; border-radius: 8px;"
331+
)
332+
self._toast.setText(message)
333+
self._toast.show()
334+
self._toast.raise_()
335+
self._toast_timer.start()
336+
337+
def handle_error_occurred(self, name: str, reason: str) -> None:
338+
self._show_toast(f"{name}: {reason}")
339+
340+
def handle_rollback_done(self, name: str, success: bool) -> None:
341+
self._show_toast(
342+
f"{name}: {'rolled back' if success else 'rollback failed'}",
343+
success=success,
344+
)
345+
346+
def handle_recover_done(self, name: str, success: bool) -> None:
347+
self._show_toast(
348+
f"{name}: recovery {'complete' if success else 'failed'}", success=success
349+
)
350+
283351
def show_loading(self, loading: bool = False) -> None:
284352
self.setUpdatesEnabled(False)
285353
self._loadwidget.setVisible(loading)
@@ -373,12 +441,31 @@ def _setupUI(self) -> None:
373441
self._scroll_area.setWidget(self._scroll_content)
374442
content.addWidget(self._scroll_area, 1)
375443

376-
# Loading overlay
444+
# Loading overlay with elapsed time and cancel button
377445
self._loadwidget = LoadingOverlayWidget(
378446
self, LoadingOverlayWidget.AnimationGIF.DEFAULT
379447
)
380448
content.addWidget(self._loadwidget, 1)
381449

450+
# Elapsed time label (positioned in the load widget area via custom overlay)
451+
self._elapsed_time_label = QtWidgets.QLabel("00:00", self._loadwidget)
452+
self._elapsed_time_label.setStyleSheet(
453+
"color: rgba(255, 255, 255, 200); background: transparent;"
454+
)
455+
self._elapsed_time_label.setFont(QtGui.QFont(self._font_family, 16))
456+
self._elapsed_time_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
457+
self._elapsed_time_label.setFixedWidth(100)
458+
self._elapsed_time_label.hide()
459+
460+
# Cancel button (positioned in the load widget area)
461+
self._cancel_btn = BlocksCustomButton(self._loadwidget)
462+
self._cancel_btn.setMinimumSize(QtCore.QSize(180, 50))
463+
self._cancel_btn.setMaximumSize(QtCore.QSize(240, 50))
464+
self._cancel_btn.setFont(QtGui.QFont(self._font_family, 18))
465+
self._cancel_btn.setText("Cancel")
466+
self._cancel_btn.clicked.connect(self._on_cancel_clicked)
467+
self._cancel_btn.hide()
468+
382469
# Update All button
383470
self.update_all_btn = BlocksCustomButton(self)
384471
self.update_all_btn.setMinimumSize(QtCore.QSize(240, 70))
@@ -391,6 +478,18 @@ def _setupUI(self) -> None:
391478
content.addWidget(self.update_all_btn, 0, QtCore.Qt.AlignmentFlag.AlignCenter)
392479
self.update_all_btn.hide()
393480

481+
self._toast = QtWidgets.QLabel(self)
482+
self._toast.setWordWrap(True)
483+
self._toast.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
484+
self._toast.setFont(QtGui.QFont(self._font_family, 13))
485+
self._toast.setFixedHeight(46)
486+
self._toast.hide()
487+
content.addWidget(self._toast, 0)
488+
self._toast_timer = QtCore.QTimer(self)
489+
self._toast_timer.setSingleShot(True)
490+
self._toast_timer.setInterval(8000)
491+
self._toast_timer.timeout.connect(self._toast.hide)
492+
394493
_arrow = QtGui.QPixmap(":/arrow_icons/media/btn_icons/arrow_right.svg")
395494
self._chevron_right: QtGui.QPixmap = _arrow
396495
self._chevron_down: QtGui.QPixmap = _arrow.transformed(

BlocksScreen/lib/updater_worker.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,12 @@ async def _async_initialize(self) -> None:
105105
self._listener_tasks.append(task)
106106
task.add_done_callback(self._on_listener_done)
107107

108-
# Yield so listener tasks enter their async-for loops (subscribe to signals)
109-
# before we poll current state, avoiding a missed busy_changed(False) on reconnect.
110-
await asyncio.sleep(0)
108+
# Give each listener task a scheduling slot to enter its async-for loop
109+
# (and register its D-Bus signal subscription) before we poll current state.
110+
# One sleep(0) per task is sufficient: asyncio runs all ready callbacks
111+
# before yielding back to us.
112+
for _ in listeners:
113+
await asyncio.sleep(0)
111114

112115
try:
113116
busy = await self._proxy.get_busy()
@@ -305,12 +308,15 @@ async def _listen_busy_changed(self) -> None:
305308
self.busy_changed.emit(busy)
306309

307310
async def _busy_watchdog(self) -> None:
308-
"""Emit daemon_unavailable if BusyChanged(False) does not arrive within 60 seconds.""" # noqa: E501
311+
"""Emit daemon_unavailable if BusyChanged(False) does not arrive within 360 seconds.
312+
313+
360s = 300s apt-get upgrade hard deadline + 30s systemctl + 30s buffer.
314+
"""
309315
if self._busy_false_event is None:
310316
_msg = "_busy_false_event not initialized"
311317
raise RuntimeError(_msg)
312318
try:
313-
async with asyncio.timeout(60.0):
319+
async with asyncio.timeout(360.0):
314320
await self._busy_false_event.wait()
315321
except TimeoutError:
316322
_log.error("busy watchdog timed out, daemon is unavailable")

scripts/BlocksScreen-updater.service

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,21 @@ ExecStart=BSENV/bin/python3.11 -m updater daemon
1313
Restart=always
1414
RestartSec=5
1515

16+
# Security hardening
17+
NoNewPrivileges=yes
18+
PrivateTmp=yes
19+
ProtectKernelTunables=yes
20+
ProtectKernelModules=yes
21+
ProtectControlGroups=yes
22+
RestrictRealtime=yes
23+
LockPersonality=yes
24+
MemoryDenyWriteExecute=yes
25+
RestrictNamespaces=yes
26+
RemoveIPC=yes
27+
ProtectHostname=yes
28+
RestrictSUIDSGID=yes
29+
SystemCallFilter=@system-service
30+
SystemCallErrorNumber=EPERM
31+
1632
[Install]
1733
WantedBy=multi-user.target

scripts/install-updater.sh

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,19 @@ echo_info "Installing sudoers rules for updater ..."
4040
SUDOERS_FILE="/etc/sudoers.d/blockscreen-updater"
4141
SUDOERS_TMP=$(mktemp)
4242
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/apt-get update\n' >>"$SUDOERS_TMP"
43-
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/apt-get upgrade -y\n' >>"$SUDOERS_TMP"
44-
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart *\n' >>"$SUDOERS_TMP"
43+
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/apt-get install --only-upgrade -y *\n' >>"$SUDOERS_TMP"
4544
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl reboot\n' >>"$SUDOERS_TMP"
45+
# Derive allowed restart targets from components.yaml instead of wildcard
46+
_COMP_YAML="$BS_PATH/updater/components.yaml"
47+
if [[ -f "$_COMP_YAML" ]]; then
48+
while IFS= read -r _svc; do
49+
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart %s\n' "$_svc" >>"$SUDOERS_TMP"
50+
done < <(grep '^\s*service:' "$_COMP_YAML" | awk '{print $2}' | sort -u)
51+
else
52+
for _svc in klipper.service moonraker.service crowsnest.service KlipperScreen.service BlocksScreen.service; do
53+
printf 'blocks ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart %s\n' "$_svc" >>"$SUDOERS_TMP"
54+
done
55+
fi
4656
if sudo visudo -cf "$SUDOERS_TMP" >/dev/null 2>&1; then
4757
sudo install -m 0440 "$SUDOERS_TMP" "$SUDOERS_FILE"
4858
echo_ok "Sudoers rules installed"

tests/updater/test_components_unit.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44
import logging
55
from pathlib import Path
6-
from unittest.mock import patch, mock_open
6+
from unittest.mock import patch, mock_open, MagicMock
77

88
from updater.components import load_components
99

@@ -91,10 +91,17 @@ def test_override_permission_error_skipped(self, caplog):
9191

9292

9393
class TestYamlMerge:
94+
def _safe_stat_mock(self):
95+
"""Return a mock stat result for safe (owner-only) file permissions."""
96+
mock_stat = MagicMock()
97+
mock_stat.st_mode = 0o100600 # Regular file, owner-only
98+
return mock_stat
99+
94100
def test_overrides_path_by_name(self):
95101
with (
96102
patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)),
97103
patch("pathlib.Path.exists", return_value=True),
104+
patch("pathlib.Path.stat", return_value=self._safe_stat_mock()),
98105
patch("pathlib.Path.is_dir", return_value=True),
99106
):
100107
components, poll = load_components()
@@ -106,6 +113,7 @@ def test_appends_new_component(self):
106113
with (
107114
patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)),
108115
patch("pathlib.Path.exists", return_value=True),
116+
patch("pathlib.Path.stat", return_value=self._safe_stat_mock()),
109117
patch("pathlib.Path.is_dir", return_value=True),
110118
):
111119
components, poll = load_components()
@@ -115,6 +123,7 @@ def test_keeps_bundled_fields_not_in_override(self):
115123
with (
116124
patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)),
117125
patch("pathlib.Path.exists", return_value=True),
126+
patch("pathlib.Path.stat", return_value=self._safe_stat_mock()),
118127
patch("pathlib.Path.is_dir", return_value=True),
119128
):
120129
components, poll = load_components()
@@ -127,6 +136,7 @@ def test_syntax_error_falls_back_to_bundled(self, caplog):
127136
with (
128137
patch("builtins.open", _mock_load(BUNDLE_YAML, invalid_yaml)),
129138
patch("pathlib.Path.exists", return_value=True),
139+
patch("pathlib.Path.stat", return_value=self._safe_stat_mock()),
130140
patch("pathlib.Path.is_dir", return_value=True),
131141
caplog.at_level(logging.ERROR, logger="updater.components"),
132142
):
@@ -270,3 +280,65 @@ def test_yaml_missing_returns_empty_list(self, caplog):
270280
assert components == []
271281
assert poll == 1440 * 60.0 # default poll interval
272282
assert any("not installed" in r.message for r in caplog.records)
283+
284+
285+
class TestOverridePermissions:
286+
def test_override_skipped_when_world_writable(self, caplog):
287+
"""Override file with mode 0o622 (world-writable) is skipped."""
288+
mock_stat = MagicMock()
289+
mock_stat.st_mode = 0o100622 # Regular file, world-writable
290+
with (
291+
patch("builtins.open", _mock_load(BUNDLE_YAML)),
292+
patch("pathlib.Path.exists", return_value=True),
293+
patch("pathlib.Path.stat", return_value=mock_stat),
294+
patch("pathlib.Path.is_dir", return_value=True),
295+
caplog.at_level(logging.WARNING, logger="updater.components"),
296+
):
297+
components, poll = load_components()
298+
# Should only have bundled components, no override merged
299+
names = [c.name for c in components]
300+
assert "klipper" in names
301+
assert "blockscreen" in names
302+
assert any("writable by group/others" in r.message for r in caplog.records)
303+
304+
def test_override_skipped_when_group_writable(self, caplog):
305+
"""Override file with mode 0o620 (group-writable) is skipped."""
306+
mock_stat = MagicMock()
307+
mock_stat.st_mode = 0o100620 # Regular file, group-writable
308+
with (
309+
patch("builtins.open", _mock_load(BUNDLE_YAML)),
310+
patch("pathlib.Path.exists", return_value=True),
311+
patch("pathlib.Path.stat", return_value=mock_stat),
312+
patch("pathlib.Path.is_dir", return_value=True),
313+
caplog.at_level(logging.WARNING, logger="updater.components"),
314+
):
315+
components, poll = load_components()
316+
assert any("writable by group/others" in r.message for r in caplog.records)
317+
318+
def test_override_loaded_with_safe_permissions(self):
319+
"""Override file with mode 0o600 (owner-only) is loaded."""
320+
mock_stat = MagicMock()
321+
mock_stat.st_mode = 0o100600 # Regular file, owner-only
322+
with (
323+
patch("builtins.open", _mock_load(BUNDLE_YAML, OVERRIDE_YAML)),
324+
patch("pathlib.Path.exists", return_value=True),
325+
patch("pathlib.Path.stat", return_value=mock_stat),
326+
patch("pathlib.Path.is_dir", return_value=True),
327+
):
328+
components, poll = load_components()
329+
# Should have merged components from override
330+
assert any(c.name == "my-plugin" for c in components)
331+
klipper = next(c for c in components if c.name == "klipper")
332+
assert str(klipper.path).endswith("costum_klipper")
333+
334+
def test_override_skipped_when_stat_fails(self, caplog):
335+
"""Override file that cannot be stat'd is skipped."""
336+
with (
337+
patch("builtins.open", _mock_load(BUNDLE_YAML)),
338+
patch("pathlib.Path.exists", return_value=True),
339+
patch("pathlib.Path.stat", side_effect=OSError("Permission denied")),
340+
patch("pathlib.Path.is_dir", return_value=True),
341+
caplog.at_level(logging.WARNING, logger="updater.components"),
342+
):
343+
components, poll = load_components()
344+
assert any("Cannot stat override path" in r.message for r in caplog.records)

0 commit comments

Comments
 (0)