SnowSignal 0.1.1__py3-none-any.whl → 0.1.4.2__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.
snowsignal/configure.py CHANGED
@@ -20,12 +20,13 @@ class ConfigArgs(NamedTuple):
20
20
  mesh_port: int
21
21
  other_relays: list[str]
22
22
  log_level: str
23
+ decode_pvaccess: bool
23
24
 
24
25
 
25
26
  def configure(argv: Sequence[str] | None = None) -> ConfigArgs:
26
27
  """Setup configuration for the SnowSignal service"""
27
28
 
28
- p = configargparse.ArgParser()
29
+ p = configargparse.ArgParser(prog="snowsignal")
29
30
  # Remember to add new arguments to the Args class above!
30
31
  p.add_argument(
31
32
  "-t",
@@ -66,6 +67,7 @@ def configure(argv: Sequence[str] | None = None) -> ConfigArgs:
66
67
  default="info",
67
68
  help="Logging level",
68
69
  )
70
+ p.add_argument("--decode-pvaccess", action="store_true", help="Attempt to decode and log to INFO pvaccess messages")
69
71
  # Remember to add new arguments to the Args class above!
70
72
 
71
73
  # config = p.parse_args(argv)
@@ -82,10 +84,12 @@ def configure(argv: Sequence[str] | None = None) -> ConfigArgs:
82
84
  loglevel = logging.INFO
83
85
  case "debug":
84
86
  loglevel = logging.DEBUG
87
+ case _:
88
+ loglevel = logging.WARNING
85
89
 
86
90
  if loglevel < logging.INFO:
87
91
  logging.basicConfig(
88
- format="%(asctime)s - %(levelname)s - " "%(name)s.%(funcName)s: %(message)s",
92
+ format="%(asctime)s - %(levelname)s - %(name)s.%(funcName)s: %(message)s",
89
93
  encoding="utf-8",
90
94
  level=loglevel,
91
95
  )
@@ -100,7 +104,7 @@ def configure(argv: Sequence[str] | None = None) -> ConfigArgs:
100
104
  "Broadcast port (%i) and mesh port (%i) may not be the same", config.broadcast_port, config.mesh_port
101
105
  )
102
106
  raise ValueError(
103
- f"Broadcast port ({config.broadcast_port}) and " f"mesh port ({config.mesh_port}) may not be the same"
107
+ f"Broadcast port ({config.broadcast_port}) and mesh port ({config.mesh_port}) may not be the same"
104
108
  )
105
109
 
106
110
  return config
snowsignal/dockerfile CHANGED
@@ -2,8 +2,10 @@
2
2
  # psutils requirement in requirements.txt
3
3
  FROM python:3.12-slim
4
4
 
5
+ ARG PIPARGS=
6
+
5
7
  # Token has read_api and read_repository permissions and so does not need to be kept secret
6
8
  # However it will expire
7
- RUN pip install SnowSignal --index-url https://gitlab.stfc.ac.uk/api/v4/projects/5671/packages/pypi/simple
9
+ RUN pip install SnowSignal ${PIPARGS} --no-cache-dir --index-url https://gitlab.stfc.ac.uk/api/v4/projects/5671/packages/pypi/simple
8
10
 
9
11
  CMD ["python", "-m", "snowsignal"]
