Skip to content

Commit 1746b1c

Browse files
committed
Add Phase 6 hardening: encryption, regression, semantic replay, resume, WoL
Six new subsystems landing in one batch: - 6.1 Wake-on-LAN GUI: right-click a Quick Connect recent entry to send a magic packet; MAC + broadcast persisted in AddressBook. - 6.2 EncryptedJpegSequenceRecorder: AES-256-GCM per-frame with a HMAC-SHA256-signed manifest; key derivation from passphrase (PBKDF2 600k SHA-256) and round-trip + tamper-detection tests. - 6.3 Visual regression framework: PIL-only golden image compare with per-pixel threshold, region masks, and pytest-friendly DiffResult.write_diff(). - 6.4 Semantic recording enrichment: walk a recording, ask the accessibility tree for the smallest element covering each click, attach role/name/app_name as an `anchor` payload. - 6.6 Resume tokens: in-memory TTL store; host ships a one-shot token inside AUTH_OK, viewer reconnects with it and skips the approval popup while keeping its saved permission (view-only preserves across the reconnect). - 6.7 Replay-anywhere: at replay, use the anchor to look up the current element via the accessibility tree, rewrite x/y, fall back to the recorded coordinates when lookup fails. 41 new headless tests, ruff clean, complexity all ≤ 10.
1 parent ac60881 commit 1746b1c

18 files changed

Lines changed: 1530 additions & 16 deletions

