iotsploit-protocols 0.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ """One-shot SocketCAN I/O: open, do exactly one thing, close.
2
+
3
+ This is deliberately not a driver. ``iotsploit_drivers.socketcan`` owns
4
+ discovery, link lifecycle, and a long-lived streaming socket, and it stays that
5
+ way -- borrowing it here would either drag privileged link mutation into a send
6
+ path or add a second mutable "current device" to race against. SocketCAN allows
7
+ several sockets on one interface, so a client here and the driver's monitor
8
+ coexist without contending for the bus.
9
+
10
+ Two prohibitions are absolute, and they are why this module is small:
11
+
12
+ *It never changes host networking.* No ``sudo``, no ``ip link``, no bitrate, no
13
+ listen-only, no bringing an interface up. A link is configured outside
14
+ IoTSploit; this opens one that is already up or fails saying so. A tool that
15
+ quietly reconfigures an interface to make its own call succeed has changed the
16
+ vehicle's bus to suit itself.
17
+
18
+ *It never retries.* One request puts at most one frame on the wire. A retry
19
+ loop around a send that may have already reached an ECU is how one confirmed
20
+ action becomes several unconfirmed ones.
21
+
22
+ ``python-can`` is imported inside the functions that need it, not at module
23
+ scope. It opens platform sockets and reads host configuration on import, and a
24
+ preview must work on a host with no CAN interface at all.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import time
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Callable, Iterator, Optional
34
+
35
+ from iotsploit_protocols.canbus.definitions import EncodedFrame
36
+ from iotsploit_protocols.errors import NotConfigured, ProtocolError
37
+
38
+ #: Linux caps an interface name at IFNAMSIZ-1 and forbids whitespace and '/'.
39
+ #: Checked before opening because python-can reports a bad name as a generic
40
+ #: OSError, which reads as "the bus is down" rather than "that is not a name".
41
+ #: ``\Z`` rather than ``$``: ``$`` also matches just before a trailing
42
+ #: newline, so "can0\n" would pass as a valid name.
43
+ _CHANNEL_RE = re.compile(r"\A[A-Za-z0-9_.:-]{1,15}\Z")
44
+
45
+ #: What builds the underlying bus. Injected in tests so the deterministic suite
46
+ #: never opens can0, vcan0, or a socket of any kind.
47
+ BusFactory = Callable[..., Any]
48
+
49
+
50
+ class CanTransportError(ProtocolError):
51
+ """The local socket refused the frame.
52
+
53
+ Never means an ECU did or did not receive anything. It means this host's
54
+ CAN stack would not accept the message for transmission, which is a
55
+ different claim entirely and is the only one a sender can honestly make.
56
+ """
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SocketCanConfig:
61
+ """Where to send, and how long to wait for the socket to take it.
62
+
63
+ ``channel`` is the kernel interface name (``can0``), never the device
64
+ driver's own id (``can_001``). They look similar in a UI and only one of
65
+ them can be opened.
66
+ """
67
+
68
+ channel: str
69
+ timeout: float = 1.0
70
+ #: Whether to open the socket in FD mode. Set from the frame, not guessed:
71
+ #: a classic socket rejects a 16-byte payload.
72
+ fd: bool = False
73
+
74
+ def __post_init__(self) -> None:
75
+ if not isinstance(self.channel, str) or not _CHANNEL_RE.match(self.channel):
76
+ raise NotConfigured(
77
+ f"{self.channel!r} is not a usable SocketCAN interface name; "
78
+ "expected something like 'can0' or 'vcan0'"
79
+ )
80
+ if self.timeout is None or self.timeout <= 0:
81
+ raise NotConfigured(f"send timeout must be positive, not {self.timeout!r}")
82
+
83
+
84
+ def _default_bus_factory(**kwargs: Any) -> Any:
85
+ import can # imported here: see the module docstring
86
+
87
+ return can.Bus(**kwargs)
88
+
89
+
90
+ def _build_message(frame: EncodedFrame) -> Any:
91
+ import can
92
+
93
+ return can.Message(
94
+ arbitration_id=frame.frame_id,
95
+ is_extended_id=frame.is_extended,
96
+ is_fd=frame.is_fd,
97
+ data=frame.data,
98
+ # check=True makes python-can validate the id against the flag and the
99
+ # payload length against the frame type before anything reaches the
100
+ # kernel, so a mismatch is a stated error rather than a silent
101
+ # truncation on the wire.
102
+ check=True,
103
+ )
104
+
105
+
106
+ class SocketCanClient:
107
+ """A socket that exists for the duration of one send.
108
+
109
+ Used as a context manager so the socket closes on every path, including the
110
+ one where the send raised. A leaked SocketCAN socket keeps receiving into a
111
+ kernel buffer nobody drains.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ config: SocketCanConfig,
117
+ *,
118
+ bus_factory: Optional[BusFactory] = None,
119
+ ) -> None:
120
+ self.config = config
121
+ self._bus_factory = bus_factory or _default_bus_factory
122
+ self._bus: Any = None
123
+
124
+ def __enter__(self) -> "SocketCanClient":
125
+ self.open()
126
+ return self
127
+
128
+ def __exit__(self, *exc_info: Any) -> None:
129
+ self.close()
130
+
131
+ def open(self) -> None:
132
+ if self._bus is not None:
133
+ return
134
+ try:
135
+ self._bus = self._bus_factory(
136
+ interface="socketcan",
137
+ channel=self.config.channel,
138
+ fd=self.config.fd,
139
+ # The host's can.conf must not be able to redirect this to
140
+ # another interface or quietly supply a bitrate.
141
+ ignore_config=True,
142
+ )
143
+ except Exception as error:
144
+ raise CanTransportError(
145
+ f"cannot open SocketCAN interface {self.config.channel!r}: {error}. "
146
+ "The interface has to exist and be up before sending; "
147
+ "IoTSploit does not configure it."
148
+ ) from error
149
+
150
+ def close(self) -> None:
151
+ bus, self._bus = self._bus, None
152
+ if bus is None:
153
+ return
154
+ try:
155
+ bus.shutdown()
156
+ except Exception: # noqa: BLE001 - a failed close must not mask the result
157
+ pass
158
+
159
+ def send(self, frame: EncodedFrame) -> None:
160
+ """Put exactly one frame on the wire.
161
+
162
+ Returning normally means the local socket accepted the frame. It does
163
+ not mean an ECU received it, acted on it, or even that anything was
164
+ listening -- and no caller may report otherwise.
165
+ """
166
+ if self._bus is None:
167
+ self.open()
168
+
169
+ try:
170
+ message = _build_message(frame)
171
+ except (ValueError, TypeError) as error:
172
+ # check=True validates the id against the extended flag and the
173
+ # payload length against the frame type. It raises from the
174
+ # constructor rather than from send(), so catching only around the
175
+ # send would let this escape as a bare ValueError.
176
+ raise CanTransportError(
177
+ f"frame 0x{frame.frame_id:X} cannot be represented on a CAN bus: {error}"
178
+ ) from error
179
+
180
+ try:
181
+ self._bus.send(message, timeout=self.config.timeout)
182
+ except Exception as error:
183
+ raise CanTransportError(
184
+ f"SocketCAN refused frame 0x{frame.frame_id:X} on "
185
+ f"{self.config.channel!r}: {error}"
186
+ ) from error
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class CaptureBudget:
191
+ """When a capture stops, stated two ways because either can run out first.
192
+
193
+ A capture with no budget is a leak: it runs inside a worker, and an
194
+ observation scope needs a run that ends in order to mean anything. The
195
+ frame budget is the one that saves you on a busy bus, where thirty seconds
196
+ is millions of frames; the duration is the one that saves you on a silent
197
+ bus, where the frame budget would never be reached.
198
+ """
199
+
200
+ duration_s: float = 30.0
201
+ max_frames: int = 200_000
202
+
203
+ def __post_init__(self) -> None:
204
+ if self.duration_s <= 0:
205
+ raise NotConfigured(f"capture duration must be positive, not {self.duration_s!r}")
206
+ if self.max_frames <= 0:
207
+ raise NotConfigured(f"frame budget must be positive, not {self.max_frames!r}")
208
+
209
+
210
+ class SocketCanReceiver:
211
+ """A read-only socket that yields frames until its budget runs out.
212
+
213
+ There is no send path here, not even a disabled one. What this cannot do is
214
+ part of what it is.
215
+
216
+ It opens its own socket rather than borrowing the streaming driver's. That
217
+ is not duplication for its own sake: SocketCAN permits several sockets on
218
+ one interface, so a capture neither disturbs nor depends on the driver's
219
+ monitor, and neither one's lifetime is tied to the other's.
220
+
221
+ Note for anyone reading this next to the hardware: a CAN controller in
222
+ normal mode **acknowledges frames it receives, in silicon**. Attaching an
223
+ interface to a live bus is therefore not electrically inert, whatever this
224
+ software does or does not send. Listen-only is host link configuration
225
+ (``ip link set can0 type can listen-only on``) and this class must not set
226
+ it, for the same reason the sender must not set a bitrate.
227
+ """
228
+
229
+ def __init__(
230
+ self,
231
+ config: SocketCanConfig,
232
+ *,
233
+ bus_factory: Optional[BusFactory] = None,
234
+ clock: Optional[Callable[[], float]] = None,
235
+ ) -> None:
236
+ self.config = config
237
+ self._bus_factory = bus_factory or _default_bus_factory
238
+ self._clock = clock or time.monotonic
239
+ self._bus: Any = None
240
+ self._stop = False
241
+
242
+ def __enter__(self) -> "SocketCanReceiver":
243
+ self.open()
244
+ return self
245
+
246
+ def __exit__(self, *exc_info: Any) -> None:
247
+ self.close()
248
+
249
+ def open(self) -> None:
250
+ if self._bus is not None:
251
+ return
252
+ try:
253
+ self._bus = self._bus_factory(
254
+ interface="socketcan",
255
+ channel=self.config.channel,
256
+ fd=self.config.fd,
257
+ ignore_config=True,
258
+ )
259
+ except Exception as error:
260
+ raise CanTransportError(
261
+ f"cannot open SocketCAN interface {self.config.channel!r} for capture: "
262
+ f"{error}. The interface has to exist and be up; IoTSploit does not "
263
+ "configure it."
264
+ ) from error
265
+
266
+ def close(self) -> None:
267
+ bus, self._bus = self._bus, None
268
+ if bus is None:
269
+ return
270
+ try:
271
+ bus.shutdown()
272
+ except Exception: # noqa: BLE001 - a failed close must not mask the capture
273
+ pass
274
+
275
+ def stop(self) -> None:
276
+ """Ask the loop to finish at its next opportunity."""
277
+ self._stop = True
278
+
279
+ def frames(
280
+ self,
281
+ budget: CaptureBudget,
282
+ *,
283
+ check_cancelled: Optional[Callable[[], None]] = None,
284
+ ) -> Iterator[Any]:
285
+ """Yield received messages until the budget is spent.
286
+
287
+ The socket is closed on every exit path -- normal end, exception, and
288
+ the consumer abandoning the generator -- because a leaked SocketCAN
289
+ socket keeps filling a kernel buffer that nobody drains.
290
+ """
291
+ if self._bus is None:
292
+ self.open()
293
+
294
+ started = self._clock()
295
+ deadline = started + budget.duration_s
296
+ delivered = 0
297
+ try:
298
+ while not self._stop and delivered < budget.max_frames:
299
+ remaining = deadline - self._clock()
300
+ if remaining <= 0:
301
+ break
302
+ # Bounded by whichever is sooner, so a silent bus still wakes up
303
+ # to notice its own deadline instead of blocking past it.
304
+ try:
305
+ message = self._bus.recv(timeout=min(remaining, 0.25))
306
+ except Exception as error:
307
+ # SocketCAN binds to an interface that is down and only says
308
+ # so at the first read, so a capture that opened cleanly
309
+ # still fails here. python-can spells that CanOperationError,
310
+ # which is not an OSError: unwrapped it escapes every
311
+ # caller's except clause and reaches the operator as a crash.
312
+ raise CanTransportError(
313
+ f"capture on {self.config.channel!r} failed: {error}. The "
314
+ "interface has to stay up for the whole sample; IoTSploit "
315
+ "does not configure it."
316
+ ) from error
317
+ if check_cancelled is not None:
318
+ check_cancelled()
319
+ if message is None:
320
+ continue
321
+ delivered += 1
322
+ yield message
323
+ finally:
324
+ self.close()
325
+
326
+
327
+ #: ``ARPHRD_CAN`` from ``linux/if_arp.h``. What marks a net device as a CAN
328
+ #: interface rather than an Ethernet one.
329
+ ARPHRD_CAN = 280
330
+
331
+
332
+ @dataclass(frozen=True)
333
+ class CanInterface:
334
+ """A CAN interface this host has, as sysfs describes it."""
335
+
336
+ name: str
337
+ is_up: bool
338
+ #: ``vcan``/``vxcan`` devices carry no transceiver, so nothing an operator
339
+ #: does on one can reach a vehicle. Worth saying out loud before a capture.
340
+ is_virtual: bool
341
+
342
+ @property
343
+ def label(self) -> str:
344
+ state = "up" if self.is_up else "down"
345
+ kind = "virtual" if self.is_virtual else "hardware"
346
+ return f"{self.name} ({kind}, {state})"
347
+
348
+
349
+ def list_can_interfaces(sysfs_root: str = "/sys/class/net") -> "list[CanInterface]":
350
+ """Every CAN interface on this host, newest kernel truth, read-only.
351
+
352
+ Reads sysfs rather than shelling out to ``ip`` or asking the device driver.
353
+ That keeps it unprivileged, keeps it free of the driver's lifecycle -- the
354
+ plugins must not initialize or connect a device merely to list one -- and
355
+ means it works when the driver has never been touched.
356
+
357
+ An interface that is *down* is still listed. Hiding it would leave an
358
+ operator wondering why the interface they can see in ``ip link`` is absent
359
+ here; saying "down" tells them what to fix.
360
+ """
361
+ root = Path(sysfs_root)
362
+ found: list[CanInterface] = []
363
+ try:
364
+ entries = sorted(root.iterdir())
365
+ except OSError:
366
+ return found
367
+
368
+ for entry in entries:
369
+ try:
370
+ if int((entry / "type").read_text().strip()) != ARPHRD_CAN:
371
+ continue
372
+ operstate = (entry / "operstate").read_text().strip()
373
+ except (OSError, ValueError):
374
+ continue
375
+ found.append(
376
+ CanInterface(
377
+ name=entry.name,
378
+ # vcan reports "unknown" rather than "up"; treat anything that
379
+ # is not explicitly "down" as usable and let the open fail
380
+ # honestly if it is not.
381
+ is_up=operstate != "down",
382
+ is_virtual=entry.name.startswith(("vcan", "vxcan")),
383
+ )
384
+ )
385
+ return found
@@ -0,0 +1,22 @@
1
+ """DoIP transport and UDS diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "FACET_KEY",
7
+ "DoipClient",
8
+ "DoipConfig",
9
+ "DoipFacet",
10
+ "DoipUdsClient",
11
+ "RoutingActivationFailed",
12
+ "UdsClient",
13
+ "UdsResponse",
14
+ ]
15
+
16
+ from iotsploit_protocols.doip.client import (
17
+ DoipClient,
18
+ DoipConfig,
19
+ RoutingActivationFailed,
20
+ )
21
+ from iotsploit_protocols.doip.facet import FACET_KEY, DoipFacet
22
+ from iotsploit_protocols.doip.uds import DoipUdsClient, UdsClient, UdsResponse
@@ -0,0 +1,267 @@
1
+ """A DoIP connection: routing activation, framing, one diagnostic exchange.
2
+
3
+ Replaces the transport half of the old ``DoIP_Mgr``. The differences that
4
+ matter, each of them a defect in what came before:
5
+
6
+ **Framing follows the length field.** The old code read exactly 13 bytes, slept
7
+ half a second, then read 2048 more. TCP guarantees neither that one ``recv`` is
8
+ one message nor that an acknowledgement arrives alone, so a coalesced or split
9
+ segment silently desynchronized the stream and every subsequent response was
10
+ attributed to the wrong request. Here the 8-byte header is read, its length
11
+ field is believed, and exactly that many bytes follow.
12
+
13
+ **One client is one connection to one ECU.** The old module exported a
14
+ process-wide singleton, so two ECUs could not be addressed at once and a
15
+ half-open socket poisoned every later caller.
16
+
17
+ **Nothing here knows about a vehicle.** No default host, no NIC name, no sudo,
18
+ no interactive prompt. A caller supplies a config; an unconfigured target fails
19
+ loudly rather than probing whatever used to live at a hardcoded address.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ import socket
26
+ from dataclasses import dataclass
27
+ from typing import Optional
28
+
29
+ from iotsploit_protocols.errors import NotConfigured, ProtocolError
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ #: protocol_version, inverse_version, payload_type, payload_length.
34
+ HEADER_LEN = 8
35
+
36
+ #: ISO 13400-2 version 0x02, followed by its bitwise inverse. The pair is how a
37
+ #: receiver rejects a stream that is not DoIP at all.
38
+ PROTOCOL_VERSION = b"\x02\xfd"
39
+
40
+ #: The payload types this client uses. ISO 13400-2.
41
+ PT_GENERIC_NACK = 0x0000
42
+ PT_ROUTING_ACTIVATION_REQUEST = 0x0005
43
+ PT_ROUTING_ACTIVATION_RESPONSE = 0x0006
44
+ PT_ALIVE_CHECK_REQUEST = 0x0007
45
+ PT_ALIVE_CHECK_RESPONSE = 0x0008
46
+ PT_DIAGNOSTIC_MESSAGE = 0x8001
47
+ PT_DIAGNOSTIC_ACK = 0x8002
48
+ PT_DIAGNOSTIC_NACK = 0x8003
49
+
50
+ #: Routing activation response codes worth naming. 0x10 is the only success.
51
+ ROUTING_ACTIVATION_OK = 0x10
52
+ ROUTING_ACTIVATION_CODES = {
53
+ 0x00: "unknown source address",
54
+ 0x01: "all sockets registered and active",
55
+ 0x02: "source address does not match",
56
+ 0x03: "source address already registered",
57
+ 0x04: "missing authentication",
58
+ 0x05: "rejected confirmation",
59
+ 0x06: "unsupported activation type",
60
+ 0x10: "success",
61
+ }
62
+
63
+
64
+ class RoutingActivationFailed(ProtocolError):
65
+ """The gateway refused to route for us. Nothing else can work until it does."""
66
+
67
+ def __init__(self, code: int) -> None:
68
+ self.code = code
69
+ name = ROUTING_ACTIVATION_CODES.get(code, "unknown")
70
+ super().__init__(f"routing activation refused: 0x{code:02X} ({name})")
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DoipConfig:
75
+ """Where to connect, and which ECU to address.
76
+
77
+ ``host`` has no default on purpose. The address it replaces was a function
78
+ default naming one vehicle's gateway, so an unconfigured bench quietly
79
+ probed that address and reported its silence as a result.
80
+ """
81
+
82
+ host: str
83
+ logical_address: int
84
+ port: int = 13400
85
+ tester_address: int = 0x0E80
86
+ activation_type: int = 0x00
87
+ timeout: float = 5.0
88
+
89
+ def __post_init__(self) -> None:
90
+ if not self.host:
91
+ raise NotConfigured("DoIP host is required; there is no default")
92
+ if not 0 < self.port < 65536:
93
+ raise NotConfigured(f"DoIP port {self.port!r} is out of range")
94
+ for name in ("logical_address", "tester_address"):
95
+ value = getattr(self, name)
96
+ if not 0 <= value <= 0xFFFF:
97
+ raise NotConfigured(f"DoIP {name} 0x{value:X} is not a 16-bit address")
98
+
99
+
100
+ class DoipClient:
101
+ """One TCP connection to one DoIP entity.
102
+
103
+ Use as a context manager: routing activation happens on entry and the socket
104
+ is closed on exit even when a request raises.
105
+ """
106
+
107
+ def __init__(self, config: DoipConfig) -> None:
108
+ self.config = config
109
+ self._sock: Optional[socket.socket] = None
110
+
111
+ # ── lifecycle ─────────────────────────────────────────────────────────
112
+
113
+ def __enter__(self) -> "DoipClient":
114
+ self.connect()
115
+ return self
116
+
117
+ def __exit__(self, *exc_info: object) -> None:
118
+ self.close()
119
+
120
+ def connect(self) -> None:
121
+ """Open the socket and activate routing.
122
+
123
+ Routing activation is not optional politeness: until the gateway accepts
124
+ it, diagnostic messages are discarded, and the old code's habit of
125
+ pressing on regardless is why a refused activation looked like a silent
126
+ ECU.
127
+ """
128
+ if self._sock is not None:
129
+ return
130
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
131
+ sock.settimeout(self.config.timeout)
132
+ sock.connect((self.config.host, self.config.port))
133
+ self._sock = sock
134
+ logger.debug("DoIP connected to %s:%d", self.config.host, self.config.port)
135
+ try:
136
+ self._activate_routing()
137
+ except Exception:
138
+ self.close()
139
+ raise
140
+
141
+ def close(self) -> None:
142
+ if self._sock is None:
143
+ return
144
+ try:
145
+ self._sock.close()
146
+ except OSError:
147
+ logger.debug("DoIP socket close failed", exc_info=True)
148
+ finally:
149
+ self._sock = None
150
+
151
+ @property
152
+ def connected(self) -> bool:
153
+ return self._sock is not None
154
+
155
+ # ── exchanges ─────────────────────────────────────────────────────────
156
+
157
+ def request(self, payload: bytes) -> bytes:
158
+ """Send one UDS payload to the configured ECU and return the response.
159
+
160
+ The positive/negative acknowledgement DoIP interposes (0x8002 / 0x8003)
161
+ is consumed here rather than handed upward: it says the *gateway* took
162
+ the message, which is not an answer to the diagnostic request and was
163
+ the source of the old code's magic offsets.
164
+ """
165
+ self._send(PT_DIAGNOSTIC_MESSAGE, self._addresses() + payload)
166
+ return self.read()
167
+
168
+ def read(self) -> bytes:
169
+ """The next diagnostic response, sending nothing.
170
+
171
+ Used by the UDS layer after a responsePending: the ECU will send the
172
+ real answer by itself, and re-sending the request would perform it
173
+ twice.
174
+ """
175
+ while True:
176
+ payload_type, body = self._read_message()
177
+
178
+ if payload_type == PT_DIAGNOSTIC_ACK:
179
+ logger.debug("DoIP ack")
180
+ continue
181
+ if payload_type == PT_DIAGNOSTIC_NACK:
182
+ code = body[4] if len(body) > 4 else -1
183
+ raise ProtocolError(f"DoIP refused the diagnostic message: 0x{code:02X}")
184
+ if payload_type == PT_ALIVE_CHECK_REQUEST:
185
+ # Answering keeps the socket registered. Ignoring it, as the old
186
+ # code did, gets the connection dropped mid-session.
187
+ self._send(PT_ALIVE_CHECK_RESPONSE, self._addresses()[:2])
188
+ continue
189
+ if payload_type == PT_GENERIC_NACK:
190
+ code = body[0] if body else -1
191
+ raise ProtocolError(f"DoIP header negative acknowledge: 0x{code:02X}")
192
+ if payload_type == PT_DIAGNOSTIC_MESSAGE:
193
+ # source(2) + target(2) then the UDS payload.
194
+ return body[4:]
195
+
196
+ logger.debug("DoIP ignoring payload type 0x%04X", payload_type)
197
+
198
+ # ── internals ─────────────────────────────────────────────────────────
199
+
200
+ def _addresses(self) -> bytes:
201
+ return self.config.tester_address.to_bytes(2, "big") + self.config.logical_address.to_bytes(
202
+ 2, "big"
203
+ )
204
+
205
+ def _activate_routing(self) -> None:
206
+ request = (
207
+ self.config.tester_address.to_bytes(2, "big")
208
+ + bytes([self.config.activation_type])
209
+ + b"\x00\x00\x00\x00"
210
+ )
211
+ self._send(PT_ROUTING_ACTIVATION_REQUEST, request)
212
+
213
+ payload_type, body = self._read_message()
214
+ if payload_type != PT_ROUTING_ACTIVATION_RESPONSE:
215
+ raise ProtocolError(
216
+ f"expected routing activation response, got payload type 0x{payload_type:04X}"
217
+ )
218
+ if len(body) < 5:
219
+ raise ProtocolError(f"routing activation response truncated: {len(body)} bytes")
220
+ code = body[4]
221
+ if code != ROUTING_ACTIVATION_OK:
222
+ raise RoutingActivationFailed(code)
223
+ logger.debug("DoIP routing activated for tester 0x%04X", self.config.tester_address)
224
+
225
+ def _require_socket(self) -> socket.socket:
226
+ if self._sock is None:
227
+ raise ProtocolError("DoIP client is not connected; use it as a context manager")
228
+ return self._sock
229
+
230
+ def _send(self, payload_type: int, body: bytes) -> None:
231
+ """Header plus body.
232
+
233
+ Built by hand rather than through scapy's ``DoIP`` layer, which declares
234
+ source_address/target_address as *conditional* fields for some payload
235
+ types: handing it a body that already contains those addresses encodes
236
+ them twice, once zeroed. The header is four fields and the parser here
237
+ already reads it directly, so building it directly keeps one definition
238
+ of the format instead of two that must agree.
239
+ """
240
+ header = (
241
+ PROTOCOL_VERSION
242
+ + payload_type.to_bytes(2, "big")
243
+ + len(body).to_bytes(4, "big")
244
+ )
245
+ self._require_socket().sendall(header + body)
246
+
247
+ def _read_message(self) -> tuple[int, bytes]:
248
+ """One whole DoIP message: header, then exactly what it declares."""
249
+ header = self._read_exactly(HEADER_LEN)
250
+ payload_type = int.from_bytes(header[2:4], "big")
251
+ declared = int.from_bytes(header[4:HEADER_LEN], "big")
252
+ if declared > 0xFFFF:
253
+ # A length this large is a desynchronized stream, not a real message.
254
+ raise ProtocolError(f"implausible DoIP payload length {declared}")
255
+ return payload_type, self._read_exactly(declared) if declared else b""
256
+
257
+ def _read_exactly(self, count: int) -> bytes:
258
+ sock = self._require_socket()
259
+ chunks = []
260
+ remaining = count
261
+ while remaining > 0:
262
+ chunk = sock.recv(remaining)
263
+ if not chunk:
264
+ raise ProtocolError(f"DoIP peer closed after {count - remaining} of {count} bytes")
265
+ chunks.append(chunk)
266
+ remaining -= len(chunk)
267
+ return b"".join(chunks)