snowsignal/netutils.py CHANGED
@@ -65,9 +65,7 @@ def get_from_iface(
65
65
  if snicaddr.family == family:
66
66
  return getattr(snicaddr, attribute)
67
67
 
68
- raise ResourceNotFoundException(
69
- f"Could not identify the {family}, " "{attribute} associated with interface {iface}"
70
- )
68
+ raise ResourceNotFoundException(f"Could not identify the {family}, {attribute} associated with interface {iface}")
71
69
 
72
70
 
73
71
  def get_localipv4_from_iface(iface: str) -> str:
snowsignal/packet.py CHANGED
@@ -48,6 +48,10 @@ class Packet:
48
48
  ip_chksum: int | None = None
49
49
  ip_src_addr: str | None = None
50
50
  ip_dst_addr: str | None = None
51
+ ip_length: int | None = None
52
+ ipv4_identification: int | None = None
53
+ ipv4_more_fragments: bool = False
54
+ ipv4_fragmented_offset: int | None = None
51
55
 
52
56
  udp_src_port: int | None = None
53
57
  udp_dst_port: int | None = None
@@ -93,6 +97,14 @@ class Packet:
93
97
  if self.ip_version != 4:
94
98
  return
95
99
 
100
+ self.ip_length = iph[2]
101
+ self.ipv4_identification = iph[3]
102
+
103
+ # Decode fragment data
104
+ fragment_flag_and_offset = iph[4]
105
+ self.ipv4_more_fragments = bool(fragment_flag_and_offset & 0x2000)
106
+ self.ipv4_fragmented_offset = fragment_flag_and_offset & 0x1FFF
107
+
96
108
  # Calculate the length (of the header?)
97
109
  ihl = version_ihl & 0xF
98
110
  self._iph_length = ihl * 4
@@ -172,3 +184,21 @@ class Packet:
172
184
  def change_ethernet_source(self, newmac) -> None:
173
185
  """Change packet Ethernet source to a new MAC address"""
174
186
  self.raw = self.raw[0:6] + newmac + self.raw[12:]
187
+
188
+ def is_ipv4_fragmented(self) -> bool:
189
+ """Check if the IPv4 packet is fragmented"""
190
+ # Check for IP packet fragmentation, only IPv4 packets can be fragmented
191
+ # https://en.wikipedia.org/wiki/IPv4#Fragmentation_and_reassembly
192
+ # If a fragment then the More Fragments flag is set True for the first fragment and subsequent flags until the
193
+ # last segment in which the More Fragments flag is False. However, the Fragment Offset is only zero
194
+ # in the first fragment.
195
+ # We can identify a last fragment by the More Fragments flag being False and the Fragment Offset being non-zero.
196
+ # A non-fragmented IP packet will have the More Fragments flag set False and the Fragment Offset equal to zero.
197
+ logger.debug(
198
+ "ip_version = %i, ipv4_more_fragments = %s, ipv4_fragmented_offset = %i",
199
+ self.ip_version,
200
+ self.ipv4_more_fragments,
201
+ self.ipv4_fragmented_offset,
202
+ )
203
+
204
+ return self.ip_version == 4 and (self.ipv4_more_fragments or self.ipv4_fragmented_offset != 0)
@@ -0,0 +1,394 @@
1
+ """
2
+ Decode and understand PVAccess Protocol packets
3
+ Protocol specification is available here: https://docs.epics-controls.org/en/latest/pv-access/protocol.html
4
+ """
5
+
6
+ import dataclasses
7
+ import logging
8
+ import socket
9
+ import struct
10
+ import traceback
11
+ from enum import Enum, IntEnum, unique
12
+ from struct import unpack
13
+
14
+ from .packet import BadPacketException, Packet
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @unique
20
+ class PVAccessMessageType(Enum):
21
+ """PVAccess Application Message Type"""
22
+
23
+ BEACON = 0x00
24
+ VALIDATION = 0x01
25
+ ECHO = 0x02
26
+ SEARCH_REQUEST = 0x03
27
+ SEARCH_RESPONSE = 0x04
28
+ CREATE_CHANNEL = 0x07
29
+ DESTROY_CHANNEL = 0x08
30
+ GET = 0x0A
31
+ PUT = 0x0B
32
+ PUTGET = 0x0C
33
+ MONITOR = 0x0D
34
+ ARRAY = 0x0E
35
+ DESTROY_REQUEST = 0x0F
36
+ CHANNEL_PROCESS = 0x10
37
+ GET_INTROSPECT = 0x11
38
+ MESSAGE = 0x12
39
+ CHANNEL_RPC = 0x14
40
+ CANCEL_REQUEST = 0x15
41
+
42
+
43
+ @unique
44
+ class Endianness(IntEnum):
45
+ """Endianness of message"""
46
+
47
+ LITTLEEND = 0
48
+ BIGEND = 1
49
+
50
+ def unpack_char(self) -> str:
51
+ """Convert endianness to a Python struct format string, see
52
+ https://docs.python.org/3/library/struct.html#format-strings"""
53
+ match self.value:
54
+ case self.LITTLEEND:
55
+ return "<"
56
+ case self.BIGEND:
57
+ return ">"
58
+ case _:
59
+ raise IndexError("Unknown / Impossible Endianness")
60
+
61
+
62
+ @dataclasses.dataclass
63
+ class PVAccessMessageHeader:
64
+ """PVAccess Message Header decoder
65
+
66
+ https://docs.epics-controls.org/en/latest/pv-access/Protocol-Messages.html#message-header
67
+ """
68
+
69
+ @unique
70
+ class MessageType(IntEnum):
71
+ """Message header is type application or control"""
72
+
73
+ APPLICATION = 0
74
+ CONTROL = 1
75
+
76
+ @unique
77
+ class Segmentation(Enum):
78
+ """Message is segmented, and what type of segment"""
79
+
80
+ NOT_SEGMENTED = 0
81
+ SEGMENT_START = 1
82
+ SEGMENT_MIDDLE = 2
83
+ SEGMENT_END = 3
84
+
85
+ @unique
86
+ class Role(IntEnum):
87
+ """Message is from a client or server"""
88
+
89
+ CLIENT = 0
90
+ SERVER = 1
91
+
92
+ raw: bytes # The raw original bytes of the header
93
+
94
+ magic: int # Must be 0xCA for PVAccess
95
+ version: int
96
+ msgtype: MessageType
97
+ segmented: Segmentation
98
+ role: Role
99
+ endian: Endianness
100
+ message_command: PVAccessMessageType
101
+ payload_size: int
102
+
103
+ def __init__(self, raw: bytes) -> None:
104
+ """Decode message header bytes"""
105
+
106
+ self.raw = raw
107
+
108
+ try:
109
+ msg_header = self.raw[0:8]
110
+
111
+ # Decode the first four bytes of the header
112
+ # This also serves as a loose confirmation that this is a PVAccess protocol message
113
+ # due to the magic bytes. Due to chance we'll only try to process mistakenly one
114
+ # in 256 times. We decode only the first four bytes initially because we need to
115
+ # know the endianness of the message
116
+ pvh = unpack("BBBB", msg_header[0:4])
117
+ if pvh[0] != 0xCA:
118
+ logger.debug("Magic bytes were %s instead of 0xCA", hex(pvh[0]))
119
+ raise BadPacketException(f"Magic bytes were {hex(pvh[0])} instead of 0xCA")
120
+
121
+ self.magic = pvh[0]
122
+ self.version = pvh[1]
123
+ self.message_command = PVAccessMessageType(pvh[3])
124
+
125
+ # Flags are packed in individual bits, or in one case a pair of bits, within a single byte
126
+ # We need to know the endianness to decode the integer that follows
127
+ flags = int(pvh[2])
128
+ self.msgtype = self.MessageType(flags & 1)
129
+ self.segmented = self.Segmentation((flags >> 4) & 0b11)
130
+ self.role = self.Role((flags >> 6) & 1)
131
+ self.endian = Endianness((flags >> 7) & 1)
132
+
133
+ # Payload size, here we need to know the endianness
134
+ pvhsize = unpack(f"{self.endian.unpack_char()}I", msg_header[4:8])
135
+ self.payload_size = pvhsize[0]
136
+
137
+ except Exception as e:
138
+ raise BadPacketException from e
139
+
140
+
141
+ def decode_pvaccess_size(payload: bytes, endianness: Endianness, start_byte: int = 0) -> tuple[int, int]:
142
+ """Decode a string or array size in the PVAccess Protocol format.
143
+
144
+ https://docs.epics-controls.org/en/latest/pv-access/Protocol-Encoding.html#sizes
145
+
146
+ We require a set of bytes to decode and optionally where in the bytes to start. This means that
147
+ part of a message payload starting at the size or the entire payload with a pointer to the start
148
+ of the size may be passed in. We then return a tuple of the size and a pointer to the byte after
149
+ the decode."""
150
+
151
+ endchar = endianness.unpack_char()
152
+
153
+ # Calculate the length of the string or array. Usually this is encoded in a single byte, but
154
+ # if the value of that byte is 254 then there is an integer which holds the size instead
155
+ sizedecode_byte = unpack("B", payload[start_byte : start_byte + 1])
156
+ bytes_for_decode = 1
157
+
158
+ if sizedecode_byte[0] == 254:
159
+ sizedecode_int = unpack(f"{endchar}I", payload[start_byte + 1 : start_byte + 5])
160
+
161
+ pvasize = sizedecode_int[0]
162
+ bytes_for_decode = 5
163
+ elif sizedecode_byte[0] == 255:
164
+ # I'm not sure why we're not using 0 to represent 0?
165
+ pvasize = 0
166
+ else:
167
+ pvasize = sizedecode_byte[0]
168
+
169
+ return (pvasize, start_byte + bytes_for_decode)
170
+
171
+
172
+ def decode_pvaccess_string(payload: bytes, endianness: Endianness, start_byte: int = 0) -> tuple[str, int]:
173
+ """
174
+ Decode a PVAccess Protocol string
175
+
176
+ https://docs.epics-controls.org/en/latest/pv-access/Protocol-Encoding.html#strings
177
+
178
+ This is basically an integer size specified in the usual PVAccess Protocol way
179
+ (see decode_pvaccess_size()) and then an array of UTF-8 bytes
180
+ """
181
+
182
+ # First get the length of the string
183
+ (pvastrlen, new_start_byte) = decode_pvaccess_size(payload, endianness, start_byte)
184
+
185
+ # Then get the string
186
+ pvastr_unpacked = unpack(f"!{pvastrlen}s", payload[new_start_byte : new_start_byte + pvastrlen])
187
+ pvastr = pvastr_unpacked[0].decode("utf8")
188
+
189
+ return (pvastr, new_start_byte + pvastrlen)
190
+
191
+
192
+ @dataclasses.dataclass
193
+ class PVAccessBeaconMessage:
194
+ """
195
+ PVAccess Beacon Message decode
196
+
197
+ https://docs.epics-controls.org/en/latest/pv-access/Protocol-Messages.html#cmd-beacon-0x00
198
+ """
199
+
200
+ raw: bytes
201
+
202
+ guid: str
203
+ flags: bytes
204
+ beacon_sequence_id: int
205
+ change_count: int
206
+ server_address: str # IPv4Address | IPv6Address
207
+ server_port: int
208
+ protocol: str
209
+
210
+ def __init__(self, raw: bytes, endianness: Endianness) -> None:
211
+ """Decode beacon message payload"""
212
+
213
+ self.raw = raw
214
+
215
+ try:
216
+ msg_payload = self.raw
217
+
218
+ endchar = endianness.unpack_char()
219
+ pbm = unpack(f"{endchar}12sBBH16sH", msg_payload[0:34])
220
+
221
+ self.guid = pbm[0].hex()
222
+ self.flags = pbm[1]
223
+ self.beacon_sequence_id = pbm[2]
224
+ self.change_count = pbm[3]
225
+ self.server_address = pbm[4].hex() # ip_address(pbm[4])
226
+ self.server_port = pbm[5]
227
+
228
+ (pvastr, _) = decode_pvaccess_string(msg_payload[34:], endianness)
229
+ self.protocol = pvastr
230
+ except Exception as e:
231
+ raise BadPacketException from e
232
+
233
+
234
+ @dataclasses.dataclass
235
+ class PVAccessSearchMessage:
236
+ """PVAccess Search Request Message decode
237
+
238
+ https://docs.epics-controls.org/en/latest/pv-access/Protocol-Messages.html#cmd-search-0x03
239
+ """
240
+
241
+ raw: bytes
242
+
243
+ search_sequence_id: int
244
+ flags: bytes
245
+ reponse_address: str # IPv4Address | IPv6Address
246
+ response_port: int
247
+ protocols: list[str]
248
+
249
+ @dataclasses.dataclass
250
+ class Channel:
251
+ search_instance_id: int
252
+ channelname: str
253
+
254
+ def __repr__(self) -> str:
255
+ return f"{self.search_instance_id} / {self.channelname}"
256
+
257
+ channels: list[Channel]
258
+
259
+ def __repr__(self) -> str:
260
+ return f"sid: {self.search_sequence_id} flags: {self.flags} raddr: {self.reponse_address} rport: {self.response_port} protos: {self.protocols}"
261
+
262
+ def __init__(self, raw: bytes, endianness: Endianness) -> None:
263
+ """Decode search message payload"""
264
+
265
+ self.raw = raw
266
+
267
+ try:
268
+ msg_payload = self.raw
269
+
270
+ endchar = endianness.unpack_char()
271
+ psm = unpack(f"{endchar}IB3x16sH", msg_payload[0:26])
272
+
273
+ self.search_sequence_id = psm[0]
274
+ self.flags = psm[1]
275
+ self.reponse_address = psm[2].hex() # ip_address(pbm[4])
276
+ self.response_port = psm[3]
277
+
278
+ # Decode the array of protocol strings
279
+ (protocol_strings_count, payload_pointer) = decode_pvaccess_size(msg_payload, endianness, 26)
280
+ self.protocols: list[str] = []
281
+ for x in range(protocol_strings_count):
282
+ (protocol_string, payload_pointer) = decode_pvaccess_string(msg_payload, endianness, payload_pointer)
283
+ self.protocols.append(protocol_string)
284
+
285
+ # Decode the array of channel searches
286
+ # Note that the spec uses a different size / count for this
287
+ cc_unpacked = unpack(f"{endchar}H", msg_payload[payload_pointer : payload_pointer + 2])
288
+ channels_count = cc_unpacked[0]
289
+ payload_pointer = payload_pointer + 2
290
+
291
+ # Get list of channels. This is an array of structs, where the structs are an integer identifier
292
+ # and a channel name string. We catch struct unpack exceptions because an earlier version of SnowSignal
293
+ # was rebroadcasting truncated strings
294
+ self.channels: list[PVAccessSearchMessage.Channel] = []
295
+ try:
296
+ for x in range(channels_count):
297
+ # Get search instance ID
298
+ chans_unpacked = unpack(f"{endchar}I", msg_payload[payload_pointer : payload_pointer + 4])
299
+ search_instance_id = chans_unpacked[0]
300
+ payload_pointer = payload_pointer + 4
301
+
302
+ # Get channelname string
303
+ (channame_string, payload_pointer) = decode_pvaccess_string(
304
+ msg_payload, endianness, payload_pointer
305
+ )
306
+
307
+ self.channels.append(self.Channel(search_instance_id, channame_string))
308
+ except struct.error as e:
309
+ logger.debug(
310
+ "Unexpected termination of search channel array, possible truncated packet or malformed channel count"
311
+ )
312
+ logger.debug(
313
+ "%s, channels_count: %i no_channels_found: %i channels_found: %s",
314
+ self,
315
+ channels_count,
316
+ len(self.channels),
317
+ self.channels,
318
+ )
319
+ raise BadPacketException from e
320
+
321
+ except Exception as e:
322
+ raise BadPacketException from e
323
+
324
+
325
+ def log_pvaccess(payload: bytes, packet_src_ip: str | None, source: str = "Rebroadcasting") -> None:
326
+ """Log details of a PVAccess message payload"""
327
+ # We trap all BadPacketExceptions because
328
+ try:
329
+ # Decode the PVAcccess Protocol message header
330
+ pvamgshdr = PVAccessMessageHeader(payload)
331
+
332
+ # Construct a string describing the source for the later log messages
333
+ if packet_src_ip:
334
+ try:
335
+ # Try to determine the name of the source machine, but such a thing may not exist
336
+ srcname = socket.gethostbyaddr(packet_src_ip)[0] if packet_src_ip else "Unknown"
337
+ srcstring = f"{packet_src_ip} --> {srcname}"
338
+ except socket.herror:
339
+ srcstring = f"{packet_src_ip}"
340
+ else:
341
+ srcstring = "Unspecified"
342
+
343
+ messagehdr_str = (
344
+ f"{source} {pvamgshdr.message_command.name} (v{pvamgshdr.version}) "
345
+ f"[Flags: {pvamgshdr.msgtype.name},{pvamgshdr.segmented.name},{pvamgshdr.role.name},{pvamgshdr.endian.name}] "
346
+ f"from {srcstring}"
347
+ )
348
+
349
+ pva_message = payload[8:]
350
+ match pvamgshdr.message_command:
351
+ case PVAccessMessageType.BEACON:
352
+ pvabeaconmsg = PVAccessBeaconMessage(pva_message, pvamgshdr.endian)
353
+ logger.info(
354
+ "%s: self-identifies as %s %s:%i;%s with update counters beacon:%i, PVs:%i",
355
+ messagehdr_str,
356
+ pvabeaconmsg.protocol,
357
+ pvabeaconmsg.server_address,
358
+ pvabeaconmsg.server_port,
359
+ pvabeaconmsg.guid,
360
+ pvabeaconmsg.beacon_sequence_id,
361
+ pvabeaconmsg.change_count,
362
+ )
363
+ case PVAccessMessageType.SEARCH_REQUEST:
364
+ # Seems to work for pvxs and Phoebus sources
365
+ pvasearchmsg = PVAccessSearchMessage(pva_message, pvamgshdr.endian)
366
+ logger.info(
367
+ "%s: self-identifies as %s:%i (seq id %i) with protocols %s searching for %s",
368
+ messagehdr_str,
369
+ pvasearchmsg.reponse_address,
370
+ pvasearchmsg.response_port,
371
+ pvasearchmsg.search_sequence_id,
372
+ pvasearchmsg.protocols,
373
+ pvasearchmsg.channels,
374
+ )
375
+ case _:
376
+ # Currently unsupported / unexpected
377
+ logger.info("Decode of %s message is currently unsupported", pvamgshdr.message_command.name)
378
+
379
+ except BadPacketException:
380
+ # Ignore packets we can't decode
381
+ logger.info("Packet not decoded; invalid or malformed PVAccess Protocol?")
382
+ logger.info("Bad PVAccess packet from %s : %s", packet_src_ip, payload)
383
+ logging.error(traceback.format_exc())
384
+
385
+
386
+ def log_pvaccess_packet(packet: Packet) -> None:
387
+ """Details of a PVAccess message packet"""
388
+ # Check packet is minimum length to support a PVAccess protocol header
389
+ if packet.udp_length and packet.udp_length >= 8:
390
+ payload = packet.get_udp_payload()
391
+ packet_src_ip = packet.ip_src_addr
392
+ log_pvaccess(payload, packet_src_ip, "Received")
393
+ else:
394
+ logger.debug("Received from %s payload that was not PVAccess Protocol message", packet.ip_src_addr)
snowsignal/snowsignal.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """SnowSignal - UDP Broadcast Relay"""
2
2
 
3
3
  import asyncio
4
+ import importlib.metadata
4
5
  import ipaddress
5
6
  import logging
6
7
  import os
@@ -89,7 +90,10 @@ async def main(argv: Sequence[str] | None = None, loop_forever: bool = True):
89
90
 
90
91
  # Configure this relay
91
92
  config = configure(argv)
92
- logger.info("Starting with configuration %s", config)
93
+ try:
94
+ logger.info("Starting version %s with configuration %s", importlib.metadata.version("snowsignal"), config)
95
+ except importlib.metadata.PackageNotFoundError:
96
+ logger.info("Starting local version with configuration %s", config)
93
97
 
94
98
  # Get the local IP address
95
99
  # TODO: Properly support IPv6
@@ -116,7 +120,7 @@ async def main(argv: Sequence[str] | None = None, loop_forever: bool = True):
116
120
  # Loop forever, but if in swarm mode periodically recheck the relays
117
121
  while loop_forever:
118
122
  await asyncio.sleep(10)
119
- if swarmmode:
123
+ if swarmmode and not config.other_relays:
120
124
  # Check to see if remote relays have changed
121
125
  # e.g. containers have restarted
122
126
  udp_relay_transmit.set_remote_relays(discover_relays())
@@ -16,6 +16,8 @@ from typing import Any
16
16
 
17
17
  from .configure import ConfigArgs
18
18
  from .netutils import get_broadcast_from_iface, get_macaddress_from_iface
19
+ from .packet import BadPacketException, Packet
20
+ from .pva_packet import log_pvaccess
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
@@ -38,8 +40,10 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
38
40
 
39
41
  if config:
40
42
  self._iface = config.target_interface
43
+ self._decode_pvaccess = config.decode_pvaccess
41
44
  else:
42
45
  self._iface = "eth0"
46
+ self._decode_pvaccess = False
43
47
 
44
48
  # Assume the MAC address is immutable
45
49
  self._mac = get_macaddress_from_iface(self._iface)
@@ -64,6 +68,7 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
64
68
  """Calculate UDP checksum, using the IP and UDP parts of the packet,
65
69
  and change the existing packet UDP checksum with the newly calculcated
66
70
  checksum"""
71
+ logger.debug("Recalculating UDP checksum")
67
72
 
68
73
  # The UDP checksum algorithm is defined in RFC768
69
74
  # https://www.rfc-editor.org/rfc/rfc768.txt
@@ -117,7 +122,7 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
117
122
  return ip_packet
118
123
 
119
124
  def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
120
- """Receive a UDP message and forward it to the remote relays"""
125
+ """Receive a UDP message and forward it any listeners on our local broadcast network segment"""
121
126
  logger.debug(
122
127
  "Received from %s for rebroadcast on port %i message: %r",
123
128
  addr,
@@ -126,19 +131,24 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
126
131
  )
127
132
 
128
133
  # Simple verification of the received payload, and remove the bytes
129
- # confirming that this is for us
134
+ # confirming that this is for us. We also remove the ethernet frame as the
135
+ # sendto() below will take care of that part
130
136
  if data[0:2] == b"SS":
131
137
  data = data[2:]
132
138
  else:
133
139
  logger.debug("Malformed packet received")
134
140
  return
135
141
 
136
- # TODO: Apply any filters
142
+ # Check if this is a UDP packet and if it is recalculate the UDP checksum
143
+ try:
144
+ packet = Packet(data)
145
+ packet.decode_ip()
146
+ packet.decode_udp()
147
+ data = self.recalculate_udp_checksum(data[14:])
148
+ except BadPacketException:
149
+ pass
137
150
 
138
- # We can't use the data as is for some reason but need to recalculate the
139
- # UDP checksum. We also remove the ethernet frame as the sendto() below
140
- # will take care of that part
141
- data = self.recalculate_udp_checksum(data[14:])
151
+ # TODO: Apply any filters
142
152
 
143
153
  # TODO: The code above does not change the IP source address
144
154
  # If we're on a different network segment then we should switch the
@@ -146,7 +156,7 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
146
156
  # then require recomputing checksums. Note: is this required? We
147
157
  # are sending to the broadcast address in the sendto() below
148
158
 
149
- # TODO: Logic to validate what we're receiving as a PVAccess message
159
+ # TODO: Logic to validate what we're receiving a PVAccess message
150
160
  # Note that although doing the validation on receipt means we're doing
151
161
  # it for every relay (instead of once if we did it on send), it's much
152
162
  # safer to do it on receipt since it means we don't have to trust the
@@ -156,11 +166,35 @@ class UDPRelayReceive(asyncio.DatagramProtocol):
156
166
  # this asynchronous but unfortunately that wouldn't allow us to
157
167
  # control the IP headers. Is there another way to resolve that?
158
168
 
169
+ # Use this unusual conditional in order to avoid expensive
170
+ # decoding operations when we're not debugging
171
+ if self._decode_pvaccess and logger.isEnabledFor(logging.INFO):
172
+ # Construct a fake ethernet header so we can decode the IP address using existing
173
+ # functionality. This will only work if the original source used IPv4.
174
+ # TODO: This indicates a flaw in the current logic. The received broadcast and the
175
+ # rebroadcast must use the same IP version (IPv4 or IPv6) or the message will
176
+ # be mangled
177
+ packet = Packet(b"\xff\xff\xff\xff\xff\xff\x02B\xac\x16\x00\x02\x08\x00" + data)
178
+ packet.decode_ip()
179
+ packet.decode_udp()
180
+ logger.debug(Packet)
181
+
182
+ packet_src_ip = packet.ip_src_addr
183
+
184
+ log_pvaccess(data[28:], packet_src_ip)
185
+
159
186
  # Finally broadcast the new packet
160
187
  # It doesn't feel much simpler but we're not using fully raw sockets here
161
188
  # but instead letting Python do the work of handling the Ethernet frames
162
189
  sendbytes = self._rebroad_sock.sendto(data, (self._broadcast_addr, self.broadcast_port))
163
- logger.debug("Broadcast UDP packet of length %d on iface %s: %s", sendbytes, self._iface, data)
190
+ logger.debug(
191
+ "Broadcast UDP packet of length %d to (%s,%s) on iface %s: %s",
192
+ sendbytes,
193
+ self._broadcast_addr,
194
+ self.broadcast_port,
195
+ self._iface,
196
+ data,
197
+ )
164
198
 
165
199
  async def start(self) -> None:
166
200
  """Start the UDP server that listens for messages from other relays and broadcasts them"""
@@ -18,17 +18,38 @@ import ipaddress
18
18
  import logging
19
19
  import socket
20
20
  from collections.abc import Sequence
21
+ from dataclasses import dataclass
22
+
23
+ import cachetools
21
24
 
22
25
  from .configure import ConfigArgs
23
26
  from .netutils import get_localhost_macs, human_readable_mac, identify_pkttype, machine_readable_mac
24
27
  from .packet import BadPacketException, EthernetProtocol, Packet
28
+ from .pva_packet import log_pvaccess_packet
25
29
 
26
30
  logger = logging.getLogger(__name__)
27
31
 
28
32
 
33
+ @dataclass(init=True, repr=True, eq=True, frozen=True)
34
+ class FragID:
35
+ """Enough details about an IPv4 fragment to identify its later parts"""
36
+
37
+ fragid: int
38
+ src: str
39
+ dst: str
40
+
41
+
29
42
  class UDPRelayTransmit:
30
43
  """Listen for UDP broadcasts and transmit to the other relays"""
31
44
 
45
+ # We need a cache to store potential UDP fragments
46
+ _fragcache = cachetools.TTLCache(maxsize=1024, ttl=1)
47
+
48
+ # This next bit is supplied only so that the testing may override it
49
+ # define ETH_P_ALL 0x0003 /* Every packet (be careful!!!) */
50
+ # define ETH_P_IP 0x0800 IP packets only; I believe this is IPv4
51
+ _packet_filter = 0x0800
52
+
32
53
  def __init__(
33
54
  self,
34
55
  remote_relays: Sequence[ipaddress.IPv4Address | ipaddress.IPv6Address | str],
@@ -54,8 +75,10 @@ class UDPRelayTransmit:
54
75
  # otherwise use some sensible defaults
55
76
  if config:
56
77
  self._iface = config.target_interface
78
+ self._decode_pvaccess = config.decode_pvaccess
57
79
  else:
58
80
  self._iface = "eth0"
81
+ self._decode_pvaccess = False
59
82
 
60
83
  self._macs = get_localhost_macs()
61
84
  self._macs = [machine_readable_mac(x) for x in self._macs]
@@ -89,7 +112,13 @@ class UDPRelayTransmit:
89
112
  with socket.socket(sock_family, socket.SOCK_DGRAM) as s:
90
113
  s.setblocking(False)
91
114
  loop = asyncio.get_running_loop()
92
- await loop.sock_sendto(s, msgbytes, (str(remote_relay), self.remote_port))
115
+ bytessent = await loop.sock_sendto(s, msgbytes, (str(remote_relay), self.remote_port))
116
+ if bytessent < len(msgbytes):
117
+ logger.warning(
118
+ "Sent truncated message to other SnowSignal nodes; was %i, should be %i",
119
+ bytessent,
120
+ len(msgbytes),
121
+ )
93
122
 
94
123
  def l1filter(self, ifname: str) -> bool:
95
124
  """Check the network interface is as expected"""
@@ -134,27 +163,82 @@ class UDPRelayTransmit:
134
163
  def l4filter(self, packet: Packet) -> bool:
135
164
  """Tests to perform on Level4 of packet, i.e. UDP Protocol"""
136
165
  if packet.udp_dst_port != self.local_port:
137
- logger.debug("Wrong UDP destination port: %i", packet.udp_dst_port)
166
+ logger.debug("Wrong UDP destination port: %i on packet %s", packet.udp_dst_port, packet)
138
167
  return False
139
168
 
140
169
  return True
141
170
 
171
+ def filter_fragment(self, packet: Packet) -> bool:
172
+ """
173
+ We have identified this as a UDP packet fragment. If it the first fragment we apply the
174
+ l4filter and then cache an identifier so that we may apply the result of that filter to
175
+ the subsequent fragments.
176
+ """
177
+
178
+ # If this is the first packet then if will have a fragment offset of 0. Importantly,
179
+ # we should still be able to evaluate it as a UDP packet and thus see if it satisfies
180
+ # our filters. If it doesn't then neither will subsequent fragments. However, if it
181
+ # does satisfy the filter then so will subsequent fragments which won't have UDP
182
+ # headers for us to evaluate
183
+ fragid = FragID(packet.ipv4_identification, packet.ip_src_addr, packet.ip_dst_addr) # type: ignore
184
+
185
+ # If the Fragment Offset is zero then this is the first fragment
186
+ if packet.ipv4_fragmented_offset == 0:
187
+ # The first fragment should be a valid UDP packet
188
+ packet.decode_udp()
189
+ # If it passes the l4filter then we cache its identifier so that subsequent fragments may be passed
190
+ if self.l4filter(packet):
191
+ logger.debug(
192
+ "Fragment (%i/%i), a first fragment, passed l4filter",
193
+ packet.ipv4_identification,
194
+ packet.ipv4_fragmented_offset,
195
+ )
196
+ self._fragcache[fragid] = packet
197
+ else:
198
+ logger.debug(
199
+ "Fragment (%i/%i) failed l4filter",
200
+ packet.ipv4_identification,
201
+ packet.ipv4_fragmented_offset,
202
+ )
203
+ return False
204
+ else:
205
+ # This is a fragment but not the first one. It will therefore not have a valid UDP header to decode.
206
+ # Instead we check if its identifier is in the cache from an inspection of the first fragement. If it is
207
+ # then the first fragment passed the l4filter and therefore this one does too.
208
+ if not self._fragcache[fragid]:
209
+ logger.debug(
210
+ "Fragment (%i/%i) not recognised as continuation of l4filter approved packet",
211
+ packet.ipv4_identification,
212
+ packet.ipv4_fragmented_offset,
213
+ )
214
+ return False
215
+
216
+ # If this is the final fragment, indicated by the More Fragments flag being False but the Fragment Offset
217
+ # being >0, then we need to remove its identifier from the fragment cache
218
+ if not packet.ipv4_more_fragments:
219
+ self._fragcache.pop(fragid)
220
+
221
+ return True
222
+
142
223
  async def start(self) -> None:
143
224
  """Monitor for UDP broadcasts on the specified port"""
225
+
226
+ logger.debug("UDPRelayTransmit starting to listen for raw packets")
227
+
144
228
  # create a AF_PACKET type raw socket (thats basically packet level)
145
229
  # define ETH_P_ALL 0x0003 /* Every packet (be careful!!!) */
146
230
  # define ETH_P_IP 0x0800 IP packets only; I believe this is IPv4
147
231
  with socket.socket(
148
- socket.AF_PACKET,
232
+ socket.AF_PACKET, # type: ignore - not available on Windows
149
233
  socket.SOCK_RAW,
150
- socket.ntohs(0x0800), # pylint: disable=no-member
234
+ socket.ntohs(self._packet_filter),
151
235
  ) as sock:
152
236
  sock.setblocking(False)
153
237
 
154
238
  while self._loop_forever:
155
239
  loop = asyncio.get_running_loop()
156
240
 
157
- raw_packet = await loop.sock_recvfrom(sock, 1024)
241
+ raw_packet = await loop.sock_recvfrom(sock, 65536)
158
242
  (ifname, proto, pkttype, hatype, addr) = raw_packet[1]
159
243
  raw_packet = raw_packet[0]
160
244
  logger.debug(
@@ -188,21 +272,38 @@ class UDPRelayTransmit:
188
272
  self._loop_forever = self._continue_while_loop()
189
273
  continue
190
274
 
191
- # Check Level 4 transport protocol, i.e. UDP
192
- packet.decode_udp()
193
- if not self.l4filter(packet):
194
- logger.debug("Failed l4filter")
195
- self._loop_forever = self._continue_while_loop()
196
- continue
275
+ # Check for IP packet fragmentation, only IPv4 packets can be fragmented
276
+ # https://en.wikipedia.org/wiki/IPv4#Fragmentation_and_reassembly
277
+ if not packet.is_ipv4_fragmented():
278
+ # This is an ordinary unfragmented IP packet
279
+ # Check Level 4 transport protocol, i.e. UDP
280
+ packet.decode_udp()
281
+ if not self.l4filter(packet):
282
+ logger.debug("Failed l4filter")
283
+ self._loop_forever = self._continue_while_loop()
284
+ continue
285
+ else:
286
+ if not self.filter_fragment(packet): # Note we may l4filter in this call
287
+ logger.debug("Failed l4filter of fragment")
288
+ self._loop_forever = self._continue_while_loop()
289
+ continue
290
+
197
291
  except BadPacketException as bpe:
198
292
  logger.debug("Malformed packet %r", bpe)
199
293
  self._loop_forever = self._continue_while_loop()
200
294
  continue
201
295
 
296
+ # Use this unusual conditional in order to avoid expensive
297
+ # decoding operations when we're not debugging
298
+ if self._decode_pvaccess and logger.isEnabledFor(logging.INFO):
299
+ log_pvaccess_packet(packet)
300
+
202
301
  # Send to other relays
203
302
  await self._send_to_relays_packet(packet)
204
303
  self._loop_forever = self._continue_while_loop()
205
304
 
305
+ logger.warning("UDPRelayTransmit no longer listening for raw packets")
306
+
206
307
  def _continue_while_loop(self) -> bool:
207
308
  """This function exists purely to allow unit testing of the start() function above"""
208
309
  return self._loop_forever
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: SnowSignal
3
- Version: 0.1.1
3
+ Version: 0.1.4.2
4
4
  Summary: UDP Broadcast Relay
5
5
  Project-URL: Repository, https://github.com/ISISNeutronMuon/SnowSignal
6
6
  Author-email: Ivan Finch <ivan.finch@stfc.ac.uk>
@@ -16,14 +16,15 @@ Classifier: Programming Language :: Python
16
16
  Classifier: Topic :: System :: Networking
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.11
19
+ Requires-Dist: cachetools>=5.5
19
20
  Requires-Dist: configargparse>=1.7
20
21
  Requires-Dist: psutil>=5.9
21
22
  Provides-Extra: dist
22
23
  Requires-Dist: build>=1.2; extra == 'dist'
23
- Requires-Dist: twine>=5.1; extra == 'dist'
24
+ Requires-Dist: hatch>=1.14; extra == 'dist'
24
25
  Provides-Extra: test
25
26
  Requires-Dist: coverage>=7.6; extra == 'test'
26
- Requires-Dist: ruff>0.6; extra == 'test'
27
+ Requires-Dist: ruff>0.9; extra == 'test'
27
28
  Requires-Dist: scapy~=2.0; extra == 'test'
28
29
  Description-Content-Type: text/markdown
29
30
 
@@ -0,0 +1,14 @@
1
+ snowsignal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ snowsignal/__main__.py,sha256=AehSEZINUEFv4Y2zFjUHpBifeLZNE-KFA2X9vZ890LU,255
3
+ snowsignal/configure.py,sha256=piAZXf36YhW4cgCYl3gVcv0WOo7jTwRJ5qMnm_Zz3Kc,3392
4
+ snowsignal/dockerfile,sha256=_WDaJrTD_iYESymw5e2a33BE0T8YwYDunkQwDKm7ajs,426
5
+ snowsignal/netutils.py,sha256=OCQwhFHtw3uMfD2IelxNztdN5COPLm8Dy8YyD9Z6foI,4777
6
+ snowsignal/packet.py,sha256=upIR1S6_jDRWuzzZ13355oFT78f2CyaujYm7b_LLa1c,7234
7
+ snowsignal/pva_packet.py,sha256=OutH928pUB4V5tDClaiVkcgzY6zxl6inFPb-uDCRs5Y,14153
8
+ snowsignal/snowsignal.py,sha256=Q-mQ9KKMF6lOWvM1lgyoSOwILTB7am2AmhSFosaecd8,5070
9
+ snowsignal/udp_relay_receive.py,sha256=9Hdsm8PrEjcdXsldVk9nh25NuzgARmXw-tZNVhGnVkE,9747
10
+ snowsignal/udp_relay_transmit.py,sha256=CIvNhMQs5XcEVYK-gcGRYdbU6uKrbQ92IB2WYjp924U,13467
11
+ snowsignal-0.1.4.2.dist-info/METADATA,sha256=cEZ_34eVrp-BGLSBjKxkZ7zOR11wHwLTAe_il1BBCqs,11541
12
+ snowsignal-0.1.4.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ snowsignal-0.1.4.2.dist-info/licenses/LICENSE,sha256=Xyl-Ykl-HUYLhQ4bu65pmUbqf8c-deJjxoq7ScjEcrI,1526
14
+ snowsignal-0.1.4.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.25.0
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,13 +0,0 @@
1
- snowsignal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- snowsignal/__main__.py,sha256=AehSEZINUEFv4Y2zFjUHpBifeLZNE-KFA2X9vZ890LU,255
3
- snowsignal/configure.py,sha256=e_dLvanmrPVs6RG1KOZPzscRJF9SbYlwstDMqBJSU90,3180
4
- snowsignal/dockerfile,sha256=HeJUva1v8-QBjU_yd8NNPSo3YEDYLdHLiSkF3Yf-28Q,386
5
- snowsignal/netutils.py,sha256=ZQ0cs3JF0Ec1DRWQsLOaLEcBRx6iGK4A6EQITIPac84,4794
6
- snowsignal/packet.py,sha256=CNnFDpoFdtIwAMBenXrRrOK7UpHAGsOXlI1QOcSWyeM,5696
7
- snowsignal/snowsignal.py,sha256=bG8I6tiFTixTiRYtDGcETbfqbTwZTb_nVxYvZeCGmvo,4822
8
- snowsignal/udp_relay_receive.py,sha256=uySOhRKLd2L1epHXYUxHP6M_Ey6NQ4LcN7MKtbH5pdQ,8331
9
- snowsignal/udp_relay_transmit.py,sha256=gxdS1dVPB4srkRtNA_UPOz3jDZsbfZNjL68VF8DtFuU,8626
10
- snowsignal-0.1.1.dist-info/METADATA,sha256=PHJ9-4_asNKK_g_cjajA0bDFhSUi9aRHc3rfMXSy7fE,11507
11
- snowsignal-0.1.1.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
12
- snowsignal-0.1.1.dist-info/licenses/LICENSE,sha256=Xyl-Ykl-HUYLhQ4bu65pmUbqf8c-deJjxoq7ScjEcrI,1526
13
- snowsignal-0.1.1.dist-info/RECORD,,