mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-10 10:13:45 +08:00
Fix offline GPU firmware and Connect streaming
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# WebRTC ICE compatibility
|
||||
|
||||
The August 24, 2026 AGNOS update (`05140149fb`) replaced the aiortc backend
|
||||
with libdatachannel. `libdatachannel-py==2026.1.0.dev2` embeds libdatachannel
|
||||
v0.24.0 and libjuice revision `5948a4162d37bc213d6051b67ee2876ccc5a99a6`.
|
||||
|
||||
That libjuice revision uses a zero ICE tie-breaker to mean "role attribute
|
||||
absent". A present `ICE-CONTROLLING` attribute containing zero is consequently
|
||||
rejected with STUN `400 Bad Request`. This was captured on the device during
|
||||
Connect attempts: its outgoing checks succeed, but it rejects the browser's
|
||||
nominating checks. It remains at ICE Connected, never completes DTLS, and
|
||||
Connect eventually reports that no direct peer-to-peer routes were found.
|
||||
|
||||
The patch records attribute presence separately from the 64-bit value. It does
|
||||
not change credentials, integrity checks, DTLS, camera handling, Sentry, media
|
||||
codecs, or data-channel behavior. It also continues to reject missing roles and
|
||||
rejects requests containing both role attributes.
|
||||
|
||||
## Build and verify
|
||||
|
||||
On the target architecture, with git, uv, a C/C++ compiler and Python 3.12:
|
||||
|
||||
```sh
|
||||
bash tools/webrtc/build_libdatachannel.sh /absolute/path/to/wheels /usr/bin/python3.12
|
||||
```
|
||||
|
||||
This builds `2026.1.0.dev2+starpilot.ice1` in an isolated temporary environment
|
||||
and runs real loopback UDP checks. Source and build directories are retained
|
||||
for inspection. It does not install into the device's runtime environment.
|
||||
|
||||
To test an installed runtime independently:
|
||||
|
||||
```sh
|
||||
/usr/local/venv/bin/python tools/webrtc/check_ice.py
|
||||
```
|
||||
|
||||
The original wheel fails the zero-tie-breaker case; a corrected wheel must pass
|
||||
all cases, including invalid-authentication rejection. A successful loopback
|
||||
check is not proof that a Connect video session works; verify that separately.
|
||||
|
||||
## Deployment
|
||||
|
||||
The correction is in a compiled dependency, not just openpilot Python source.
|
||||
A git pull alone does not replace the affected AGNOS library. Install the
|
||||
validated, architecture-matching wheel into the image's managed Python
|
||||
environment and run the check above when building a release image. Existing
|
||||
devices need that runtime update too. Back up the original package and its
|
||||
distribution metadata before a temporary device-side installation; a later
|
||||
AGNOS image or dependency sync can otherwise overwrite the hotfix.
|
||||
|
||||
AGNOS normally mounts the OS read-only. For a reversible live test, the
|
||||
corrected extension can be bind-mounted read-only over the original extension
|
||||
file. This leaves the OS partition unchanged, but the test hotfix is lost on
|
||||
reboot. Do not confuse that with deploying a corrected release image.
|
||||
|
||||
Upstream sources: [Python bindings](https://github.com/shiguredo/libdatachannel-py/tree/989d29a32968046a002b5b9deb7a00f5012c530c),
|
||||
[libjuice](https://github.com/paullouisageneau/libjuice/tree/5948a4162d37bc213d6051b67ee2876ccc5a99a6).
|
||||
The libjuice source modification is covered by its MPL-2.0 license.
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the existing WebRTC backend with the ICE role-presence correction.
|
||||
# Requires git, uv, a C/C++ compiler, and Python 3.12+. Does not install anything
|
||||
# into the running device environment. The output wheel is architecture-specific.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PATCH_DIR="$SCRIPT_DIR/patches"
|
||||
OUTPUT_DIR="${1:?usage: bash tools/webrtc/build_libdatachannel.sh OUTPUT_DIR [PYTHON]}"
|
||||
BUILD_PYTHON="${2:-python3.12}"
|
||||
SOURCE_REV="989d29a32968046a002b5b9deb7a00f5012c530c"
|
||||
BUILD_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/starpilot-webrtc.XXXXXX")"
|
||||
echo "Build directory (retained for inspection): $BUILD_ROOT"
|
||||
mkdir -p -- "$OUTPUT_DIR"
|
||||
OUTPUT_DIR="$(cd -- "$OUTPUT_DIR" && pwd)"
|
||||
|
||||
git clone --depth 1 --branch 2026.1.0.dev2 https://github.com/shiguredo/libdatachannel-py.git "$BUILD_ROOT/source"
|
||||
[[ "$(git -C "$BUILD_ROOT/source" rev-parse HEAD)" == "$SOURCE_REV" ]]
|
||||
git -C "$BUILD_ROOT/source" apply "$PATCH_DIR/libdatachannel-build.patch"
|
||||
cp "$PATCH_DIR/libjuice-zero-tiebreaker.patch" "$BUILD_ROOT/source/"
|
||||
|
||||
uv venv --python "$BUILD_PYTHON" "$BUILD_ROOT/venv"
|
||||
uv pip install --python "$BUILD_ROOT/venv/bin/python" \
|
||||
build==1.6.0 scikit-build-core==1.0.3 nanobind==3.0.1 cmake==4.4.3 ninja==1.13.2
|
||||
export PATH="$BUILD_ROOT/venv/bin:$PATH"
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-2}"
|
||||
cd -- "$BUILD_ROOT/source"
|
||||
python -m build --wheel --no-isolation --outdir "$OUTPUT_DIR"
|
||||
uv pip install --python "$BUILD_ROOT/venv/bin/python" --no-index --find-links "$OUTPUT_DIR" \
|
||||
'libdatachannel-py==2026.1.0.dev2+starpilot.ice1'
|
||||
python "$SCRIPT_DIR/check_ice.py"
|
||||
echo "Validated wheel written to $OUTPUT_DIR; the running environment was not modified."
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the installed WebRTC library's ICE role handling over real UDP sockets.
|
||||
|
||||
No cameras, device parameters, external servers, or live sessions are used.
|
||||
Run this with the same Python environment as webrtcd when validating an image.
|
||||
"""
|
||||
|
||||
import binascii
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from libdatachannel import Configuration, Description, PeerConnection
|
||||
|
||||
|
||||
def attribute(kind: int, value: bytes) -> bytes:
|
||||
return struct.pack("!HH", kind, len(value)) + value + bytes(-len(value) % 4)
|
||||
|
||||
|
||||
def binding_request(username: str, password: str, roles: list[tuple[int, int]]) -> tuple[bytes, bytes]:
|
||||
transaction = secrets.token_bytes(12)
|
||||
attrs = attribute(0x0006, username.encode())
|
||||
# Match Chromium's attribute ordering, including its optional network info.
|
||||
attrs += attribute(0xC057, bytes(4))
|
||||
for role, tiebreaker in roles:
|
||||
attrs += attribute(role, struct.pack("!Q", tiebreaker))
|
||||
attrs += attribute(0x0025, b"") + attribute(0x0024, struct.pack("!I", 1853824767))
|
||||
def header(size: int) -> bytes:
|
||||
return struct.pack("!HHI12s", 1, size, 0x2112A442, transaction)
|
||||
|
||||
integrity = hmac.new(password.encode(), header(len(attrs) + 24) + attrs, hashlib.sha1).digest()
|
||||
attrs += attribute(0x0008, integrity)
|
||||
packet = header(len(attrs) + 8) + attrs
|
||||
packet += attribute(0x8028, struct.pack("!I", binascii.crc32(packet) ^ 0x5354554E))
|
||||
return packet, transaction
|
||||
|
||||
|
||||
def sdp_value(sdp: str, name: str) -> str:
|
||||
prefix = f"a={name}:"
|
||||
return next(line[len(prefix):] for line in sdp.splitlines() if line.startswith(prefix))
|
||||
|
||||
|
||||
def check_binding(roles: list[tuple[int, int]], *, invalid_password: bool = False) -> int | None:
|
||||
config = Configuration()
|
||||
config.bind_address = "127.0.0.1"
|
||||
config.disable_auto_negotiation = True
|
||||
offerer = PeerConnection(config)
|
||||
answerer = PeerConnection(config)
|
||||
channel = offerer.create_data_channel("data")
|
||||
try:
|
||||
offerer.set_local_description(Description.Type.Offer)
|
||||
offer = str(offerer.local_description())
|
||||
# Only our probe sends checks; do not start an independent connection.
|
||||
offer = "\r\n".join(line for line in offer.splitlines() if not line.startswith("a=candidate:")) + "\r\n"
|
||||
answerer.set_remote_description(Description(offer, Description.Type.Offer))
|
||||
answerer.set_local_description(Description.Type.Answer)
|
||||
deadline = time.monotonic() + 2
|
||||
while answerer.gathering_state() != PeerConnection.GatheringState.Complete:
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("Loopback candidate gathering timed out")
|
||||
time.sleep(0.01)
|
||||
answer = str(answerer.local_description())
|
||||
candidate = sdp_value(answer, "candidate").split()
|
||||
username = f"{sdp_value(answer, 'ice-ufrag')}:{sdp_value(offer, 'ice-ufrag')}"
|
||||
password = "invalid-test-password" if invalid_password else sdp_value(answer, "ice-pwd")
|
||||
packet, transaction = binding_request(username, password, roles)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.settimeout(1)
|
||||
sock.sendto(packet, (candidate[4], int(candidate[5])))
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
response = sock.recv(2048)
|
||||
except TimeoutError:
|
||||
return None
|
||||
if len(response) < 20 or response[8:20] != transaction:
|
||||
continue
|
||||
if response[:2] == b"\x01\x01":
|
||||
return 200
|
||||
offset = 20
|
||||
while offset + 4 <= len(response):
|
||||
kind, size = struct.unpack_from("!HH", response, offset)
|
||||
value = response[offset + 4:offset + 4 + size]
|
||||
if kind == 0x0009 and len(value) >= 4:
|
||||
return value[2] * 100 + value[3]
|
||||
offset += 4 + (size + 3) // 4 * 4
|
||||
raise AssertionError("Unexpected ICE response")
|
||||
return None
|
||||
finally:
|
||||
answerer.close()
|
||||
offerer.close()
|
||||
# Keep the wrapper alive until after its connection has closed.
|
||||
assert channel is not None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
cases = [
|
||||
("nonzero controlling tiebreaker", [(0x802A, 123)], False, 200),
|
||||
("zero controlling tiebreaker", [(0x802A, 0)], False, 200),
|
||||
("missing role rejected", [], False, 400),
|
||||
("both roles rejected", [(0x802A, 123), (0x8029, 456)], False, 400),
|
||||
("invalid authentication rejected", [(0x802A, 0)], True, None),
|
||||
]
|
||||
failed = False
|
||||
for name, roles, invalid_password, expected in cases:
|
||||
actual = check_binding(roles, invalid_password=invalid_password)
|
||||
passed = actual == expected
|
||||
print(f"{'PASS' if passed else 'FAIL'}: {name}: expected {expected}, got {actual}", flush=True)
|
||||
failed |= not passed
|
||||
raise SystemExit(int(failed))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index ba0479b..04a5134 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -122,6 +122,7 @@ else()
|
||||
GIT_SHALLOW TRUE
|
||||
GIT_SUBMODULES_RECURSE TRUE
|
||||
SOURCE_DIR ${LIBDATACHANNEL_SOURCE_DIR}
|
||||
+ PATCH_COMMAND git -C ${LIBDATACHANNEL_SOURCE_DIR}/deps/libjuice apply ${PROJECT_ROOT}/libjuice-zero-tiebreaker.patch
|
||||
BINARY_DIR ${LIBDATACHANNEL_BUILD_DIR}
|
||||
STAMP_DIR ${LIBDATACHANNEL_BUILD_DIR}/stamp
|
||||
TMP_DIR ${LIBDATACHANNEL_BUILD_DIR}/tmp
|
||||
diff --git a/VERSION b/VERSION
|
||||
index 7655152..2270f25 100644
|
||||
--- a/VERSION
|
||||
+++ b/VERSION
|
||||
@@ -1 +1 @@
|
||||
-2026.1.0.dev2
|
||||
\ No newline at end of file
|
||||
+2026.1.0.dev2+starpilot.ice1
|
||||
@@ -0,0 +1,99 @@
|
||||
diff --git a/src/agent.c b/src/agent.c
|
||||
index 00bbecb..40819eb 100644
|
||||
--- a/src/agent.c
|
||||
+++ b/src/agent.c
|
||||
@@ -1431,7 +1431,7 @@ int agent_process_stun_binding(juice_agent_t *agent, const stun_message_t *msg,
|
||||
return -1;
|
||||
|
||||
ice_candidate_pair_t *pair = entry->pair;
|
||||
- if (msg->ice_controlling == msg->ice_controlled) {
|
||||
+ if (msg->has_ice_controlling == msg->has_ice_controlled) {
|
||||
JLOG_WARN("Controlling and controlled attributes mismatch in request");
|
||||
agent_send_stun_binding(agent, entry, STUN_CLASS_RESP_ERROR, 400, msg->transaction_id,
|
||||
NULL);
|
||||
@@ -1445,7 +1445,7 @@ int agent_process_stun_binding(juice_agent_t *agent, const stun_message_t *msg,
|
||||
// ERROR-CODE attribute with a value of 487 (Role Conflict) but retains its role.
|
||||
// * If the agent's tiebreaker value is less than the contents of the ICE-CONTROLLING
|
||||
// attribute, the agent switches to the controlled role.
|
||||
- if (agent->mode == AGENT_MODE_CONTROLLING && msg->ice_controlling) {
|
||||
+ if (agent->mode == AGENT_MODE_CONTROLLING && msg->has_ice_controlling) {
|
||||
JLOG_WARN("ICE role conflict (both controlling)");
|
||||
if (agent->ice_tiebreaker >= msg->ice_controlling) {
|
||||
JLOG_DEBUG("Asking remote peer to switch roles");
|
||||
@@ -1465,7 +1465,7 @@ int agent_process_stun_binding(juice_agent_t *agent, const stun_message_t *msg,
|
||||
// * If the agent's tiebreaker value is less than the contents of the ICE-CONTROLLED
|
||||
// attribute, the agent generates a Binding error response and includes an ERROR-CODE
|
||||
// attribute with a value of 487 (Role Conflict) but retains its role.
|
||||
- if (agent->mode == AGENT_MODE_CONTROLLED && msg->ice_controlled) {
|
||||
+ if (agent->mode == AGENT_MODE_CONTROLLED && msg->has_ice_controlled) {
|
||||
JLOG_WARN("ICE role conflict (both controlled)");
|
||||
if (agent->ice_tiebreaker >= msg->ice_controlling) {
|
||||
JLOG_DEBUG("Switching to controlling role");
|
||||
@@ -1479,7 +1479,7 @@ int agent_process_stun_binding(juice_agent_t *agent, const stun_message_t *msg,
|
||||
break;
|
||||
}
|
||||
if (msg->use_candidate) {
|
||||
- if (!msg->ice_controlling) {
|
||||
+ if (!msg->has_ice_controlling) {
|
||||
JLOG_WARN("STUN message use_candidate missing ice_controlling attribute");
|
||||
agent_send_stun_binding(agent, entry, STUN_CLASS_RESP_ERROR, 400,
|
||||
msg->transaction_id, NULL);
|
||||
@@ -1709,6 +1709,8 @@ int agent_send_stun_binding(juice_agent_t *agent, agent_stun_entry_t *entry, stu
|
||||
password = agent->remote.ice_pwd;
|
||||
msg.ice_controlling = agent->mode == AGENT_MODE_CONTROLLING ? agent->ice_tiebreaker : 0;
|
||||
msg.ice_controlled = agent->mode == AGENT_MODE_CONTROLLED ? agent->ice_tiebreaker : 0;
|
||||
+ msg.has_ice_controlling = agent->mode == AGENT_MODE_CONTROLLING;
|
||||
+ msg.has_ice_controlled = agent->mode == AGENT_MODE_CONTROLLED;
|
||||
|
||||
// RFC 8445 7.1.1. PRIORITY
|
||||
// The PRIORITY attribute MUST be included in a Binding request and be set to the value
|
||||
diff --git a/src/stun.c b/src/stun.c
|
||||
index 842b6c1..06e90ec 100644
|
||||
--- a/src/stun.c
|
||||
+++ b/src/stun.c
|
||||
@@ -157,14 +157,14 @@ int stun_write(void *buf, size_t size, const stun_message_t *msg, const char *pa
|
||||
goto overflow;
|
||||
pos += len;
|
||||
}
|
||||
- if (msg->ice_controlling) {
|
||||
+ if (msg->has_ice_controlling || msg->ice_controlling) {
|
||||
uint64_t ice_controlling = htonll(msg->ice_controlling);
|
||||
len = stun_write_attr(pos, end - pos, STUN_ATTR_ICE_CONTROLLING, &ice_controlling, 8);
|
||||
if (len <= 0)
|
||||
goto overflow;
|
||||
pos += len;
|
||||
}
|
||||
- if (msg->ice_controlled) {
|
||||
+ if (msg->has_ice_controlled || msg->ice_controlled) {
|
||||
uint64_t ice_controlled = htonll(msg->ice_controlled);
|
||||
len = stun_write_attr(pos, end - pos, STUN_ATTR_ICE_CONTROLLED, &ice_controlled, 8);
|
||||
if (len <= 0)
|
||||
@@ -916,6 +916,7 @@ int stun_read_attr(const void *data, size_t size, stun_message_t *msg, uint8_t *
|
||||
}
|
||||
uint32_t *value32 = (uint32_t *)attr->value;
|
||||
msg->ice_controlling = ((uint64_t)ntohl(value32[0]) << 32) | ntohl(value32[1]);
|
||||
+ msg->has_ice_controlling = true;
|
||||
break;
|
||||
}
|
||||
case STUN_ATTR_ICE_CONTROLLED: {
|
||||
@@ -926,6 +927,7 @@ int stun_read_attr(const void *data, size_t size, stun_message_t *msg, uint8_t *
|
||||
}
|
||||
uint32_t *value32 = (uint32_t *)attr->value;
|
||||
msg->ice_controlled = ((uint64_t)ntohl(value32[0]) << 32) | ntohl(value32[1]);
|
||||
+ msg->has_ice_controlled = true;
|
||||
break;
|
||||
}
|
||||
case STUN_ATTR_CHANNEL_NUMBER: {
|
||||
diff --git a/src/stun.h b/src/stun.h
|
||||
index 398bfb0..61f4246 100644
|
||||
--- a/src/stun.h
|
||||
+++ b/src/stun.h
|
||||
@@ -319,6 +319,8 @@ typedef struct stun_message {
|
||||
uint32_t priority;
|
||||
uint64_t ice_controlling;
|
||||
uint64_t ice_controlled;
|
||||
+ bool has_ice_controlling;
|
||||
+ bool has_ice_controlled;
|
||||
bool use_candidate;
|
||||
addr_record_t mapped;
|
||||
|
||||
Reference in New Issue
Block a user