From 0db0d2f589cace255971690c5f4bfefa06bda300 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:53:21 -0500 Subject: [PATCH] Software and AGNOS Manage software updates and AGNOS retained state through explicit manifests and update preferences. Co-authored-by: Daniel Koepping Co-authored-by: Prabhaav Pillai Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> --- launch_chffrplus.sh | 35 +++- launch_env.sh | 9 +- openpilot/common/hardware/comma/agnos.json | 71 +++---- openpilot/common/hardware/comma/agnos.py | 2 +- openpilot/starpilot/software/__init__.py | 0 openpilot/starpilot/software/history.py | 90 +++++++++ openpilot/starpilot/software/preferences.py | 19 ++ .../starpilot/software/tests/__init__.py | 0 .../starpilot/software/tests/test_history.py | 41 ++++ .../software/tests/test_preferences.py | 29 +++ .../software/tests/test_update_control.py | 87 ++++++++ .../starpilot/software/update_control.py | 185 ++++++++++++++++++ 12 files changed, 529 insertions(+), 39 deletions(-) create mode 100644 openpilot/starpilot/software/__init__.py create mode 100644 openpilot/starpilot/software/history.py create mode 100644 openpilot/starpilot/software/preferences.py create mode 100644 openpilot/starpilot/software/tests/__init__.py create mode 100644 openpilot/starpilot/software/tests/test_history.py create mode 100644 openpilot/starpilot/software/tests/test_preferences.py create mode 100644 openpilot/starpilot/software/tests/test_update_control.py create mode 100644 openpilot/starpilot/software/update_control.py diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 78678c2b90..7e771a90d8 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -5,6 +5,11 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" source "$DIR/launch_env.sh" function agnos_init { + if [ "${AGNOS_UPDATE_POLICY:-auto}" = "retain" ] && [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then + echo "This StarPilot build retains AGNOS $AGNOS_VERSION; installed OS does not match. No OS update was attempted." + return 1 + fi + # TODO: move this to agnos sudo rm -f /data/etc/NetworkManager/system-connections/*.nmmeta rm -f /data/scons_cache/config.lock @@ -20,7 +25,7 @@ function agnos_init { # Check if AGNOS update is required if [ "$(< /VERSION)" != "$AGNOS_VERSION" ]; then AGNOS_PY="$DIR/openpilot/common/hardware/comma/agnos.py" - MANIFEST="$DIR/openpilot/system/hardware/comma/agnos.json" + MANIFEST="$DIR/openpilot/common/hardware/comma/agnos.json" if "$AGNOS_PY" --verify "$MANIFEST"; then sudo reboot fi @@ -28,6 +33,28 @@ function agnos_init { "$DIR/openpilot/common/hardware/comma/updater" "$AGNOS_PY" "$MANIFEST" done fi + + # Add missing USB rules for C3's internal Panda; C3X/C4 use SPI. + if [ "$(tr -d '\0' 2>/dev/null < /sys/firmware/devicetree/base/model)" = "comma tici" ]; then + if ! sudo timeout -k 1s 5s sh -ec ' + mkdir -p /run/udev/rules.d + cat > /run/udev/rules.d/99-starpilot-panda.rules < /run/udev/rules.d/99-gpio.rules + mount --bind /run/udev/rules.d/99-gpio.rules /etc/udev/rules.d/99-gpio.rules + fi + udevadm control --reload-rules + for product in ddcc ddee; do + udevadm trigger --subsystem-match=usb --attr-match=idVendor=3801 --attr-match=idProduct="$product" + done + '; then + echo "Panda USB permission setup failed; continuing startup." + fi + fi } function launch { @@ -81,10 +108,14 @@ function launch { ln -sfn rednose_repo/rednose rednose ln -sfn teleoprtc_repo/teleoprtc teleoprtc ln -sfn tinygrad_repo/tinygrad tinygrad + ln -sfn jetlink_repo/jetlink jetlink # hardware specific init if [ -f /AGNOS ]; then - agnos_init + agnos_init || return $? + if ! "$DIR/scripts/install_boot_logo.sh"; then + echo "StarPilot boot image update failed; continuing startup." + fi fi # write tmux scrollback to a file diff --git a/launch_env.sh b/launch_env.sh index 0f913f287a..99e0c8ea9e 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -6,6 +6,11 @@ export NUMEXPR_NUM_THREADS=1 export OPENBLAS_NUM_THREADS=1 export VECLIB_MAXIMUM_THREADS=1 +# Use the dependency environment supplied by the retained custom image. +if [ -x /usr/local/venv/bin/python3 ]; then + export PATH="/usr/local/venv/bin:$PATH" +fi + # models get lower priority than ui # - ui is ~5ms # - modeld is 20ms @@ -16,7 +21,9 @@ export VECLIB_MAXIMUM_THREADS=1 export QCOM_PRIORITY=12 if [ -z "$AGNOS_VERSION" ]; then - export AGNOS_VERSION="19.9" + export AGNOS_VERSION="19.8.1" fi +export AGNOS_UPDATE_POLICY="auto" + export STAGING_ROOT="/data/safe_staging" diff --git a/openpilot/common/hardware/comma/agnos.json b/openpilot/common/hardware/comma/agnos.json index 96803bdb85..b3a23b4c28 100644 --- a/openpilot/common/hardware/comma/agnos.json +++ b/openpilot/common/hardware/comma/agnos.json @@ -1,84 +1,85 @@ [ { "name": "xbl", - "url": "https://commadist.azureedge.net/agnosupdate/xbl-e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb.img.xz", - "hash": "e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb", - "hash_raw": "e8acf2a9cc7f0ce84cb803bfea9477f765c0d7b4daf26048e59651b9e6a7bfbb", + "url": "https://commadist.azureedge.net/agnosupdate/xbl-effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b.img.xz", + "hash": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", + "hash_raw": "effa23294138e2297b85a5b482a885184c437b5ab25d74f2a62d4fce4e68f63b", "size": 3282256, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "bea7f1a24428c3ededf672fa4fc78baf180cfbd8aafb77c974655b38517283e3" + "ondevice_hash": "ed61a650bea0c56652dd0fc68465d8fc722a4e6489dc8f257630c42c6adcdc89" }, { "name": "xbl_config", - "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85.img.xz", - "hash": "758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85", - "hash_raw": "758552ecf92b5569677197783bf0ccb73d7f961685308e45d3276ac9dd974f85", + "url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c.img.xz", + "hash": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", + "hash_raw": "63d019efed684601f145ef37628e62c8da73f5053a8e51d7de09e72b8b11f97c", "size": 98124, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "fb18cde08a98a168961ecd357e92474823046752b94e112f59fe51a6acd7197d" + "ondevice_hash": "b12801ffaa81e58e3cef914488d3b447e35483ba549b28c6cd9deb4814c3265f" }, { "name": "abl", - "url": "https://commadist.azureedge.net/agnosupdate/abl-29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35.img.xz", - "hash": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35", - "hash_raw": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35", + "url": "https://commadist.azureedge.net/agnosupdate/abl-32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6.img.xz", + "hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", + "hash_raw": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6", "size": 274432, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "29fd7ed1c012e599420764840f9f11286d34dbff4adaf102a447f06d8c5e0b35" + "ondevice_hash": "32a2174b5f764e95dfc54cf358ba01752943b1b3b90e626149c3da7d5f1830b6" }, { "name": "aop", - "url": "https://commadist.azureedge.net/agnosupdate/aop-78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1.img.xz", - "hash": "78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1", - "hash_raw": "78b2287ca219a0811b3004c523fa0f4749e4d1fd92be3aba61699305b7943ad1", + "url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz", + "hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", + "hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9", "size": 184364, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "6c9135446bd3fc075fcee59b887a12e49029ab1f98ed8d6d1e32c73569d47de3" + "ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180" }, { "name": "devcfg", - "url": "https://commadist.azureedge.net/agnosupdate/devcfg-f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc.img.xz", - "hash": "f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc", - "hash_raw": "f71df3a86958c093ba3969254c4db025187eef9385427f1ade946742939b43cc", + "url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz", + "hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", + "hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620", "size": 40336, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "2a67971602012c1b43544964709da13c322786b456a8e78568b117e8b1540ce3" + "ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f" }, { "name": "boot", - "url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz", - "hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", - "hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615", - "size": 46962688, + "url": "https://files.firestar.link/x/31f0wvnkr4vh/boot31.img.xz", + "fallback_urls": [ + "https://files-east.firestar.link/x/191w2n28bgbb/boot31.img.xz" + ], + "hash": "ac3863c3834a94986647eb61d21796e7550095a18e0119c9102cab06ce390417", + "hash_raw": "ac3863c3834a94986647eb61d21796e7550095a18e0119c9102cab06ce390417", + "size": 49043456, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28" + "ondevice_hash": "b09c28cb609f996fce791c5cf36e00922896d54a54b476a7d9463d4cf817dc9b" }, { "name": "system", - "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz", - "hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d", - "hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", + "url": "https://files.firestar.link/x/ffkgctpyunid/system30.img.xz", + "fallback_urls": [ + "https://files-east.firestar.link/x/9ft817s9f5no/system30.img.xz" + ], + "hash": "9c753bdd90e6079b2cf525659d7523d0ebc0a375bd61e61cca628f1db3832cfd", + "hash_raw": "98e15de07c39a19b5fa17c96a67e66d4362f9ebaf7827b640f4127e3b6417da8", "size": 4718592000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef", - "alt": { - "hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7", - "url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img", - "size": 4718592000 - } + "ondevice_hash": "9625ec32df735b26ea6b0b62834addc8b522108d5f5c4a2aebcb98fc2b94f4e9" } -] \ No newline at end of file +] diff --git a/openpilot/common/hardware/comma/agnos.py b/openpilot/common/hardware/comma/agnos.py index de0fb24551..6f73b5bb85 100755 --- a/openpilot/common/hardware/comma/agnos.py +++ b/openpilot/common/hardware/comma/agnos.py @@ -12,7 +12,7 @@ import requests SPARSE_CHUNK_FMT = struct.Struct('H2xI4x') -AGNOS_MANIFEST_FILE = "openpilot/system/hardware/comma/agnos.json" +AGNOS_MANIFEST_FILE = "openpilot/common/hardware/comma/agnos.json" class StreamingDecompressor: diff --git a/openpilot/starpilot/software/__init__.py b/openpilot/starpilot/software/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/starpilot/software/history.py b/openpilot/starpilot/software/history.py new file mode 100644 index 0000000000..9b533208d2 --- /dev/null +++ b/openpilot/starpilot/software/history.py @@ -0,0 +1,90 @@ +"""Bounded local history and plain-text notes from the existing updater.""" + +from datetime import datetime +from html.parser import HTMLParser +from pathlib import Path +import re +import subprocess + +from openpilot.starpilot.saved_source import read_saved + +COMMIT = re.compile(r'[0-9a-f]{40}\Z') +MAX_COMMITS = 20 +MAX_NOTES_BYTES = 32768 + + +def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> list[dict]: + if not COMMIT.fullmatch(commit): + return [] + try: + output = subprocess.run( + ['git', '-C', str(path), '--no-pager', 'log', '--no-decorate', '--no-color', '--no-show-signature', + *(['--first-parent'] if first_parent else []), f'-{MAX_COMMITS}', '--format=%H%x00%cI%x00%<(240,trunc)%s%x00', commit, '--'], + check=True, capture_output=True, text=True, timeout=2).stdout + if len(output.encode()) > 32768: + return [] + fields = output.split('\0') + rows = [] + for index in range(0, len(fields) - 2, 3): + sha, date, subject = fields[index].strip(), fields[index + 1], fields[index + 2].rstrip() + if not COMMIT.fullmatch(sha) or len(subject) > 500: + return [] + parsed = datetime.fromisoformat(date) + if parsed.tzinfo is None: + return [] + rows.append({'hash': sha, 'date': parsed.isoformat(), 'subject': ''.join(c for c in subject if c.isprintable())}) + return rows[:MAX_COMMITS] + except (OSError, ValueError, subprocess.SubprocessError): + return [] + + +class _NotesText(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + self.hidden = 0 + + def handle_starttag(self, tag, attrs): + if tag in ('script', 'style'): + self.hidden += 1 + elif not self.hidden and tag in ('br', 'p', 'li', 'h1', 'h2', 'h3'): + self.parts.append('\n') + + def handle_endtag(self, tag): + if tag in ('script', 'style'): + self.hidden = max(0, self.hidden - 1) + elif not self.hidden and tag in ('p', 'li', 'h1', 'h2', 'h3'): + self.parts.append('\n') + + def handle_data(self, data): + if not self.hidden: + self.parts.append(data) + + +def release_notes(params, key: str) -> str | None: + raw, readable = read_saved(params, key, MAX_NOTES_BYTES) + if not readable or raw is None: + return None + try: + parser = _NotesText() + parser.feed(raw.decode('utf-8')) + parser.close() + return re.sub(r'\n{3,}', '\n\n', ''.join(parser.parts)).strip() + except (UnicodeError, ValueError): + return None + + +def recent_versions(path: Path, branch: str | None) -> dict: + empty = {'branch': branch, 'head': None, 'entries': []} + if type(branch) is not str or not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}', branch): + return empty + ref = f'refs/starpilot/recent/{branch}' + try: + subprocess.run(['git', '-C', str(path), 'check-ref-format', ref], check=True, capture_output=True, timeout=2) + head = subprocess.run(['git', '-C', str(path), 'rev-parse', '--verify', ref + '^{commit}'], + check=True, capture_output=True, text=True, timeout=2).stdout.strip() + if not COMMIT.fullmatch(head): + return empty + return {'branch': branch, 'head': head, 'entries': commit_history(path, head, first_parent=True)} + except (OSError, subprocess.SubprocessError): + return empty diff --git a/openpilot/starpilot/software/preferences.py b/openpilot/starpilot/software/preferences.py new file mode 100644 index 0000000000..94a7ebb9ea --- /dev/null +++ b/openpilot/starpilot/software/preferences.py @@ -0,0 +1,19 @@ +"""Automatic download preference; manual updater requests remain independent.""" + +from openpilot.starpilot.saved_source import read_saved + +AUTOMATIC_DOWNLOADS = 'UpdaterAutomaticDownloads' + + +def automatic_downloads(params) -> bool | None: + try: + raw, readable = read_saved(params, AUTOMATIC_DOWNLOADS, 1) + except (OSError, KeyError, ValueError): + return None + if not readable or raw not in (None, b'0', b'1'): + return None + return raw != b'0' # Preserve the upstream schedule for existing installations. + + +def download_permitted(params, *, manual: bool, check_only: bool) -> bool: + return not check_only and (manual or automatic_downloads(params) is True) diff --git a/openpilot/starpilot/software/tests/__init__.py b/openpilot/starpilot/software/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpilot/starpilot/software/tests/test_history.py b/openpilot/starpilot/software/tests/test_history.py new file mode 100644 index 0000000000..7fee220827 --- /dev/null +++ b/openpilot/starpilot/software/tests/test_history.py @@ -0,0 +1,41 @@ +from pathlib import Path +from types import SimpleNamespace +import subprocess + +from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions + + +def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path): + def git(*args): + return subprocess.run(['git', '-C', str(tmp_path), '-c', 'core.hooksPath=/dev/null', + '-c', 'user.name=Test', '-c', 'user.email=test@example.com', *args], + check=True, capture_output=True, text=True).stdout.strip() + git('init', '-q') + for index in range(23): + git('commit', '--allow-empty', '-qm', f'Change {index:02d}') + commit = git('rev-parse', 'HEAD~1') + rows = commit_history(tmp_path, commit) + assert len(rows) == 20 + assert rows[0]['hash'] == commit and rows[0]['subject'] == 'Change 21' + assert rows[-1]['subject'] == 'Change 02' + assert all(row['date'] for row in rows) + assert commit_history(tmp_path, '--all') == [] + assert commit_history(tmp_path, 'f' * 40) == [] + assert recent_versions(tmp_path, 'Dom')['entries'] == [] + git('update-ref', 'refs/starpilot/recent/Dom', commit) + recent = recent_versions(tmp_path, 'Dom') + assert recent['head'] == commit and recent['branch'] == 'Dom' and recent['entries'] == rows + assert recent_versions(tmp_path, '../escape')['entries'] == [] + assert recent_versions(tmp_path, 'Dom')['entries'][0]['hash'] == commit + + +def test_release_notes_are_bounded_plain_text(tmp_path): + params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key)) + path = Path(params.get_param_path('UpdaterCurrentReleaseNotes')) + assert release_notes(params, path.name) is None + path.write_text('

