Skip to content

Commit 2a74de1

Browse files
committed
Release 1.1.0
2 parents 1c84e28 + 67bd0ec commit 2a74de1

File tree

18 files changed

+984
-231
lines changed

18 files changed

+984
-231
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
*~
22
__pycache__
33
*.pyc
4+
dist
45
docs/_build
56
bof.log
7+
examples/*/drafts

README.md

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,33 @@ buildings and people and that BOF must be used carefully.**
2727
[![GitHub license](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://github.com/Orange-Cyberdefense/bof/blob/master/LICENSE)
2828
[![GitHub release](https://img.shields.io/github/release/Orange-Cyberdefense/bof.svg)](https://gitHub.com/Orange-Cyberdefense/bof/releases/)
2929

30-
Getting started
31-
---------------
30+
Install
31+
-------
32+
33+
### From PyPI
34+
35+
```
36+
pip install boiboite-opener-framework
37+
```
38+
39+
https://pypi.org/project/boiboite-opener-framework/
40+
41+
### Manual install
3242

3343
```
3444
git clone https://github.com/Orange-Cyberdefense/bof.git
3545
```
3646

37-
Requirements (Protocol implementations use
38-
[Scapy](https://scapy.readthedocs.io/en/latest/)'s format): ``` pip install
39-
scapy ```
47+
Install requirements with:
48+
49+
```
50+
pip install -r requirements.txt
51+
```
52+
53+
Protocol implementations use [Scapy](https://scapy.readthedocs.io/en/latest/)'s format.
54+
55+
Getting started
56+
---------------
4057

4158
BOF is a Python 3.6+ library that should be imported in scripts. It has no
4259
installer yet so you need to refer to the `bof` subdirectory which contains the

bof/layers/knx/knx_feature.py

Lines changed: 173 additions & 198 deletions
Large diffs are not rendered by default.

bof/layers/knx/knx_messages.py

Lines changed: 353 additions & 0 deletions
Large diffs are not rendered by default.

bof/layers/knx/knx_network.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class KNXnet(UDP):
3939
4040
..seealso:: Details on data exchange: **KNX Standard v2.1 - 03_03_04**.
4141
"""
42+
sequence_counter = None
43+
4244
def connect(self, ip:str, port:int=3671) -> object:
4345
"""Connect to a KNX device (opens socket). Default port is ``3671``.
4446
@@ -48,6 +50,7 @@ def connect(self, ip:str, port:int=3671) -> object:
4850
:raises BOFNetworkError: if connection fails.
4951
"""
5052
super().connect(ip, port)
53+
self.sequence_counter = 0
5154
return self
5255

5356
def send(self, data:object, address:tuple=None) -> int:

bof/layers/knx/knx_packet.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
ACPI = type('ACPI', (object,),
4444
{to_property(v):k for k,v in scapy_knx.KNX_ACPI_CODES.items()})()
4545

46+
CONNECTION_TYPE_CODES = type('CONNECTION_TYPE_CODES', (object,),
47+
{to_property(v):k for k,v in scapy_knx.CONNECTION_TYPE_CODES.items()})()
48+
CEMI_OBJECT_TYPES = type('CEMI_OBJECT_TYPES', (object,),
49+
{to_property(v):k for k,v in scapy_knx.CEMI_OBJECT_TYPES.items()})()
50+
51+
CEMI_PROPERTIES = type('CEMI_PROPERTIES', (object,),
52+
{to_property(v):k for k,v in scapy_knx.CEMI_PROPERTIES.items()})()
53+
4654
TYPE_FIELD = "service_identifier"
4755
CEMI_FIELD = "cemi"
4856

bof/layers/raw_scapy/knx.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
# KNX Standard v2.1 - 03_08_04
8888
MESSAGE_CODES = {
8989
0x11: "L_Data.req",
90+
0x29: "L_data.ind",
9091
0x2e: "L_Data.con",
9192
0xFC: "M_PropRead.req",
9293
0xFB: "M_PropRead.con",
@@ -122,6 +123,11 @@
122123
0x03D5: "PropValueRead"
123124
}
124125

126+
KNX_SERVICE_CODES = {
127+
0x00: "Connect",
128+
0x01: "Disconnect"
129+
}
130+
125131
CEMI_OBJECT_TYPES = {
126132
0: "DEVICE",
127133
11: "IP PARAMETER_OBJECT"
@@ -352,10 +358,13 @@ class LcEMI(Packet):
352358
BitEnumField("sequence_type", 0, 1, {
353359
0: "unnumbered"
354360
}),
355-
BitField("reserved2", 0, 4),
356-
BitEnumField("acpi", 2, 4, KNX_ACPI_CODES),
361+
BitField("sequence_number", 0, 4), # Not used when sequence_type = unnumbered
362+
ConditionalField(BitEnumField("acpi", 2, 4, KNX_ACPI_CODES),
363+
lambda pkt:pkt.packet_type==0),
364+
ConditionalField(BitEnumField("service", 0, 2, KNX_SERVICE_CODES),
365+
lambda pkt:pkt.packet_type==1),
357366
ConditionalField(BitField("data", 0, 6),
358-
lambda pkt:pkt.packet_type==0)
367+
lambda pkt:pkt.packet_type==0),
359368
]
360369

361370

bof/network.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,24 @@
2828
import asyncio
2929
from ipaddress import ip_address, IPv4Address
3030
from concurrent import futures
31-
from socket import AF_INET, gaierror
31+
from socket import AF_INET, SOCK_DGRAM, IPPROTO_IP, IP_MULTICAST_TTL, \
32+
SOL_SOCKET, SO_BROADCAST
33+
from socket import socket, timeout as sotimeout, gaierror
34+
from struct import pack
3235
# Internal
3336
from .base import BOFNetworkError, BOFProgrammingError, log
3437

38+
###############################################################################
39+
# Global network-related constants and functions #
40+
###############################################################################
41+
42+
def IS_IP(ip: str):
43+
"""Check that ip is a valid IPv4 address."""
44+
try:
45+
ip_address(ip)
46+
except ValueError:
47+
raise BOFProgrammingError("Invalid IP {0}".format(ip)) from None
48+
3549
###############################################################################
3650
# Asyncio classes for UDP and TCP #
3751
###############################################################################
@@ -182,7 +196,7 @@ def _handle_exception(self, loop:object, context) -> None:
182196
.. seealso:: bof.base.BOFNetworkError"""
183197
message = context if isinstance(context, str) else context.get("exception", context["message"])
184198
log("Exception occurred: {0}".format(message), "ERROR")
185-
self.disconnect()
199+
# self.disconnect()
186200
raise BOFNetworkError(message) from None
187201

188202
def _receive(self, data:bytes, address:tuple) -> None:
@@ -196,6 +210,28 @@ def _receive(self, data:bytes, address:tuple) -> None:
196210
log("Queue is full", "ERROR")
197211
raise BOFNetworkError("Queue is full")
198212

213+
def _argument_check(data:bytes, address:tuple) -> None:
214+
"""Check that parameters to send ``data`` to an ``address`` are valid.
215+
If so, they are changed to appropriate format for sockets.
216+
217+
:param data: Raw byte array or string to send.
218+
:param address: Remote network address with format tuple ``(ip, port)``.
219+
:returns: data, address
220+
:raises BOFNetworkError: If either parameter is invalid.
221+
"""
222+
try:
223+
if isinstance(data, str):
224+
data = data.encode('utf-8')
225+
else:
226+
data = bytes(data)
227+
except TypeError:
228+
raise BOFProgrammingError("Invalid data type (must be bytes).") from None
229+
try:
230+
address = str(ip_address(address[0])), address[1]
231+
except (ValueError, TypeError):
232+
raise BOFProgrammingError("Invalid address {0}".format(address)) from None
233+
return data, address
234+
199235
#-------------------------------------------------------------------------#
200236
# Private #
201237
#-------------------------------------------------------------------------#
@@ -215,6 +251,13 @@ async def __listen_once(self, timeout:float=1.0) -> (bytes, tuple):
215251
# Properties #
216252
#-------------------------------------------------------------------------#
217253

254+
@property
255+
def is_connected(self):
256+
"""Returns true if a connection has been established.
257+
Relies on the values of _socket and _transport to find out.
258+
"""
259+
return True if self._socket and self._transport else False
260+
218261
@property
219262
def transport(self):
220263
"""Get transport object depending on the protocol.
@@ -266,8 +309,81 @@ class UDP(_Transport):
266309
"""
267310

268311
#-------------------------------------------------------------------------#
269-
# Public #
312+
# Static #
313+
#-------------------------------------------------------------------------#
314+
315+
@staticmethod
316+
def multicast(data:bytes, address:tuple, timeout:float=1.0) -> list:
317+
"""Sends a multicast request to specified ip address and port (UDP).
318+
319+
Expects devices subscribed to the address to respond and return
320+
responses as a list of frames with their source. Opens its own socket.
321+
322+
:param data: Raw byte array or string to send.
323+
:param address: Remote network address with format tuple ``(ip, port)``.
324+
:param timeout: Time out value in seconds, as a float (default is 1.0s).
325+
:returns: A list of tuples with format ``(response, (ip, port))``.
326+
:raises BOFNetworkError: If multicast parameters are invalid.
327+
328+
Example::
329+
330+
devices = UDP.multicast(b'\x06\x10...', ('224.0.23.12', 3671))
331+
"""
332+
responses = []
333+
ttl = pack('b', 1)
334+
data, address = UDP._argument_check(data, address)
335+
try:
336+
sock = socket(AF_INET, SOCK_DGRAM)
337+
sock.settimeout(timeout)
338+
sock.setsockopt(IPPROTO_IP, IP_MULTICAST_TTL, ttl)
339+
sock.sendto(data, address)
340+
while True:
341+
response, sender = sock.recvfrom(1024)
342+
responses.append((response, sender))
343+
except OverflowError as exc: # Raised when port invalid
344+
sock.close()
345+
raise BOFProgrammingError(str(exc))
346+
except sotimeout as te:
347+
pass
348+
sock.close()
349+
return responses
350+
351+
@staticmethod
352+
def broadcast(data:bytes, address:tuple, timeout:float=1.0) -> list:
353+
"""Broadcasts a request and waits for responses from devices (UDP).
354+
355+
:param data: Raw byte array or string to send.
356+
:param address: Remote network address with format tuple ``(ip, port)``.
357+
:param timeout: Time out value in seconds, as a float (default is 1.0s).
358+
:returns: A list of tuples with format ``(response, (ip, port))``.
359+
:raises BOFNetworkError: If multicast parameters are invalid.
360+
361+
Example::
362+
363+
devices = UDP.broadcast(b'\x06\x10...', ('192.168.1.255', 3671))
364+
"""
365+
responses = []
366+
data, address = UDP._argument_check(data, address)
367+
# Broadcast request
368+
try:
369+
sock = socket(AF_INET, SOCK_DGRAM)
370+
sock.settimeout(timeout)
371+
sock.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
372+
sock.sendto(data, address)
373+
while True:
374+
response, sender = sock.recvfrom(1024)
375+
responses.append((response, sender))
376+
except OverflowError as exc: # Raised when port invalid
377+
sock.close()
378+
raise BOFProgrammingError(str(exc))
379+
except sotimeout as te:
380+
pass
381+
sock.close()
382+
return responses
383+
270384
#-------------------------------------------------------------------------#
385+
# Public #
386+
#-------------------------------------------------------------------------#
271387

272388
def connect(self, ip:str, port:int) -> object:
273389
"""Initialize asynchronous connection using UDP on ``ip``:``port``.

bof/packet.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ def fields(self) -> list:
203203
"""
204204
return [field for field, parent in self._field_generator()]
205205

206+
@property
207+
def length(self) -> int:
208+
"""Returns the length of the packet (number of bytes)."""
209+
return len(self._scapy_pkt)
210+
206211
#-------------------------------------------------------------------------#
207212
# Public #
208213
#-------------------------------------------------------------------------#
@@ -326,7 +331,10 @@ def _field_generator(self, start_packet:object=None, terminal=False) -> tuple:
326331
elif isinstance(field, ConditionalField) and field._evalcond(packet):
327332
field = field.fld
328333
if isinstance(field, PacketField) or isinstance(field, Packet):
329-
yield from self._field_generator(getattr(packet, field.name))
334+
pkt = getattr(packet, field.name)
335+
# if pkt = None, next call restarts at start_packet (1st line)
336+
# and causes infinite loop, so we replace with empty packet.
337+
yield from self._field_generator(pkt if pkt else Packet())
330338
if isinstance(field, Field):
331339
yield field, start_packet
332340

docs/layers.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ KNX
2424
:undoc-members:
2525
:show-inheritance:
2626

27+
.. automodule:: bof.layers.knx.knx_messages
28+
:members:
29+
:undoc-members:
30+
:show-inheritance:
31+
2732
.. automodule:: bof.layers.knx.knx_feature
2833
:members:
2934
:undoc-members:

0 commit comments

Comments
 (0)