je_auto_control/gui/language_wrapper/english.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,10 @@
899899
"rd_quick_publish_signaling": (
900900
"Publish via signaling (let viewers connect by 9-digit ID)"
901901
),
902+
"rd_quick_wake_host": "Wake host (WoL)",
903+
"rd_quick_edit_mac": "Edit MAC address…",
904+
"rd_quick_wol_mac_prompt": "MAC address (AA:BB:CC:DD:EE:FF):",
905+
"rd_quick_wol_sent": "Magic packet sent to {mac}.",
902906
"rd_host_security_warning": (
903907
"WARNING: anyone with the host:port and token gets full mouse / "
904908
"keyboard control of this machine. Default bind is 127.0.0.1; "

je_auto_control/gui/language_wrapper/japanese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,10 @@
899899
"rd_quick_publish_signaling": (
900900
"シグナリングに公開(ビューアが 9 桁 ID で接続できるようにする)"
901901
),
902+
"rd_quick_wake_host": "ホストを起動(WoL)",
903+
"rd_quick_edit_mac": "MAC アドレスを編集…",
904+
"rd_quick_wol_mac_prompt": "MAC アドレス(AA:BB:CC:DD:EE:FF):",
905+
"rd_quick_wol_sent": "マジックパケットを {mac} へ送信しました。",
902906
"rd_host_security_warning": (
903907
"警告:host:port と token を知る相手は、このマシンのマウス/キーボードを"
904908
"完全に操作できます。既定は 127.0.0.1。外部公開は SSH トンネルか"

je_auto_control/gui/language_wrapper/simplified_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,10 @@
885885
"rd_quick_approval_view_only": "仅检视",
886886
"rd_quick_approval_deny": "拒绝",
887887
"rd_quick_publish_signaling": "发布到 signaling(让对方用 9 位数 ID 连线)",
888+
"rd_quick_wake_host": "唤醒主机(WoL)",
889+
"rd_quick_edit_mac": "编辑 MAC 地址…",
890+
"rd_quick_wol_mac_prompt": "MAC 地址(AA:BB:CC:DD:EE:FF):",
891+
"rd_quick_wol_sent": "Magic packet 已发送到 {mac}。",
888892
"rd_host_security_warning": (
889893
"警告:取得本机 host:port 与 token 的人,可以完全控制本机的鼠标/键盘。"
890894
"默认仅绑 127.0.0.1;要对外请透过 SSH tunnel 或可信的 VPN。"

je_auto_control/gui/language_wrapper/traditional_chinese.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,10 @@
888888
"rd_quick_approval_view_only": "僅檢視",
889889
"rd_quick_approval_deny": "拒絕",
890890
"rd_quick_publish_signaling": "發布到 signaling(讓對方用 9 位數 ID 連線)",
891+
"rd_quick_wake_host": "喚醒主機(WoL)",
892+
"rd_quick_edit_mac": "編輯 MAC 位址…",
893+
"rd_quick_wol_mac_prompt": "MAC 位址(AA:BB:CC:DD:EE:FF):",
894+
"rd_quick_wol_sent": "Magic packet 已送出到 {mac}。",
891895
"rd_host_security_warning": (
892896
"警告:取得本機 host:port 與 token 的人,可以完全控制本機的滑鼠/鍵盤。"
893897
"預設只綁 127.0.0.1;要對外請透過 SSH tunnel 或可信的 VPN。"

je_auto_control/gui/remote_desktop/connection_screen.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
from PySide6.QtCore import Qt, QTimer, Signal
2222
from PySide6.QtGui import QGuiApplication, QImage
2323
from PySide6.QtWidgets import (
24-
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
25-
QMessageBox, QPushButton, QVBoxLayout, QWidget,
24+
QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
25+
QListWidgetItem, QMenu, QMessageBox, QPushButton, QVBoxLayout, QWidget,
2626
)
2727

2828
from je_auto_control.gui._i18n_helpers import TranslatableMixin
@@ -42,6 +42,9 @@
4242
)
4343
from je_auto_control.utils.remote_desktop.host_id import format_host_id
4444
from je_auto_control.utils.remote_desktop.registry import registry
45+
from je_auto_control.utils.remote_desktop.wake_on_lan import (
46+
send_magic_packet,
47+
)
4548

4649
_HOST_ID_CSS = (
4750
"font-family: 'Consolas', 'Menlo', 'Courier New', monospace; "
@@ -118,6 +121,11 @@ def __init__(self, parent: Optional[QWidget] = None) -> None:
118121
self._connect_token.setEchoMode(QLineEdit.EchoMode.Password)
119122
self._recent = QListWidget()
120123
self._recent.itemActivated.connect(self._on_recent_activated)
124+
# Phase 6.1: right-click a recent entry → "Wake host" via
125+
# build_magic_packet / send_magic_packet (the MAC is stored in
126+
# AddressBook when the operator saved it for a previous session).
127+
self._recent.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
128+
self._recent.customContextMenuRequested.connect(self._on_recent_menu)
121129
self._start_btn: Optional[QPushButton] = None
122130
self._stop_btn: Optional[QPushButton] = None
123131
self._connect_btn: Optional[QPushButton] = None
@@ -552,6 +560,80 @@ def _on_recent_activated(self, item: QListWidgetItem) -> None:
552560
target = item.data(Qt.ItemDataRole.UserRole) or item.text()
553561
self._connect_target.setText(str(target))
554562

563+
def _on_recent_menu(self, pos) -> None:
564+
"""Right-click menu on the Recent list: edit MAC, send WoL."""
565+
item = self._recent.itemAt(pos)
566+
if item is None:
567+
return
568+
host_id = str(item.data(Qt.ItemDataRole.UserRole) or item.text())
569+
entry = self._find_address_book_entry(host_id)
570+
menu = QMenu(self._recent)
571+
wake = menu.addAction(_t("rd_quick_wake_host"))
572+
edit = menu.addAction(_t("rd_quick_edit_mac"))
573+
chosen = menu.exec(self._recent.mapToGlobal(pos))
574+
if chosen is wake:
575+
self._send_wake_on_lan(entry, host_id)
576+
elif chosen is edit:
577+
self._edit_recent_mac(entry, host_id)
578+
579+
def _find_address_book_entry(self, host_id: str):
580+
for entry in self._book.list_entries():
581+
if entry.get("host_id") == host_id:
582+
return entry
583+
return None
584+
585+
def _send_wake_on_lan(self, entry, host_id: str) -> None:
586+
mac = (entry or {}).get("mac_address") if entry else None
587+
if not mac:
588+
mac, ok = QInputDialog.getText(
589+
self, _t("rd_quick_wake_host"),
590+
_t("rd_quick_wol_mac_prompt"),
591+
)
592+
if not ok or not mac:
593+
return
594+
self._save_mac_to_book(host_id, mac)
595+
broadcast = (entry or {}).get("broadcast_address") if entry else None
596+
try:
597+
send_magic_packet(
598+
mac, broadcast=broadcast or "255.255.255.255",
599+
)
600+
except (OSError, ValueError) as error:
601+
QMessageBox.warning(
602+
self, _t("rd_quick_wake_host"), str(error),
603+
)
604+
return
605+
QMessageBox.information(
606+
self, _t("rd_quick_wake_host"),
607+
_t("rd_quick_wol_sent").replace("{mac}", mac),
608+
)
609+
610+
def _edit_recent_mac(self, entry, host_id: str) -> None:
611+
current = (entry or {}).get("mac_address") if entry else ""
612+
mac, ok = QInputDialog.getText(
613+
self, _t("rd_quick_edit_mac"),
614+
_t("rd_quick_wol_mac_prompt"),
615+
text=str(current or ""),
616+
)
617+
if not ok or not mac:
618+
return
619+
self._save_mac_to_book(host_id, mac)
620+
621+
def _save_mac_to_book(self, host_id: str, mac: str) -> None:
622+
"""Persist the MAC against the matching AddressBook entry."""
623+
for entry in self._book.list_entries():
624+
if entry.get("host_id") == host_id:
625+
try:
626+
self._book.upsert(
627+
host_id=host_id,
628+
server_url=entry.get("server_url", host_id),
629+
label=entry.get("label", ""),
630+
mac_address=mac,
631+
)
632+
except (ValueError, OSError):
633+
return
634+
self._refresh_recent()
635+
return
636+
555637
# --- status -------------------------------------------------------
556638

557639
def _refresh_status(self) -> None:

je_auto_control/utils/remote_desktop/host.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@
3131
from je_auto_control.utils.remote_desktop.protocol import (
3232
AuthenticationError, MessageType, ProtocolError,
3333
)
34+
from je_auto_control.utils.remote_desktop.resume_tokens import (
35+
ResumeTokenStore,
36+
)
3437
from je_auto_control.utils.remote_desktop.transport import (
3538
MessageChannel, TcpMessageChannel,
3639
)
@@ -390,21 +393,34 @@ def _authenticate(self) -> None:
390393
raise AuthenticationError(
391394
f"expected AUTH_RESPONSE, got {msg_type.name}"
392395
)
393-
if not self._host._verify_token(nonce, payload):
394-
self._channel.send_typed(MessageType.AUTH_FAIL, b"bad token")
395-
raise AuthenticationError("bad token")
396-
# Host operator gates the session *before* AUTH_OK so the viewer
397-
# surfaces the rejection as an AuthenticationError instead of
398-
# connecting and then mysteriously disconnecting.
399-
permission = self._resolve_permission()
400-
if permission == PERMISSION_DENIED:
401-
self._channel.send_typed(
402-
MessageType.AUTH_FAIL, b"rejected by host",
403-
)
404-
raise AuthenticationError("rejected by host")
405-
self.permission = permission
396+
# Phase 6.6: a viewer reconnecting with a valid resume token
397+
# signs with that token directly — host short-circuits the
398+
# approval popup and reuses the saved permission.
399+
resumed = self._host._try_consume_resume(nonce, payload)
400+
if resumed is not None:
401+
self.permission = resumed
402+
else:
403+
if not self._host._verify_token(nonce, payload):
404+
self._channel.send_typed(MessageType.AUTH_FAIL, b"bad token")
405+
raise AuthenticationError("bad token")
406+
# Host operator gates the session *before* AUTH_OK so the
407+
# viewer surfaces the rejection as an AuthenticationError
408+
# instead of connecting and then mysteriously disconnecting.
409+
permission = self._resolve_permission()
410+
if permission == PERMISSION_DENIED:
411+
self._channel.send_typed(
412+
MessageType.AUTH_FAIL, b"rejected by host",
413+
)
414+
raise AuthenticationError("rejected by host")
415+
self.permission = permission
416+
# Issue a fresh resume token so the viewer can reconnect
417+
# within the store's TTL without the approval popup.
418+
resume_token = self._host._resume_store.issue(self.permission)
406419
ok_payload = json.dumps(
407-
{"host_id": self._host.host_id}, ensure_ascii=False,
420+
{"host_id": self._host.host_id,
421+
"resume_token": resume_token,
422+
"resume_ttl": self._host._resume_store.ttl},
423+
ensure_ascii=False,
408424
).encode("utf-8")
409425
self._channel.send_typed(MessageType.AUTH_OK, ok_payload)
410426
self._channel.settimeout(None)
@@ -652,6 +668,9 @@ def __init__(
652668
self._on_chat = on_chat
653669
# Phase 4.1: TOTP secret. None disables 2FA (default).
654670
self._totp_secret = totp_secret
671+
# Phase 6.6: in-memory resume tokens — viewer reconnects within
672+
# the TTL skip the approval popup and re-use the saved permission.
673+
self._resume_store = ResumeTokenStore()
655674
self._listen_sock: Optional[socket.socket] = None
656675
self._accept_thread: Optional[threading.Thread] = None
657676
self._capture_thread: Optional[threading.Thread] = None
@@ -857,6 +876,21 @@ def send_file_to_viewers(self, source_path: str, dest_path: str,
857876
)
858877
return len(clients)
859878

879+
def _try_consume_resume(self, nonce: bytes,
880+
payload: bytes) -> Optional[str]:
881+
"""Phase 6.6: find a resume token whose HMAC matches ``payload``.
882+
883+
Returns the saved permission string and removes the matching
884+
token from the store. Returns ``None`` when no token in the
885+
store signed this nonce — caller then falls back to the normal
886+
``_verify_token`` path.
887+
"""
888+
for token, perm in self._resume_store.list_active().items():
889+
if verify_response(token, nonce, payload):
890+
self._resume_store.remove(token)
891+
return perm
892+
return None
893+
860894
def _verify_token(self, nonce: bytes, payload: bytes) -> bool:
861895
"""Phase 4.2 + 4.1: token / single-use code / TOTP-bound token.
862896

0 commit comments

Comments
 (0)