ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
remote_gdb_guard.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Validate remote-GDB transport fields and broker its local state."""
4
5from __future__ import annotations
6
7import argparse
8import contextlib
9import hashlib
10import hmac
11import json
12import os
13import pwd
14import re
15import runpy
16import secrets
17import socket
18import stat
19import struct
20import sys
21import time
22from collections.abc import Callable
23from dataclasses import dataclass
24from pathlib import Path
25
26RECORD_VERSION = 1
27PROTOCOL_VERSION = 1
28STATE_NAME = "state.json"
29SOCKET_NAME = "control.sock"
30MAX_RECORD_BYTES = 16 * 1024
31MAX_AUTHORITY_BYTES = 256 * 1024
32MAX_REQUEST_BYTES = 1024
33MAX_APP_ARG_BYTES = 256
34ASCII_MIN = 0x21
35ASCII_MAX = 0x7E
36PORT_MIN = 1024
37PORT_MAX = 65535
38DIR_MODE = 0o700
39FILE_MODE = 0o600
40HEX_RE = re.compile(r"[0-9a-f]{64}")
41ACCEPT_TIMEOUT = socket.timeout
42
43
44class GuardError(ValueError):
45 """A transport, broker, or process-state claim failed closed."""
46
47
48@dataclass(frozen=True)
49class RecordProof:
50 """One descriptor-bound state record and its content identity."""
51
52 value: dict[str, object]
53 stat: os.stat_result
54 digest: str
55
56
57@dataclass(frozen=True)
58class BrokerRequest:
59 """Canonical authorities supplied by the direct parent Bash process."""
60
61 root: Path
62 script: Path
63 port: str
64 app_arg: str
65 parent_pid: int
66 runtime_base: Path | None = None
67 proc_root: Path = Path("/proc")
68 platform: str = sys.platform
69
70
71@dataclass(frozen=True)
72class BrokerHooks:
73 """Injectable process hooks keep offline tests away from real signals."""
74
75 getppid: Callable[[], int] = os.getppid
76 signal_parent: Callable[[int], None] | None = None
77 pid_alive: Callable[[int], bool] | None = None
78
79
80def _load_process_authority() -> dict[str, object]:
81 path = Path(__file__).resolve(strict=True).with_name("remote_gdb_process.py")
82 try:
83 observed = path.lstat()
84 except OSError as exc:
85 msg = "remote-GDB process authority is unavailable"
86 raise RuntimeError(msg) from exc
87 if not stat.S_ISREG(observed.st_mode) or path.is_symlink():
88 msg = "remote-GDB process authority is linked or special"
89 raise RuntimeError(msg)
90 return runpy.run_path(str(path))
91
92
93_PROCESS = _load_process_authority()
94ProcessError = _PROCESS["ProcessError"]
95ProcessProof = _PROCESS["ProcessProof"]
96
97
98def _validate_port(value: str) -> int:
99 if not value.isascii() or not value.isdecimal():
100 msg = "port must be decimal"
101 raise GuardError(msg)
102 port = int(value, 10)
103 if not PORT_MIN <= port <= PORT_MAX:
104 msg = "port must be between 1024 and 65535"
105 raise GuardError(msg)
106 return port
107
108
109def _canonical_authorities(root_arg: str) -> tuple[Path, Path]:
110 root = Path(root_arg)
111 try:
112 resolved_root = root.resolve(strict=True)
113 script = resolved_root / "scripts/dev/remote_gdb_server.sh"
114 resolved_script = script.resolve(strict=True)
115 root_stat = root.lstat()
116 script_stat = script.lstat()
117 except OSError as exc:
118 msg = "workspace or script authority is unavailable"
119 raise GuardError(msg) from exc
120 expected = resolved_root / "scripts/dev/remote_gdb_server.sh"
121 if root != resolved_root or script != resolved_script or resolved_script != expected:
122 msg = "workspace or script authority is not canonical"
123 raise GuardError(msg)
124 if not stat.S_ISDIR(root_stat.st_mode) or not stat.S_ISREG(script_stat.st_mode):
125 msg = "workspace or script authority has the wrong type"
126 raise GuardError(msg)
127 return resolved_root, resolved_script
128
129
130def _regular_identity(path: Path) -> tuple[os.stat_result, str]:
131 flags = os.O_RDONLY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
132 try:
133 descriptor = os.open(path, flags)
134 before = os.fstat(descriptor)
135 raw = os.read(descriptor, MAX_AUTHORITY_BYTES + 1)
136 after = os.fstat(descriptor)
137 current = path.lstat()
138 except OSError as exc:
139 msg = f"cannot authenticate regular authority {path}"
140 raise GuardError(msg) from exc
141 finally:
142 if "descriptor" in locals():
143 os.close(descriptor)
144 if (
145 len(raw) > MAX_AUTHORITY_BYTES
146 or not stat.S_ISREG(before.st_mode)
147 or (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino)
148 or (before.st_dev, before.st_ino) != (current.st_dev, current.st_ino)
149 ):
150 msg = f"regular authority {path} is linked, replaced, special, or oversized"
151 raise GuardError(msg)
152 return before, hashlib.sha256(raw).hexdigest()
153
154
155def _helper_digest() -> str:
156 helper = Path(__file__).resolve(strict=True)
157 _guard_stat, guard_digest = _regular_identity(helper)
158 _process_stat, process_digest = _regular_identity(helper.with_name("remote_gdb_process.py"))
159 return hashlib.sha256(f"{guard_digest}:{process_digest}".encode("ascii")).hexdigest()
160
161
162def _open_private_directory(path: Path, uid: int) -> int:
163 flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
164 try:
165 descriptor = os.open(path, flags)
166 observed = os.fstat(descriptor)
167 current = path.lstat()
168 except OSError as exc:
169 msg = f"unsafe runtime directory {path}"
170 raise GuardError(msg) from exc
171 if (
172 not stat.S_ISDIR(observed.st_mode)
173 or observed.st_uid != uid
174 or stat.S_IMODE(observed.st_mode) != DIR_MODE
175 or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
176 ):
177 os.close(descriptor)
178 msg = f"runtime directory {path} is not owned mode 0700"
179 raise GuardError(msg)
180 return descriptor
181
182
183def _secure_child(parent: Path, name: str, uid: int) -> Path:
184 parent_fd = _open_private_directory(parent, uid)
185 try:
186 try:
187 os.mkdir(name, DIR_MODE, dir_fd=parent_fd)
188 os.fsync(parent_fd)
189 except FileExistsError:
190 pass
191 finally:
192 os.close(parent_fd)
193 child = parent / name
194 descriptor = _open_private_directory(child, uid)
195 os.close(descriptor)
196 return child
197
198
199def _home_runtime(uid: int, home: Path | None) -> Path:
200 account_home = Path(pwd.getpwuid(uid).pw_dir) if home is None else home
201 try:
202 resolved = account_home.resolve(strict=True)
203 observed = account_home.lstat()
204 except OSError as exc:
205 msg = "account home is unavailable"
206 raise GuardError(msg) from exc
207 if (
208 account_home != resolved
209 or not stat.S_ISDIR(observed.st_mode)
210 or observed.st_uid != uid
211 or stat.S_IMODE(observed.st_mode) & (stat.S_IWGRP | stat.S_IWOTH)
212 ):
213 msg = "account home is linked, foreign, special, or writable by others"
214 raise GuardError(msg)
215 name = ".ra8-runtime"
216 try:
217 (account_home / name).mkdir(mode=DIR_MODE)
218 home_fd = os.open(account_home, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
219 try:
220 os.fsync(home_fd)
221 finally:
222 os.close(home_fd)
223 except FileExistsError:
224 pass
225 return _secure_child(account_home / name, "remote-gdb", uid)
226
227
228def _runtime_directory(
229 root: Path,
230 *,
231 uid: int | None = None,
232 platform: str = sys.platform,
233 base: Path | None = None,
234) -> Path:
235 owner = os.getuid() if uid is None else uid
236 linux_base = Path("/run/user") / str(owner)
237 if base is not None:
238 descriptor = _open_private_directory(base, owner)
239 os.close(descriptor)
240 selected = _secure_child(base, "ra8-remote-gdb", owner)
241 elif platform.startswith("linux") and linux_base.exists():
242 descriptor = _open_private_directory(linux_base, owner)
243 os.close(descriptor)
244 selected = _secure_child(linux_base, "ra8-remote-gdb", owner)
245 else:
246 selected = _home_runtime(owner, None)
247 key = hashlib.sha256(os.fsencode(root)).hexdigest()[:24]
248 return _secure_child(selected, key, owner)
249
250
251def _process_value(name: str, *arguments: object) -> object:
252 try:
253 return _PROCESS[name](*arguments)
254 except ProcessError as exc:
255 raise GuardError(str(exc)) from exc
256
257
258def _proc_start_ticks(proc_root: Path, pid: int) -> int:
259 return int(_process_value("start_ticks", proc_root, pid))
260
261
262def _proc_uid(proc_root: Path, pid: int) -> int:
263 return int(_process_value("process_uid", proc_root, pid))
264
265
266def _proc_argv(proc_root: Path, pid: int) -> tuple[str, ...]:
267 return tuple(_process_value("process_argv", proc_root, pid))
268
269
270def _process_proof(pid: int, request: BrokerRequest) -> ProcessProof:
271 return _process_value(
272 "parent_proof",
273 pid,
274 (request.root, request.script, request.port, request.app_arg),
275 request.proc_root,
276 )
277
278
279def _record_keys() -> set[str]:
280 return {
281 "version",
282 "uid",
283 "platform",
284 "broker_pid",
285 "broker_start_ticks",
286 "parent_pid",
287 "parent_start_ticks",
288 "parent_argv",
289 "root",
290 "root_dev",
291 "root_ino",
292 "script",
293 "script_dev",
294 "script_ino",
295 "script_sha256",
296 "helper_sha256",
297 "port",
298 "app_arg",
299 "socket_dev",
300 "socket_ino",
301 "nonce",
302 }
303
304
305def _no_duplicate_json(pairs: list[tuple[str, object]]) -> dict[str, object]:
306 result: dict[str, object] = {}
307 for key, value in pairs:
308 if key in result:
309 msg = f"duplicate state field {key}"
310 raise GuardError(msg)
311 result[key] = value
312 return result
313
314
315def _validate_record(value: dict[str, object]) -> None:
316 if (
317 set(value) != _record_keys()
318 or type(value["version"]) is not int
319 or value["version"] != RECORD_VERSION
320 ):
321 msg = "state schema or version is invalid"
322 raise GuardError(msg)
323 integers = (
324 "uid",
325 "broker_pid",
326 "broker_start_ticks",
327 "parent_pid",
328 "parent_start_ticks",
329 "root_dev",
330 "root_ino",
331 "script_dev",
332 "script_ino",
333 "socket_dev",
334 "socket_ino",
335 )
336 if any(type(value[field]) is not int or int(value[field]) < 0 for field in integers):
337 msg = "state integer field is invalid"
338 raise GuardError(msg)
339 strings = (
340 "platform",
341 "root",
342 "script",
343 "script_sha256",
344 "helper_sha256",
345 "port",
346 "app_arg",
347 "nonce",
348 )
349 if any(type(value[field]) is not str for field in strings):
350 msg = "state string field is invalid"
351 raise GuardError(msg)
352 argv = value["parent_argv"]
353 if not isinstance(argv, list) or any(type(field) is not str for field in argv):
354 msg = "state parent argv is invalid"
355 raise GuardError(msg)
356 if any(
357 HEX_RE.fullmatch(str(value[field])) is None
358 for field in ("script_sha256", "helper_sha256", "nonce")
359 ):
360 msg = "state digest or nonce is invalid"
361 raise GuardError(msg)
362 _validate_port(str(value["port"]))
363
364
365def _publish_record(state_dir: Path, value: dict[str, object]) -> RecordProof:
366 payload = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("ascii")
367 directory = _open_private_directory(state_dir, os.getuid())
368 temporary = f".{STATE_NAME}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
369 flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
370 descriptor = -1
371 try:
372 descriptor = os.open(temporary, flags, FILE_MODE, dir_fd=directory)
373 os.fchmod(descriptor, FILE_MODE)
374 remaining = memoryview(payload)
375 while remaining:
376 remaining = remaining[os.write(descriptor, remaining) :]
377 os.fsync(descriptor)
378 os.link(
379 temporary, STATE_NAME, src_dir_fd=directory, dst_dir_fd=directory, follow_symlinks=False
380 )
381 os.unlink(temporary, dir_fd=directory)
382 os.fsync(directory)
383 except FileExistsError as exc:
384 msg = "remote-GDB state already exists"
385 raise GuardError(msg) from exc
386 finally:
387 if descriptor >= 0:
388 os.close(descriptor)
389 with contextlib.suppress(FileNotFoundError):
390 os.unlink(temporary, dir_fd=directory)
391 os.close(directory)
392 return _read_record(state_dir)
393
394
395def _read_record(state_dir: Path) -> RecordProof:
396 directory = _open_private_directory(state_dir, os.getuid())
397 flags = os.O_RDONLY | os.O_NONBLOCK | os.O_CLOEXEC | getattr(os, "O_NOFOLLOW", 0)
398 try:
399 descriptor = os.open(STATE_NAME, flags, dir_fd=directory)
400 before = os.fstat(descriptor)
401 raw = os.read(descriptor, MAX_RECORD_BYTES + 1)
402 after = os.fstat(descriptor)
403 current = os.stat(STATE_NAME, dir_fd=directory, follow_symlinks=False)
404 except FileNotFoundError as exc:
405 msg = "remote-GDB state is absent"
406 raise GuardError(msg) from exc
407 except OSError as exc:
408 msg = "remote-GDB state cannot be opened safely"
409 raise GuardError(msg) from exc
410 finally:
411 if "descriptor" in locals():
412 os.close(descriptor)
413 os.close(directory)
414 if (
415 len(raw) > MAX_RECORD_BYTES
416 or not stat.S_ISREG(before.st_mode)
417 or before.st_uid != os.getuid()
418 or stat.S_IMODE(before.st_mode) != FILE_MODE
419 or (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino)
420 or (before.st_dev, before.st_ino) != (current.st_dev, current.st_ino)
421 ):
422 msg = "remote-GDB state is linked, replaced, foreign, special, or oversized"
423 raise GuardError(msg)
424 try:
425 value = json.loads(raw.decode("ascii", "strict"), object_pairs_hook=_no_duplicate_json)
426 except (UnicodeError, json.JSONDecodeError) as exc:
427 msg = "remote-GDB state JSON is malformed"
428 raise GuardError(msg) from exc
429 if not isinstance(value, dict):
430 msg = "remote-GDB state is not an object"
431 raise GuardError(msg)
432 _validate_record(value)
433 return RecordProof(value, before, hashlib.sha256(raw).hexdigest())
434
435
436def _unlink_exact(state_dir: Path, expected: RecordProof) -> bool:
437 try:
438 current = _read_record(state_dir)
439 except GuardError:
440 return False
441 if (current.stat.st_dev, current.stat.st_ino) != (
442 expected.stat.st_dev,
443 expected.stat.st_ino,
444 ) or current.digest != expected.digest:
445 return False
446 directory = _open_private_directory(state_dir, os.getuid())
447 try:
448 os.unlink(STATE_NAME, dir_fd=directory)
449 os.fsync(directory)
450 finally:
451 os.close(directory)
452 return True
453
454
455def _peer_uid(connection: socket.socket) -> int:
456 if hasattr(connection, "getpeereid"):
457 uid, _gid = connection.getpeereid()
458 return int(uid)
459 if hasattr(socket, "SO_PEERCRED"):
460 _pid, uid, _gid = struct.unpack(
461 "3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
462 )
463 return int(uid)
464 if hasattr(socket, "LOCAL_PEERCRED"):
465 version, uid = struct.unpack(
466 "II",
467 connection.getsockopt(getattr(socket, "SOL_LOCAL", 0), socket.LOCAL_PEERCRED, 8),
468 )
469 if version != 0:
470 msg = "Unix peer credential version is invalid"
471 raise GuardError(msg)
472 return int(uid)
473 msg = "Unix peer credentials are unavailable"
474 raise GuardError(msg)
475
476
477def _socket_identity(path: Path) -> os.stat_result:
478 try:
479 observed = path.lstat()
480 except OSError as exc:
481 msg = "remote-GDB control socket is unavailable"
482 raise GuardError(msg) from exc
483 if (
484 not stat.S_ISSOCK(observed.st_mode)
485 or observed.st_uid != os.getuid()
486 or stat.S_IMODE(observed.st_mode) != FILE_MODE
487 ):
488 msg = "remote-GDB control path is not the owned socket"
489 raise GuardError(msg)
490 return observed
491
492
493def _request_bytes(action: str, nonce: str) -> bytes:
494 if action not in {"status", "stop", "release"} or HEX_RE.fullmatch(nonce) is None:
495 msg = "control request fields are invalid"
496 raise GuardError(msg)
497 value = {"action": action, "nonce": nonce, "version": PROTOCOL_VERSION}
498 return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("ascii")
499
500
501def _parse_request(raw: bytes, nonce: str) -> str:
502 if not raw.endswith(b"\n") or len(raw) > MAX_REQUEST_BYTES:
503 msg = "control request is incomplete or oversized"
504 raise GuardError(msg)
505 try:
506 value = json.loads(raw.decode("ascii", "strict"), object_pairs_hook=_no_duplicate_json)
507 except (UnicodeError, json.JSONDecodeError) as exc:
508 msg = "control request JSON is malformed"
509 raise GuardError(msg) from exc
510 if not isinstance(value, dict) or set(value) != {"action", "nonce", "version"}:
511 msg = "control request schema is invalid"
512 raise GuardError(msg)
513 if value["version"] != PROTOCOL_VERSION or type(value["action"]) is not str:
514 msg = "control request version or action type is invalid"
515 raise GuardError(msg)
516 supplied = value["nonce"]
517 if type(supplied) is not str or not hmac.compare_digest(supplied, nonce):
518 msg = "control request nonce is invalid"
519 raise GuardError(msg)
520 if value["action"] not in {"status", "stop", "release"}:
521 msg = "control request action is invalid"
522 raise GuardError(msg)
523 return str(value["action"])
524
525
526def _static_record(request: BrokerRequest, record: RecordProof) -> None:
527 value = record.value
528 root_stat = request.root.lstat()
529 script_stat, script_digest = _regular_identity(request.script)
530 helper_digest = _helper_digest()
531 expected = {
532 "uid": os.getuid(),
533 "platform": request.platform,
534 "root": str(request.root),
535 "root_dev": root_stat.st_dev,
536 "root_ino": root_stat.st_ino,
537 "script": str(request.script),
538 "script_dev": script_stat.st_dev,
539 "script_ino": script_stat.st_ino,
540 "script_sha256": script_digest,
541 "helper_sha256": helper_digest,
542 "port": request.port,
543 }
544 if any(value[key] != wanted for key, wanted in expected.items()):
545 msg = "remote-GDB state does not match current authorities"
546 raise GuardError(msg)
547
548
549def _broker_live(request: BrokerRequest, record: RecordProof) -> bool:
550 pid = int(record.value["broker_pid"])
551 if request.platform.startswith("linux"):
552 try:
553 return _proc_uid(request.proc_root, pid) == os.getuid() and _proc_start_ticks(
554 request.proc_root, pid
555 ) == int(record.value["broker_start_ticks"])
556 except GuardError:
557 return False
558 return _pid_alive(pid)
559
560
561def _live_broker_record(request: BrokerRequest, record: RecordProof) -> None:
562 _static_record(request, record)
563 if not _broker_live(request, record):
564 msg = "remote-GDB broker identity is stale"
565 raise GuardError(msg)
566 if not request.platform.startswith("linux"):
567 return
568 pid = int(record.value["broker_pid"])
569 try:
570 executable = (request.proc_root / str(pid) / "exe").resolve(strict=True)
571 except OSError as exc:
572 msg = "remote-GDB broker executable is unavailable"
573 raise GuardError(msg) from exc
574 helper = str(Path(__file__).resolve(strict=True))
575 expected = (
576 "/usr/bin/python3",
577 "-I",
578 helper,
579 "broker",
580 "--root",
581 str(request.root),
582 "--port",
583 request.port,
584 "--parent-pid",
585 str(record.value["parent_pid"]),
586 "--app-arg",
587 str(record.value["app_arg"]),
588 )
589 if (
590 executable != Path("/usr/bin/python3").resolve(strict=True)
591 or _proc_argv(request.proc_root, pid) != expected
592 ):
593 msg = "remote-GDB broker executable or argv is invalid"
594 raise GuardError(msg)
595
596
597def _request_broker(
598 state_dir: Path,
599 action: str,
600 *,
601 request: BrokerRequest | None = None,
602 timeout: float = 2.0,
603) -> RecordProof:
604 record = _read_record(state_dir)
605 if request is not None:
606 _live_broker_record(request, record)
607 socket_path = state_dir / SOCKET_NAME
608 socket_stat = _socket_identity(socket_path)
609 if (socket_stat.st_dev, socket_stat.st_ino) != (
610 int(record.value["socket_dev"]),
611 int(record.value["socket_ino"]),
612 ):
613 msg = "control socket identity does not match state"
614 raise GuardError(msg)
615 client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
616 client.settimeout(timeout)
617 try:
618 client.connect(str(socket_path))
619 client.sendall(_request_bytes(action, str(record.value["nonce"])))
620 response = client.recv(16)
621 except OSError as exc:
622 msg = "control broker is unavailable"
623 raise GuardError(msg) from exc
624 finally:
625 client.close()
626 if response != b"OK\n":
627 msg = "control broker returned an invalid response"
628 raise GuardError(msg)
629 return record
630
631
632def _pid_alive(pid: int) -> bool:
633 return bool(_process_value("pid_alive", pid))
634
635
636def _pidfd_live(descriptor: int) -> bool:
637 return bool(_process_value("pidfd_live", descriptor))
638
639
640def _signal_authority(request: BrokerRequest) -> tuple[Callable[[int], None], int | None]:
641 raw_signal, descriptor = _process_value(
642 "signal_authority", request.parent_pid, request.platform
643 )
644
645 def checked_signal(pid: int) -> None:
646 try:
647 raw_signal(pid)
648 except ProcessError as exc:
649 raise GuardError(str(exc)) from exc
650
651 return checked_signal, descriptor
652
653
654def _parent_authority(
655 request: BrokerRequest, hooks: BrokerHooks
656) -> tuple[Callable[[int], None], int | None, ProcessProof]:
657 signal_parent, pidfd = (
658 _signal_authority(request) if hooks.signal_parent is None else (hooks.signal_parent, None)
659 )
660 try:
661 parent = (
662 _process_proof(request.parent_pid, request)
663 if request.platform.startswith("linux")
664 else ProcessProof(0, ())
665 )
666 except (GuardError, OSError):
667 if pidfd is not None:
668 os.close(pidfd)
669 raise
670 if request.platform.startswith("linux") and (
671 pidfd is None or not _pidfd_live(pidfd) or hooks.getppid() != request.parent_pid
672 ):
673 if pidfd is not None:
674 os.close(pidfd)
675 msg = "remote-GDB parent changed during pidfd authentication"
676 raise GuardError(msg)
677 return signal_parent, pidfd, parent
678
679
680def _broker_record(
681 request: BrokerRequest,
682 socket_stat: os.stat_result,
683 parent: ProcessProof,
684) -> dict[str, object]:
685 root_stat = request.root.stat()
686 script_stat, script_digest = _regular_identity(request.script)
687 helper_digest = _helper_digest()
688 broker_ticks = (
689 _proc_start_ticks(request.proc_root, os.getpid())
690 if request.platform.startswith("linux")
691 else 0
692 )
693 return {
694 "version": RECORD_VERSION,
695 "uid": os.getuid(),
696 "platform": request.platform,
697 "broker_pid": os.getpid(),
698 "broker_start_ticks": broker_ticks,
699 "parent_pid": request.parent_pid,
700 "parent_start_ticks": parent.start_ticks,
701 "parent_argv": list(parent.argv),
702 "root": str(request.root),
703 "root_dev": root_stat.st_dev,
704 "root_ino": root_stat.st_ino,
705 "script": str(request.script),
706 "script_dev": script_stat.st_dev,
707 "script_ino": script_stat.st_ino,
708 "script_sha256": script_digest,
709 "helper_sha256": helper_digest,
710 "port": request.port,
711 "app_arg": request.app_arg,
712 "socket_dev": socket_stat.st_dev,
713 "socket_ino": socket_stat.st_ino,
714 "nonce": secrets.token_hex(32),
715 }
716
717
718def _cleanup_socket(state_dir: Path, socket_identity: os.stat_result) -> None:
719 directory = _open_private_directory(state_dir, os.getuid())
720 try:
721 with contextlib.suppress(FileNotFoundError):
722 current = os.stat(SOCKET_NAME, dir_fd=directory, follow_symlinks=False)
723 if (current.st_dev, current.st_ino) == (
724 socket_identity.st_dev,
725 socket_identity.st_ino,
726 ):
727 os.unlink(SOCKET_NAME, dir_fd=directory)
728 os.fsync(directory)
729 finally:
730 os.close(directory)
731
732
733def _recv_request(connection: socket.socket) -> bytes:
734 raw = bytearray()
735 while b"\n" not in raw and len(raw) <= MAX_REQUEST_BYTES:
736 chunk = connection.recv(min(256, MAX_REQUEST_BYTES + 1 - len(raw)))
737 if not chunk:
738 break
739 raw.extend(chunk)
740 if raw.count(b"\n") != 1 or not raw.endswith(b"\n"):
741 msg = "control request has trailing, incomplete, or excessive data"
742 raise GuardError(msg)
743 return bytes(raw)
744
745
746def _serve_connection(
747 connection: socket.socket,
748 nonce: str,
749 request: BrokerRequest,
750 signal_parent: Callable[[int], None],
751 getppid: Callable[[], int],
752) -> bool:
753 connection.settimeout(2.0)
754 if _peer_uid(connection) != os.getuid():
755 msg = "control peer has the wrong UID"
756 raise GuardError(msg)
757 raw = _recv_request(connection)
758 action = _parse_request(raw, nonce)
759 if action == "status":
760 connection.sendall(b"OK\n")
761 return True
762 if getppid() != request.parent_pid:
763 msg = "broker parent died before control action"
764 raise GuardError(msg)
765 if action == "stop":
766 signal_parent(request.parent_pid)
767 connection.sendall(b"OK\n")
768 return False
769
770
771def _state_dir(request: BrokerRequest) -> Path:
772 return _runtime_directory(
773 request.root,
774 platform=request.platform,
775 base=request.runtime_base,
776 )
777
778
779def _prepare_state(request: BrokerRequest, hooks: BrokerHooks) -> Path:
780 state_dir = _state_dir(request)
781 socket_path = state_dir / SOCKET_NAME
782 try:
783 record = _read_record(state_dir)
784 except GuardError as exc:
785 if str(exc) != "remote-GDB state is absent":
786 raise
787 if socket_path.exists() or socket_path.is_symlink():
788 msg = "control socket exists without authenticated state"
789 raise GuardError(msg) from exc
790 return state_dir
791 _static_record(request, record)
792 alive = (
793 hooks.pid_alive(int(record.value["broker_pid"]))
794 if hooks.pid_alive is not None and not request.platform.startswith("linux")
795 else _broker_live(request, record)
796 )
797 if alive:
798 msg = "an authenticated remote-GDB broker is already live"
799 raise GuardError(msg)
800 if socket_path.exists() or socket_path.is_symlink():
801 socket_stat = _socket_identity(socket_path)
802 if (socket_stat.st_dev, socket_stat.st_ino) != (
803 int(record.value["socket_dev"]),
804 int(record.value["socket_ino"]),
805 ):
806 msg = "stale control socket identity is ambiguous"
807 raise GuardError(msg)
808 _cleanup_socket(state_dir, socket_stat)
809 if not _unlink_exact(state_dir, record):
810 msg = "stale remote-GDB state changed during cleanup"
811 raise GuardError(msg)
812 return state_dir
813
814
815def _run_broker(request: BrokerRequest, hooks: BrokerHooks | None = None) -> None:
816 hooks = BrokerHooks() if hooks is None else hooks
817 if hooks.getppid() != request.parent_pid:
818 msg = "broker caller is not its direct parent"
819 raise GuardError(msg)
820 state_dir = _prepare_state(request, hooks)
821 socket_path = state_dir / SOCKET_NAME
822 record: RecordProof | None = None
823 socket_stat: os.stat_result | None = None
824 signal_parent, pidfd, parent = _parent_authority(request, hooks)
825 server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
826 try:
827 server.bind(str(socket_path))
828 socket_path.chmod(FILE_MODE)
829 socket_stat = _socket_identity(socket_path)
830 server.listen(1)
831 server.settimeout(0.25)
832 record = _publish_record(state_dir, _broker_record(request, socket_stat, parent))
833 keep_running = True
834 while keep_running and hooks.getppid() == request.parent_pid:
835 try:
836 connection, _address = server.accept()
837 except ACCEPT_TIMEOUT:
838 continue
839 with connection:
840 try:
841 keep_running = _serve_connection(
842 connection,
843 str(record.value["nonce"]),
844 request,
845 signal_parent,
846 hooks.getppid,
847 )
848 except (GuardError, OSError):
849 with contextlib.suppress(OSError):
850 connection.sendall(b"ERR\n")
851 finally:
852 server.close()
853 if record is not None:
854 _unlink_exact(state_dir, record)
855 if socket_stat is not None:
856 _cleanup_socket(state_dir, socket_stat)
857 if pidfd is not None:
858 os.close(pidfd)
859
860
861def _status_broker(state_dir: Path, request: BrokerRequest) -> RecordProof | None:
862 try:
863 return _request_broker(state_dir, "status", request=request, timeout=0.25)
864 except GuardError:
865 return None
866
867
868def _await_broker(request: BrokerRequest, broker_pid: int, timeout: float = 5.0) -> None:
869 state_dir = _state_dir(request)
870 deadline = time.monotonic() + timeout
871 while time.monotonic() < deadline:
872 record = _status_broker(state_dir, request)
873 if record is not None and int(record.value["broker_pid"]) == broker_pid:
874 return
875 time.sleep(0.05)
876 msg = "remote-GDB broker did not become ready"
877 raise GuardError(msg)
878
879
880def _control_broker(request: BrokerRequest, action: str) -> None:
881 state_dir = _state_dir(request)
882 try:
883 _request_broker(state_dir, action, request=request)
884 except GuardError as exc:
885 if str(exc) == "remote-GDB state is absent":
886 return
887 raise
888
889
890def _request_from_args(args: argparse.Namespace) -> BrokerRequest:
891 root, script = _canonical_authorities(args.root)
892 _validate_port(args.port)
893 app_arg = getattr(args, "app_arg", "")
894 try:
895 raw = app_arg.encode("ascii", "strict")
896 except UnicodeEncodeError as exc:
897 msg = "application argument must be ASCII"
898 raise GuardError(msg) from exc
899 if raw and (
900 len(raw) > MAX_APP_ARG_BYTES or any(byte < ASCII_MIN or byte > ASCII_MAX for byte in raw)
901 ):
902 msg = "application argument is unsafe or oversized"
903 raise GuardError(msg)
904 parent_pid = getattr(args, "parent_pid", 0)
905 if parent_pid < 0 or (args.command in {"broker", "await"} and parent_pid <= 1):
906 msg = "parent PID is invalid"
907 raise GuardError(msg)
908 return BrokerRequest(root, script, args.port, app_arg, parent_pid)
909
910
911def _parser() -> argparse.ArgumentParser:
912 parser = argparse.ArgumentParser(description=__doc__)
913 commands = parser.add_subparsers(dest="command", required=True)
914 for name in ("broker", "await", "stop", "release"):
915 command = commands.add_parser(name)
916 command.add_argument("--root", required=True)
917 command.add_argument("--port", required=True)
918 if name in {"broker", "await"}:
919 command.add_argument("--parent-pid", required=True, type=int)
920 if name == "broker":
921 command.add_argument("--app-arg", default="")
922 if name == "await":
923 command.add_argument("--broker-pid", required=True, type=int)
924 return parser
925
926
927def main() -> int:
928 """Dispatch one broker operation."""
929 args = _parser().parse_args()
930 try:
931 request = _request_from_args(args)
932 if args.command == "broker":
933 _run_broker(request)
934 elif args.command == "await":
935 _await_broker(request, args.broker_pid)
936 else:
937 _control_broker(request, args.command)
938 except GuardError as exc:
939 print(f"remote_gdb_guard: {exc}", file=sys.stderr)
940 return 2
941 return 0
942
943
944if __name__ == "__main__":
945 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157