StarPilot

  • First & second
') + assert release_notes(params, path.name) == 'StarPilot\n\nFirst & second' + path.write_bytes(b'x' * 32769) + assert release_notes(params, path.name) is None + path.write_bytes(b'\xff') + assert release_notes(params, path.name) is None diff --git a/openpilot/starpilot/software/tests/test_preferences.py b/openpilot/starpilot/software/tests/test_preferences.py new file mode 100644 index 0000000000..11f14aae29 --- /dev/null +++ b/openpilot/starpilot/software/tests/test_preferences.py @@ -0,0 +1,29 @@ +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads, download_permitted + + +@pytest.mark.parametrize('raw, expected', [(None, True), (b'1', True), (b'0', False), (b'', None), (b'yes', None), (b'2', None)]) +def test_default_saved_and_invalid_values_preserve_manual_requests(tmp_path, raw, expected): + params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key)) + if raw is not None: + Path(params.get_param_path(AUTOMATIC_DOWNLOADS)).write_bytes(raw) + assert automatic_downloads(params) is expected + assert download_permitted(params, manual=False, check_only=False) is (expected is True) + assert download_permitted(params, manual=True, check_only=False) + assert not download_permitted(params, manual=False, check_only=True) + assert not download_permitted(params, manual=True, check_only=True) + + +def test_nonregular_and_symlink_preferences_do_not_enable_automatic_downloads(tmp_path): + params = SimpleNamespace(get_param_path=lambda key: str(tmp_path / key)) + path = tmp_path / AUTOMATIC_DOWNLOADS + path.mkdir() + assert automatic_downloads(params) is None + path.rmdir() + (tmp_path / 'value').write_bytes(b'1') + path.symlink_to(tmp_path / 'value') + assert automatic_downloads(params) is None diff --git a/openpilot/starpilot/software/tests/test_update_control.py b/openpilot/starpilot/software/tests/test_update_control.py new file mode 100644 index 0000000000..a51bf04e57 --- /dev/null +++ b/openpilot/starpilot/software/tests/test_update_control.py @@ -0,0 +1,87 @@ +import os +import socket +import sys +import threading +import unittest +from unittest import mock + +from openpilot.starpilot.software import update_control + + +class UpdaterControlTests(unittest.TestCase): + def test_invalid_identity_and_action_fail_closed(self): + self.assertFalse(update_control.available(0, 0)) + with self.assertRaises(update_control.UpdaterControlError): + update_control.send(0, 0, 'install') + + @unittest.skipUnless(sys.platform.startswith('linux') and hasattr(socket, 'SO_PEERCRED'), 'Linux peer credentials required') + def test_same_process_control_and_stale_identity(self): + actions = [] + accepted = threading.Event() + def request(action): + actions.append(action) + accepted.set() + server = update_control.UpdaterControlServer(request) + server.start() + self.addCleanup(server.close) + pid = os.getpid() + start = update_control.process_start(pid) + self.assertTrue(update_control.available(pid, start)) + self.assertEqual(actions, []) + update_control.send(pid, start, 'check') + self.assertTrue(accepted.wait(1)) + accepted.clear() + update_control.send(pid, start, 'download') + self.assertTrue(accepted.wait(1)) + self.assertEqual(actions, ['check', 'download']) + self.assertFalse(update_control.available(pid, start + 1)) + with self.assertRaises(update_control.UpdaterControlError): + update_control.send(pid, start + 1, 'check') + + real_peer = update_control._peer + def wrong_server_pid(connection): + if threading.current_thread() is threading.main_thread(): + return pid + 1, os.geteuid() + return real_peer(connection) + with mock.patch.object(update_control, '_peer', side_effect=wrong_server_pid): + with self.assertRaises(update_control.UpdaterControlError): + update_control.send(pid, start, 'check') + self.assertEqual(actions, ['check', 'download']) + + with mock.patch.object(update_control, '_peer', return_value=(pid + 1, os.geteuid() + 1)): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(1) + connection.connect(update_control._address(pid, start, os.geteuid())) + connection.sendall(b'check\n') + self.assertEqual(connection.recv(16), b'error\n') + self.assertEqual(actions, ['check', 'download']) + server.close() + self.assertFalse(update_control.available(pid, start)) + + def test_wait_helper_maps_control_requests_without_signal_handlers(self): + from openpilot.system.updated import updated + + with mock.patch.object(updated.signal, 'signal') as register_signal, \ + mock.patch.object(updated, 'UpdaterControlServer') as server_type, \ + mock.patch.object(updated.atexit, 'register'): + helper = updated.WaitTimeHelper() + self.assertEqual(register_signal.call_count, 2) + server_type.assert_called_once() + server_type.return_value.start.assert_called_once() + self.assertEqual(helper.user_request, updated.UserRequest.NONE) + self.assertFalse(helper.ready_event.is_set()) + helper._control_request('check') + self.assertEqual(helper.user_request, updated.UserRequest.CHECK) + self.assertTrue(helper.ready_event.is_set()) + helper.ready_event.clear() + helper._control_request('download') + self.assertEqual(helper.user_request, updated.UserRequest.FETCH) + self.assertTrue(helper.ready_event.is_set()) + helper.ready_event.clear() + helper._control_request('status') + self.assertEqual(helper.user_request, updated.UserRequest.FETCH) + self.assertFalse(helper.ready_event.is_set()) + + +if __name__ == '__main__': + unittest.main() diff --git a/openpilot/starpilot/software/update_control.py b/openpilot/starpilot/software/update_control.py new file mode 100644 index 0000000000..9a411a5b1e --- /dev/null +++ b/openpilot/starpilot/software/update_control.py @@ -0,0 +1,185 @@ +"""Same-user, process-bound local control for the running updater.""" + +from __future__ import annotations + +import os +import re +import socket +import struct +import threading +from collections.abc import Callable +from pathlib import Path + + +TIMEOUT = 0.5 +MAX_COMMAND = 256 +_BRANCH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z") +_COMMIT = re.compile(r"[0-9a-f]{40}\Z") +_CREDENTIALS = struct.Struct('3i') + + +class UpdaterControlError(RuntimeError): + pass + + +def process_start(pid: int) -> int: + try: + raw = (Path('/proc') / str(pid) / 'stat').read_text() + fields = raw[raw.rindex(')') + 2:].split() + start = int(fields[19]) + if start <= 0: + raise ValueError + return start + except (OSError, ValueError, IndexError): + raise UpdaterControlError('Updater process identity is unavailable') from None + + +def _address(pid: int, start: int, uid: int) -> str: + if type(pid) is not int or type(start) is not int or pid <= 1 or start <= 0: + raise UpdaterControlError('Invalid updater identity') + return f'\0starpilot-updater-v1-{uid}-{pid}-{start}' + + +def _peer(connection: socket.socket) -> tuple[int, int]: + if not hasattr(socket, 'SO_PEERCRED'): + raise UpdaterControlError('Peer credentials are unavailable') + pid, uid, _ = _CREDENTIALS.unpack(connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _CREDENTIALS.size)) + return pid, uid + + +def _line(connection: socket.socket) -> bytes: + data = bytearray() + while len(data) < MAX_COMMAND: + chunk = connection.recv(1) + if not chunk: + break + data.extend(chunk) + if chunk == b'\n': + return bytes(data) + raise UpdaterControlError('Invalid updater control message') + + +def _exchange(pid: int, start: int, command: bytes) -> None: + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection: + connection.settimeout(TIMEOUT) + connection.connect(_address(pid, start, os.geteuid())) + if _peer(connection) != (pid, os.geteuid()) or process_start(pid) != start: + raise UpdaterControlError('Updater process changed') + connection.sendall(command + b'\n') + if _line(connection) != b'ok\n': + raise UpdaterControlError('Updater declined control request') + except (OSError, TimeoutError): + raise UpdaterControlError('Updater control is unavailable') from None + + +def available(pid: int, start: int) -> bool: + try: + _exchange(pid, start, b'status') + return True + except UpdaterControlError: + return False + + +def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes: + if action == 'version' and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit): + base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1] + return f'version {base} {selected_commit}'.encode('ascii') + if selected_commit is not None: + raise UpdaterControlError('Invalid updater version target') + if action in ('check', 'download') and branch is None and commit is None: + return action.encode('ascii') + if (action in ('update', 'fast', 'rollback', 'versions') and type(branch) is str and type(commit) is str and + _BRANCH.fullmatch(branch) and _COMMIT.fullmatch(commit)): + return f'{action} {branch} {commit}'.encode('ascii') + raise UpdaterControlError('Invalid updater control action') + + +def _parse(command: bytes): + if command in (b'status\n', b'check\n', b'download\n'): + return command[:-1].decode('ascii'), None, None + try: + parts = command[:-1].decode('ascii').split(' ') + if len(parts) == 4 and parts[0] == 'version' and command.endswith(b'\n'): + action, branch, commit, selected = parts + if _command(action, branch, commit, selected) + b'\n' == command: + return action, branch, commit, selected + action, branch, commit = parts + except (UnicodeError, ValueError): + raise UpdaterControlError('Invalid updater control message') from None + if not command.endswith(b'\n') or action not in ('update', 'fast', 'rollback', 'versions') or _command(action, branch, commit) + b'\n' != command: + raise UpdaterControlError('Invalid updater control message') + return action, branch, commit + + +def send(pid: int, start: int, action: str, *, branch=None, commit=None, selected_commit=None) -> None: + _exchange(pid, start, _command(action, branch, commit, selected_commit)) + + +class UpdaterControlServer: + def __init__(self, request: Callable[..., None]): + self.request = request + self.socket: socket.socket | None = None + self.thread: threading.Thread | None = None + self.stopped = threading.Event() + + def start(self) -> None: + if self.socket is not None: + raise UpdaterControlError('Updater control already started') + pid = os.getpid() + address = _address(pid, process_start(pid), os.geteuid()) + listener = None + try: + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.settimeout(TIMEOUT) + listener.bind(address) + listener.listen(4) + except OSError: + if listener is not None: + listener.close() + raise UpdaterControlError('Updater control cannot listen') from None + self.socket = listener + self.thread = threading.Thread(target=self._run, args=(listener,), name='updater-control', daemon=True) + self.thread.start() + + def _run(self, listener: socket.socket) -> None: + while not self.stopped.is_set(): + try: + connection, _ = listener.accept() + except TimeoutError: + continue + except OSError: + break + with connection: + connection.settimeout(TIMEOUT) + try: + _, uid = _peer(connection) + command = _line(connection) + if uid != os.geteuid(): + connection.sendall(b'error\n') + else: + parsed = _parse(command) + action, branch, commit = parsed[:3] + if action == 'version': + self.request(action, branch=branch, commit=commit, selected_commit=parsed[3]) + elif action in ('update', 'fast', 'rollback', 'versions'): + self.request(action, branch=branch, commit=commit) + elif action != 'status': + self.request(action) + connection.sendall(b'ok\n') + except (RuntimeError, ValueError): + try: + connection.sendall(b'error\n') + except OSError: + pass + except OSError: + pass + + def close(self) -> None: + self.stopped.set() + if self.socket is not None: + self.socket.close() + self.socket = None + if self.thread is not None: + self.thread.join(timeout=TIMEOUT * 2) + self.thread = None