SnowSignal 0.1.1__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/__init__.py ADDED
File without changes
snowsignal/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ import asyncio
2
+ import sys
3
+ from asyncio.log import logger
4
+
5
+ from .snowsignal import main
6
+
7
+ if __name__ == "__main__":
8
+ try:
9
+ asyncio.run(main())
10
+ except KeyboardInterrupt:
11
+ logger.debug("Stopped by KeyboardInterrupt")
12
+ sys.exit(1)
@@ -0,0 +1,106 @@
1
+ """Configuration for SnowSignal
2
+ uses configargparse https://pypi.org/project/ConfigArgParse/
3
+ """
4
+
5
+ import logging
6
+ from collections.abc import Sequence
7
+ from typing import NamedTuple
8
+
9
+ import configargparse
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ConfigArgs(NamedTuple):
15
+ """This bit of weirdness allows us to use strong type hinting with Argparse
16
+ or equivalent tools. See https://dev.to/xowap/the-ultimate-python-main-18kn"""
17
+
18
+ target_interface: str
19
+ broadcast_port: int
20
+ mesh_port: int
21
+ other_relays: list[str]
22
+ log_level: str
23
+
24
+
25
+ def configure(argv: Sequence[str] | None = None) -> ConfigArgs:
26
+ """Setup configuration for the SnowSignal service"""
27
+
28
+ p = configargparse.ArgParser()
29
+ # Remember to add new arguments to the Args class above!
30
+ p.add_argument(
31
+ "-t",
32
+ "--target-interface",
33
+ env_var="TARGET_INTERFACE",
34
+ default="eth0",
35
+ type=str,
36
+ help="Target network interface",
37
+ )
38
+ p.add_argument(
39
+ "-b",
40
+ "--broadcast-port",
41
+ env_var="BDCAST_PORT",
42
+ default=5076,
43
+ type=int,
44
+ help="Port on which to receive and transmit UDP broadcasts",
45
+ )
46
+ p.add_argument(
47
+ "-m",
48
+ "--mesh-port",
49
+ env_var="MESH_PORT",
50
+ default=7124,
51
+ type=int,
52
+ help="Port on which this instance will communicate with others via UDP unicast",
53
+ )
54
+ p.add_argument(
55
+ "--other-relays",
56
+ nargs="+",
57
+ type=str,
58
+ default=[],
59
+ help="Manually select other relays to transmit received UDP broadcasts to",
60
+ )
61
+ p.add_argument(
62
+ "-ll",
63
+ "--log-level",
64
+ env_var="LOGLEVEL",
65
+ choices=["debug", "info", "warning", "error", "critical"],
66
+ default="info",
67
+ help="Logging level",
68
+ )
69
+ # Remember to add new arguments to the Args class above!
70
+
71
+ # config = p.parse_args(argv)
72
+ config = ConfigArgs(**p.parse_args(argv).__dict__)
73
+
74
+ match config.log_level:
75
+ case "critical":
76
+ loglevel = logging.CRITICAL
77
+ case "error":
78
+ loglevel = logging.ERROR
79
+ case "warning":
80
+ loglevel = logging.WARNING
81
+ case "info":
82
+ loglevel = logging.INFO
83
+ case "debug":
84
+ loglevel = logging.DEBUG
85
+
86
+ if loglevel < logging.INFO:
87
+ logging.basicConfig(
88
+ format="%(asctime)s - %(levelname)s - " "%(name)s.%(funcName)s: %(message)s",
89
+ encoding="utf-8",
90
+ level=loglevel,
91
+ )
92
+ else:
93
+ logging.basicConfig(format="%(asctime)s - %(levelname)s: %(message)s", encoding="utf-8", level=loglevel)
94
+
95
+ if config.broadcast_port == config.mesh_port:
96
+ # Can't use the same port for two different purposes
97
+ # Later, if we allow the receive relay and transmit relay on different
98
+ # ports we may need to revisit this error
99
+ logger.error(
100
+ "Broadcast port (%i) and mesh port (%i) may not be the same", config.broadcast_port, config.mesh_port
101
+ )
102
+ raise ValueError(
103
+ f"Broadcast port ({config.broadcast_port}) and " f"mesh port ({config.mesh_port}) may not be the same"
104
+ )
105
+
106
+ return config
snowsignal/dockerfile ADDED
@@ -0,0 +1,9 @@
1
+ # Use slim because if we used alpine we'd need gcc for the
2
+ # psutils requirement in requirements.txt
3
+ FROM python:3.12-slim
4
+
5
+ # Token has read_api and read_repository permissions and so does not need to be kept secret
6
+ # However it will expire
7
+ RUN pip install SnowSignal --index-url https://gitlab.stfc.ac.uk/api/v4/projects/5671/packages/pypi/simple
8
+
9
+ CMD ["python", "-m", "snowsignal"]
snowsignal/netutils.py ADDED
@@ -0,0 +1,137 @@
1
+ """Network related utility functions for SnowSignal"""
2
+
3
+ import ipaddress
4
+ import logging
5
+ import socket
6
+ import sys
7
+
8
+ import psutil
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Explanation for repeated use of `pyl1nt: disable=no-member` in
13
+ # this file: a "bug" in PyLint means that it incorrectly diagnoses
14
+ # socket.AddressFamily as an error, since it believes that socket
15
+ # has no attribute AddressFamily. So we suppress this incorrect error
16
+
17
+
18
+ def get_ips_from_name(name: str) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
19
+ """Given a hostname return its IP addresses as a list"""
20
+ local_ips_details = socket.getaddrinfo(f"{name}", 80)
21
+
22
+ ips = []
23
+ for local_ip_detail in local_ips_details:
24
+ addfamily = local_ip_detail[0]
25
+ if addfamily in (socket.AddressFamily.AF_INET6, socket.AddressFamily.AF_INET): # pylint: disable=no-member
26
+ ip = ipaddress.ip_address(local_ip_detail[4][0])
27
+ ips.append(ip)
28
+ else:
29
+ raise RuntimeError(f"Unknown AddressFamily {addfamily}")
30
+
31
+ # Remove duplicates and return
32
+ ips = list(set(ips))
33
+
34
+ return ips
35
+
36
+
37
+ def get_localhost_ips() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
38
+ """Establish the IP address(es) of this container"""
39
+ # Note that in a Docker Swarm environment we expect the container to
40
+ # have at least two IP addresses, it's traditional IP address associated
41
+ # with eth0 but also a Virtual IP (VIP) shared with all of the
42
+ # other containers in the same task. We could also have other IP
43
+ # addresses associated with other networks. So there could be many
44
+ # IP addresses for this one container!
45
+
46
+ # This is a bit of a hack but is apparently the most portable way
47
+ local_ips = get_ips_from_name(socket.gethostname())
48
+ logger.debug("\tThis system has IP address(es) %s:", local_ips)
49
+
50
+ return local_ips
51
+
52
+
53
+ class ResourceNotFoundException(OSError):
54
+ """Indicate an expected hardware resource could not be found"""
55
+
56
+
57
+ def get_from_iface(
58
+ iface: str,
59
+ family: socket.AddressFamily | int,
60
+ attribute: str = "address", # pylint: disable=no-member
61
+ ):
62
+ """Get the IP address associated with a network interface"""
63
+ snicaddrs = psutil.net_if_addrs()[iface]
64
+ for snicaddr in snicaddrs:
65
+ if snicaddr.family == family:
66
+ return getattr(snicaddr, attribute)
67
+
68
+ raise ResourceNotFoundException(
69
+ f"Could not identify the {family}, " "{attribute} associated with interface {iface}"
70
+ )
71
+
72
+
73
+ def get_localipv4_from_iface(iface: str) -> str:
74
+ """Get the IPv4 address associated with a network interface"""
75
+ return get_from_iface(iface, socket.AddressFamily.AF_INET) # pylint: disable=no-member
76
+
77
+
78
+ def get_macaddress_from_iface(iface: str) -> str:
79
+ """Get the MAC address associated with a network interface"""
80
+ if sys.platform != "win32":
81
+ return get_from_iface(iface, socket.AddressFamily.AF_PACKET) # pylint: disable=no-member
82
+ else:
83
+ return get_from_iface(iface, psutil.AF_LINK)
84
+
85
+
86
+ def get_localhost_macs() -> list[str]:
87
+ """Get all the MAC addresses of local network interfaces"""
88
+ macs = []
89
+
90
+ ifaces = psutil.net_if_addrs()
91
+ for iface in ifaces:
92
+ try:
93
+ macs.append(get_macaddress_from_iface(iface))
94
+ except ResourceNotFoundException:
95
+ pass
96
+
97
+ return macs
98
+
99
+
100
+ def get_broadcast_from_iface(iface: str) -> str:
101
+ """Get the MAC address associated with a network interface"""
102
+ broadcast_address = get_from_iface(iface, socket.AddressFamily.AF_INET, attribute="broadcast") # pylint: disable=no-member
103
+
104
+ # If we don't get a valid broadcast address then attempt to substitute one
105
+ if not broadcast_address:
106
+ return "255.255.255.255"
107
+
108
+ return broadcast_address
109
+
110
+
111
+ def human_readable_mac(macbytes: bytes, separator: str = ":") -> str:
112
+ """Convert MAC in bytes into human-readable string with separators"""
113
+ unseparated_mac_str = macbytes.hex()
114
+ return separator.join([i + j for i, j in zip(unseparated_mac_str[::2], unseparated_mac_str[1::2])])
115
+
116
+
117
+ def machine_readable_mac(macstr: str) -> bytes:
118
+ """Convert MAC with ':' or '-' separators into bytes without seperators"""
119
+ hexstring = macstr.translate({45: "", 58: ""})
120
+ return bytes.fromhex(hexstring)
121
+
122
+
123
+ def identify_pkttype(pkttype: int) -> str:
124
+ """Decode packet type from socket.recvfrom"""
125
+ match pkttype:
126
+ case socket.PACKET_HOST:
127
+ return "PACKET_HOST"
128
+ case socket.PACKET_BROADCAST:
129
+ return "PACKET_BROADCAST"
130
+ case socket.PACKET_MULTICAST:
131
+ return "PACKET_MULTICAST"
132
+ case socket.PACKET_OTHERHOST:
133
+ return "PACKET_OTHERHOST"
134
+ case socket.PACKET_OUTGOING:
135
+ return "PACKET_OUTGOING"
136
+ case _:
137
+ return "UNKNOWN PACKET TYPE"
snowsignal/packet.py ADDED
@@ -0,0 +1,174 @@
1
+ """Simplified model of a Ethernet / IP / UDP packet"""
2
+
3
+ import dataclasses
4
+ import logging
5
+ import socket
6
+ from enum import Enum, unique
7
+ from struct import unpack
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ ETH_LENGTH = 14
13
+
14
+
15
+ @unique
16
+ class EthernetProtocol(Enum):
17
+ """Meaning of protocol value from Ethernet frame header"""
18
+
19
+ UNKNOWN = 0 # We could classify more but we don't care!
20
+ IPv4 = 0x0800 # pylint: disable=invalid-name
21
+ IPv6 = 0x86DD # pylint: disable=invalid-name
22
+
23
+ @classmethod
24
+ def _missing_(cls, _):
25
+ return cls.UNKNOWN
26
+
27
+
28
+ class BadPacketException(Exception):
29
+ """Basic exception type raised by Packet class"""
30
+
31
+
32
+ @dataclasses.dataclass
33
+ class Packet:
34
+ """How to deconstruct and modify a Ethernet / UDP Packet
35
+ This is a very limited implementation designed only to support the
36
+ operations needed by this code. For example, it does not attempt to
37
+ recompute the checksums if the payload is modified."""
38
+
39
+ raw: bytes
40
+
41
+ eth_protocol: EthernetProtocol = EthernetProtocol.UNKNOWN
42
+ eth_dst_mac: bytes | None = None
43
+ eth_src_mac: bytes | None = None
44
+
45
+ _iph_length: int = dataclasses.field(default=-1, repr=False)
46
+ ip_version: int | None = None
47
+ ip_protocol: int | None = None
48
+ ip_chksum: int | None = None
49
+ ip_src_addr: str | None = None
50
+ ip_dst_addr: str | None = None
51
+
52
+ udp_src_port: int | None = None
53
+ udp_dst_port: int | None = None
54
+ udp_length: int | None = None
55
+ udp_chksum: int | None = None
56
+
57
+ def __post_init__(self) -> None:
58
+ # Always decode the Ethernet portion, but we're lazy about
59
+ # decoding the higher protocols
60
+ self.decode_ethernet()
61
+
62
+ def decode_ethernet(self) -> None:
63
+ """Interpret input bytes as a Ethernet packet"""
64
+ # https://en.wikipedia.org/wiki/Ethernet_frame#Structure
65
+
66
+ logger.debug("Decoding ethernet packet %r", self.raw)
67
+
68
+ try:
69
+ # TODO: Do we need to account for formats other than Ethernet-II or
70
+ # VPN frames, etc.
71
+ eth_header = self.raw[:ETH_LENGTH]
72
+ eth = unpack("!6s6sH", eth_header)
73
+
74
+ self.eth_protocol = EthernetProtocol(eth[2])
75
+ self.eth_dst_mac = eth[0]
76
+ self.eth_src_mac = eth[1]
77
+ except Exception as e:
78
+ raise BadPacketException from e
79
+
80
+ def _decode_ipv4(self) -> None:
81
+ """Decode IPv4 protocol header"""
82
+ # https://en.wikipedia.org/wiki/IPv4#Packet_structure
83
+ # Take the data for the IPv4 header from the packet
84
+ ip_header = self.raw[ETH_LENGTH : 20 + ETH_LENGTH] # noqa: E203
85
+
86
+ # Unpack data from IP header
87
+ iph = unpack("!BBHHHBBH4s4s", ip_header)
88
+
89
+ # Calculate the version
90
+ version_ihl = iph[0]
91
+ self.ip_version = version_ihl >> 4
92
+
93
+ if self.ip_version != 4:
94
+ return
95
+
96
+ # Calculate the length (of the header?)
97
+ ihl = version_ihl & 0xF
98
+ self._iph_length = ihl * 4
99
+
100
+ # ttl = iph[5] # Time to live
101
+ self.ip_protocol = iph[6]
102
+ self.ip_chksum = iph[7]
103
+ self.ip_src_addr = socket.inet_ntoa(iph[8])
104
+ self.ip_dst_addr = socket.inet_ntoa(iph[9])
105
+
106
+ def _decode_ipv6(self) -> None:
107
+ """Decode IPv6 protocol header"""
108
+ # https://en.wikipedia.org/wiki/IPv6_packet#Fixed_header
109
+ ip_header = self.raw[ETH_LENGTH : 40 + ETH_LENGTH] # noqa: E203
110
+
111
+ # Unpack data from IP header
112
+ iph = unpack("!IHBB16s16s", ip_header)
113
+
114
+ # Calculate the version
115
+ version_ihl = ip_header[0]
116
+ self.ip_version = version_ihl >> 4
117
+
118
+ if self.ip_version != 6:
119
+ return
120
+
121
+ # Calculate the length of the header
122
+ self._iph_length = 40 # IPv6 header is a fixed length
123
+
124
+ self.ip_protocol = iph[2]
125
+ self.ip_chksum = None # IPv6 relies on other layers for the checksum
126
+ self.ip_src_addr = iph[4]
127
+ self.ip_dst_addr = iph[5]
128
+
129
+ def decode_ip(self) -> None:
130
+ """Decode the IP Protocol header"""
131
+ try:
132
+ match self.eth_protocol:
133
+ case EthernetProtocol.IPv4:
134
+ self._decode_ipv4()
135
+ case EthernetProtocol.IPv6:
136
+ self._decode_ipv6()
137
+ case EthernetProtocol.UNKNOWN:
138
+ # If we don't know what this is then do nothing
139
+ return
140
+ case _:
141
+ # This ought to be impossible so we will raise in this case
142
+ raise SyntaxError(f"Unhandled ip_protocol type {self.eth_protocol}")
143
+ except Exception as e:
144
+ raise BadPacketException from e
145
+
146
+ def decode_udp(self) -> None:
147
+ """Decode UDP header information"""
148
+ try:
149
+ # Get the UDP Header
150
+ udp_packet_start = self._iph_length + ETH_LENGTH
151
+ udp_packet_end = udp_packet_start + 8
152
+ udp_header = self.raw[udp_packet_start:udp_packet_end]
153
+
154
+ # now unpack them :)
155
+ udph = unpack("!HHHH", udp_header)
156
+
157
+ self.udp_src_port = udph[0]
158
+ self.udp_dst_port = udph[1]
159
+ self.udp_length = udph[2]
160
+ self.udp_chksum = udph[3]
161
+ except Exception as e:
162
+ raise BadPacketException from e
163
+
164
+ def get_udp_payload(self) -> bytes:
165
+ """Get the UDP payload from the packet"""
166
+ try:
167
+ udp_packet_end = self._iph_length + ETH_LENGTH + 8
168
+ return self.raw[udp_packet_end:]
169
+ except Exception as e:
170
+ raise BadPacketException from e
171
+
172
+ def change_ethernet_source(self, newmac) -> None:
173
+ """Change packet Ethernet source to a new MAC address"""
174
+ self.raw = self.raw[0:6] + newmac + self.raw[12:]
@@ -0,0 +1,130 @@
1
+ """SnowSignal - UDP Broadcast Relay"""
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ import logging
6
+ import os
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from .configure import ConfigArgs, configure
11
+ from .netutils import get_ips_from_name, get_localhost_ips, get_localipv4_from_iface
12
+ from .udp_relay_receive import UDPRelayReceive
13
+ from .udp_relay_transmit import UDPRelayTransmit
14
+
15
+ # Logging and configuration of Scapy
16
+ logger = logging.getLogger()
17
+
18
+
19
+ def is_swarmmode() -> bool:
20
+ """Crude check to see if we're running in docker swarm"""
21
+
22
+ swarmmode = False
23
+ try:
24
+ if os.environ["SERVICENAME"]:
25
+ swarmmode = True
26
+ except KeyError:
27
+ pass # Docker Swarm related environment variable not set
28
+ return swarmmode
29
+
30
+
31
+ def setup_remote_relays(
32
+ config: ConfigArgs, local_addr: str | ipaddress.IPv4Address | ipaddress.IPv6Address, swarmmode: bool
33
+ ) -> Sequence[str | ipaddress.IPv4Address | ipaddress.IPv6Address]:
34
+ """Initial setup of the remote relays. If we're not in a Docker Swarm then
35
+ this is largely immutable."""
36
+
37
+ if swarmmode and not config.other_relays:
38
+ # Use swarm DNS magic to identify the other nodes
39
+ logger.debug("Using swarm DNS to identify other relays")
40
+ remote_relays = discover_relays()
41
+ elif not swarmmode and config.other_relays:
42
+ logger.debug("Using user configuration of other relays")
43
+ remote_relays = config.other_relays
44
+ else:
45
+ # Assume we're in testing mode and loopback to ourselves
46
+ logger.debug("Using debug mode for other relays, will relay to self")
47
+ remote_relays = [local_addr]
48
+ return remote_relays
49
+
50
+
51
+ def discover_relays() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
52
+ """Discover the other UDP Broadcast Relays in the stack"""
53
+
54
+ logger.debug("Beginning relay discovery")
55
+ # Establish the IP address(es) of this container
56
+ # This is a bit of a hack but is apparently the most portable way
57
+ local_ips = get_localhost_ips()
58
+
59
+ # Get the list of IP addresses in this stack
60
+ # First get the environment variable we're using to identify our stack
61
+ try:
62
+ stack_and_task = os.environ["SERVICENAME"]
63
+ except KeyError:
64
+ logger.critical("Environment variable SERVICENAME must be set as {{.Service.Name}} in compose file")
65
+ raise
66
+
67
+ # The important bit here is to query tasks. This will work however the
68
+ # endpoint_mode is set and will only list the other containers and not
69
+ # include the Virtual IP (VIP)
70
+ task_ips = get_ips_from_name(f"tasks.{stack_and_task}")
71
+ logger.debug("\tTasks in %s have IP address(es) %s:", stack_and_task, task_ips)
72
+
73
+ # We don't want to communicate with ourself
74
+ valid_ips = list(set(task_ips) - set(local_ips))
75
+ logger.info("\tDiscovered relays: %s", valid_ips)
76
+
77
+ return valid_ips
78
+
79
+
80
+ # Weird "argv" syntax required to support unittests
81
+ async def main(argv: Sequence[str] | None = None, loop_forever: bool = True):
82
+ """Main function
83
+ Load up the configuration and do some other setup. But mostly we're here
84
+ to start two asyncio tasks. One listens for UDP broadcasts and sends them
85
+ on to other relays. The other listens to the other relays and rebroadcasts
86
+ as they instruct. Then we sit in an infinite loop to allow these things to
87
+ happen!
88
+ """
89
+
90
+ # Configure this relay
91
+ config = configure(argv)
92
+ logger.info("Starting with configuration %s", config)
93
+
94
+ # Get the local IP address
95
+ # TODO: Properly support IPv6
96
+ local_addr = get_localipv4_from_iface(config.target_interface)
97
+
98
+ # Check if we're running in a Docker Swarm
99
+ swarmmode = is_swarmmode()
100
+
101
+ # Identify the remote relays to send UDP broadcasts messages to
102
+ remote_relays = setup_remote_relays(config, local_addr, swarmmode)
103
+
104
+ # Start listening for UDP broadcasts to transmit to the other relays
105
+ udp_relay_transmit = UDPRelayTransmit(
106
+ remote_relays=remote_relays, local_port=config.broadcast_port, remote_port=config.mesh_port, config=config
107
+ )
108
+ asyncio.create_task(udp_relay_transmit.start())
109
+
110
+ # Listen for messages from the other relays to UDP broadcast
111
+ udp_relay_receive = UDPRelayReceive(
112
+ local_addr=(local_addr, config.mesh_port), broadcast_port=config.broadcast_port, config=config
113
+ )
114
+ asyncio.create_task(udp_relay_receive.start())
115
+
116
+ # Loop forever, but if in swarm mode periodically recheck the relays
117
+ while loop_forever:
118
+ await asyncio.sleep(10)
119
+ if swarmmode:
120
+ # Check to see if remote relays have changed
121
+ # e.g. containers have restarted
122
+ udp_relay_transmit.set_remote_relays(discover_relays())
123
+
124
+
125
+ if __name__ == "__main__":
126
+ try:
127
+ asyncio.run(main())
128
+ except KeyboardInterrupt:
129
+ logger.debug("Stopped by KeyboardInterrupt")
130
+ sys.exit(1)
@@ -0,0 +1,196 @@
1
+ """
2
+ Listen for UDP broadcasts on a port and transmit packet information to other relays
3
+
4
+ This uses the standard asyncio.DatagramProtocol base class so most of the initial
5
+ management of the UDP packet is already done for us.
6
+ """
7
+
8
+ import array
9
+ import asyncio
10
+ import ipaddress
11
+ import logging
12
+ import socket
13
+ import struct
14
+ from copy import deepcopy
15
+ from typing import Any
16
+
17
+ from .configure import ConfigArgs
18
+ from .netutils import get_broadcast_from_iface, get_macaddress_from_iface
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class UDPRelayReceive(asyncio.DatagramProtocol):
24
+ """Listen to UDP messages from remote relays and forward them as broadcasts on the local net"""
25
+
26
+ def __init__(
27
+ self,
28
+ local_addr: tuple[ipaddress.IPv4Address | ipaddress.IPv6Address | str, int],
29
+ broadcast_port: int,
30
+ config: ConfigArgs | None = None,
31
+ ) -> None:
32
+ super().__init__()
33
+
34
+ self.local_addr = (str(local_addr[0]), local_addr[1]) # Get typing right
35
+ self.broadcast_port = broadcast_port
36
+ self.transport: asyncio.DatagramTransport
37
+ self._rebroad_sock: socket.socket
38
+
39
+ if config:
40
+ self._iface = config.target_interface
41
+ else:
42
+ self._iface = "eth0"
43
+
44
+ # Assume the MAC address is immutable
45
+ self._mac = get_macaddress_from_iface(self._iface)
46
+
47
+ # Also assume the broadcast address associated with the
48
+ # interface is immutable
49
+ self._broadcast_addr = get_broadcast_from_iface(self._iface)
50
+
51
+ # A way to manage the forever loop for unit testing and other purposes
52
+ self._loop_forever = True
53
+
54
+ def connection_made(self, transport: asyncio.DatagramTransport) -> None:
55
+ """Handle a connection being established"""
56
+ self.transport = transport
57
+
58
+ def connection_lost(self, exc: Exception | None) -> None:
59
+ """Handle a connection being lost"""
60
+ # What does connection lost even mean for UDP?
61
+ # Seems only necessary to stop some spurious errors on server shutdown
62
+
63
+ def recalculate_udp_checksum(self, ip_packet) -> bytes:
64
+ """Calculate UDP checksum, using the IP and UDP parts of the packet,
65
+ and change the existing packet UDP checksum with the newly calculcated
66
+ checksum"""
67
+
68
+ # The UDP checksum algorithm is defined in RFC768
69
+ # https://www.rfc-editor.org/rfc/rfc768.txt
70
+ # "Checksum is the 16-bit one's complement of the one's complement sum of a
71
+ # pseudo header of information from the IP header, the UDP header, and the
72
+ # data, padded with zero octets at the end (if necessary) to make a
73
+ # multiple of two octets"
74
+
75
+ # Extract the data needed to form the pseudo packet and header
76
+ # Got this from https://dev.to/cwprogram/python-networking-tcp-and-udp-4i3l
77
+
78
+ # Make a deepcopy of the UDP portion of the whole ip_packet so that we don't
79
+ # accidentally modify it. Then zero the part that contains the UDP checksum.
80
+ # A zero checksum is valid but we'll calculate the correct value
81
+ pseudo_packet = bytearray(deepcopy(ip_packet[20:]))
82
+ pseudo_packet[6] = 0x0
83
+ pseudo_packet[7] = 0x0
84
+
85
+ # We need information from the IP header to construct the pseudo-header
86
+ # needed in turn to calculate the UDP checksum. Specifically we need the
87
+ # source and destination IP addresses
88
+ ip_header = struct.unpack("!BBHHHBBH4s4s", ip_packet[0:20])
89
+ pseudo_header = struct.pack("!4s4sHH", ip_header[8], ip_header[9], socket.IPPROTO_UDP, len(pseudo_packet))
90
+
91
+ # Combine the pseudo header and pseudo packet to form a complete pseudo packet
92
+ # that we'll perform the checksum calculations on
93
+ checksum_packet = pseudo_header + pseudo_packet
94
+
95
+ # If there is an odd number of bytes in the checksum packet we need to
96
+ # pad it to an even number of bytes
97
+ if len(checksum_packet) % 2 == 1:
98
+ checksum_packet += b"\0"
99
+
100
+ # The checksum calculation proceeds by summing the one’s complement where
101
+ # all binary 0s become 1s, of all 16-bit words in these components.
102
+ onecompsum = sum(array.array("H", checksum_packet))
103
+ onecompsum = (onecompsum >> 16) + (onecompsum & 0xFFFF)
104
+ onecompsum += onecompsum >> 16
105
+ onecompsum = ~onecompsum # Finally invert the bits
106
+
107
+ # Test endianness and do some magic if we're on a little endian system
108
+ if struct.pack("H", 1) != b"\x00\x01":
109
+ onecompsum = ((onecompsum >> 8) & 0xFF) | onecompsum << 8
110
+
111
+ # If checksum is 0 change it to 0xFFFF to signal it has been calculated
112
+ udp_checksum = onecompsum & 0xFFFF
113
+
114
+ # Insert the calculated checksum into the IP + UDP packet
115
+ ip_packet = ip_packet[:26] + udp_checksum.to_bytes(2, "big") + ip_packet[28:]
116
+
117
+ return ip_packet
118
+
119
+ def datagram_received(self, data: bytes, addr: tuple[str | Any, int]) -> None:
120
+ """Receive a UDP message and forward it to the remote relays"""
121
+ logger.debug(
122
+ "Received from %s for rebroadcast on port %i message: %r",
123
+ addr,
124
+ self.broadcast_port,
125
+ data,
126
+ )
127
+
128
+ # Simple verification of the received payload, and remove the bytes
129
+ # confirming that this is for us
130
+ if data[0:2] == b"SS":
131
+ data = data[2:]
132
+ else:
133
+ logger.debug("Malformed packet received")
134
+ return
135
+
136
+ # TODO: Apply any filters
137
+
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:])
142
+
143
+ # TODO: The code above does not change the IP source address
144
+ # If we're on a different network segment then we should switch the
145
+ # broadcast IP address to use get_broadcast_from_iface(). This will
146
+ # then require recomputing checksums. Note: is this required? We
147
+ # are sending to the broadcast address in the sendto() below
148
+
149
+ # TODO: Logic to validate what we're receiving as a PVAccess message
150
+ # Note that although doing the validation on receipt means we're doing
151
+ # it for every relay (instead of once if we did it on send), it's much
152
+ # safer to do it on receipt since it means we don't have to trust the
153
+ # sender as much
154
+
155
+ # TODO: We should be using self.transport.sendto() in order to make
156
+ # this asynchronous but unfortunately that wouldn't allow us to
157
+ # control the IP headers. Is there another way to resolve that?
158
+
159
+ # Finally broadcast the new packet
160
+ # It doesn't feel much simpler but we're not using fully raw sockets here
161
+ # but instead letting Python do the work of handling the Ethernet frames
162
+ 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)
164
+
165
+ async def start(self) -> None:
166
+ """Start the UDP server that listens for messages from other relays and broadcasts them"""
167
+
168
+ logger.info(
169
+ "Starting UDP server listening on %s; will rebroadcast on port %i",
170
+ self.local_addr,
171
+ self.broadcast_port,
172
+ )
173
+
174
+ # Open the socket we'll rebroadcast messages on
175
+ self._rebroad_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_UDP)
176
+ self._rebroad_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, True)
177
+ self._rebroad_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, True)
178
+ self._rebroad_sock.setblocking(False)
179
+
180
+ # Get a reference to the event loop as we plan to use
181
+ # low-level APIs.
182
+ loop = asyncio.get_running_loop()
183
+
184
+ # One protocol instance will be created to serve all
185
+ # client requests.
186
+ transport, _ = await loop.create_datagram_endpoint(
187
+ lambda: self, local_addr=self.local_addr, allow_broadcast=True
188
+ )
189
+
190
+ try:
191
+ while self._loop_forever:
192
+ # Basically sleep forever!
193
+ await asyncio.sleep(1)
194
+ finally:
195
+ transport.close()
196
+ self._rebroad_sock.close()
@@ -0,0 +1,221 @@
1
+ """
2
+ The UDPRelayTransmit class is confusingly named. It transmits packets
3
+ into the relay mesh network. That means that it is also the class that
4
+ listens for UDP broadcasts on the specified network interface and port.
5
+
6
+ It applies a number of defined filters (level 1 to level 4) to verify
7
+ that the received packet was received on the specified network interface,
8
+ port, that it is a broadcast packet, and that it is a well-formed UDP packet.
9
+ Importantly it filters out any packets that were received from this network
10
+ interfaces MAC address.
11
+
12
+ If these criteria are met then it sends the packet to the rest of the mesh
13
+ network relays.
14
+ """
15
+
16
+ import asyncio
17
+ import ipaddress
18
+ import logging
19
+ import socket
20
+ from collections.abc import Sequence
21
+
22
+ from .configure import ConfigArgs
23
+ from .netutils import get_localhost_macs, human_readable_mac, identify_pkttype, machine_readable_mac
24
+ from .packet import BadPacketException, EthernetProtocol, Packet
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class UDPRelayTransmit:
30
+ """Listen for UDP broadcasts and transmit to the other relays"""
31
+
32
+ def __init__(
33
+ self,
34
+ remote_relays: Sequence[ipaddress.IPv4Address | ipaddress.IPv6Address | str],
35
+ local_port: int = 5076,
36
+ remote_port: int = 7124,
37
+ config: ConfigArgs | None = None,
38
+ ) -> None:
39
+ self._loop_forever = True
40
+
41
+ logger.info(
42
+ "Initialising UDPRelayTransmit listening for UDP broadcasts "
43
+ "on port %i for relay to remote relays %s on port %i",
44
+ local_port,
45
+ remote_relays,
46
+ remote_port,
47
+ )
48
+
49
+ self.local_port = local_port
50
+ self.remote_port = remote_port
51
+ self.remote_relays = remote_relays
52
+
53
+ # If there's a config provided then use the setting from it,
54
+ # otherwise use some sensible defaults
55
+ if config:
56
+ self._iface = config.target_interface
57
+ else:
58
+ self._iface = "eth0"
59
+
60
+ self._macs = get_localhost_macs()
61
+ self._macs = [machine_readable_mac(x) for x in self._macs]
62
+ self.ip_whitelist = [] # NotImplemented
63
+
64
+ async def _send_to_relays_packet(self, packet: Packet) -> None:
65
+ """
66
+ Callback to send whole packet to other relays
67
+ if packet passes sniffer filters
68
+ """
69
+ logger.debug("Transmitting to relays UDP broadcast message:\n%s", packet)
70
+
71
+ pkt_raw = b"SS" + packet.raw
72
+
73
+ await self._send_to_relays_bytes(pkt_raw)
74
+
75
+ async def _send_to_relays_bytes(self, msgbytes: bytes) -> None:
76
+ """Send bytes to the remote relays"""
77
+
78
+ for remote_relay in self.remote_relays:
79
+ logger.debug(
80
+ "Send to (%s, %i) message: %r",
81
+ remote_relay,
82
+ self.remote_port,
83
+ msgbytes,
84
+ )
85
+ sock_family = socket.AF_INET
86
+ if isinstance(remote_relay, ipaddress.IPv6Address):
87
+ sock_family = socket.AF_INET6
88
+
89
+ with socket.socket(sock_family, socket.SOCK_DGRAM) as s:
90
+ s.setblocking(False)
91
+ loop = asyncio.get_running_loop()
92
+ await loop.sock_sendto(s, msgbytes, (str(remote_relay), self.remote_port))
93
+
94
+ def l1filter(self, ifname: str) -> bool:
95
+ """Check the network interface is as expected"""
96
+ if ifname != self._iface:
97
+ logger.debug("Identified as using wrong iface %s", ifname)
98
+ return False
99
+
100
+ return True
101
+
102
+ def l2filter(self, packet: Packet) -> bool:
103
+ """Tests to perform on Level2 of packet, i.e. Ethernet"""
104
+ # Make sure this is a broadcast and that its payload is an IP protocol message
105
+ if packet.eth_dst_mac != b"\xff\xff\xff\xff\xff\xff":
106
+ logger.debug("Not broadcast packet %r", packet)
107
+ return False
108
+ if packet.eth_protocol == EthernetProtocol.UNKNOWN:
109
+ logger.debug("Not known ethernet protocol packet %r", packet)
110
+ return False
111
+
112
+ # Do not process packets sourced from this machine
113
+ if packet.eth_src_mac in self._macs:
114
+ logger.debug("Source is a local MAC")
115
+ return False
116
+
117
+ return True
118
+
119
+ def l3filter(self, packet: Packet) -> bool:
120
+ """Tests to perform on Level3 of packet, i.e IP Protocol"""
121
+
122
+ # Make sure this contains a UDP payload
123
+ if packet.ip_protocol != 17: # 17 is UDP
124
+ return False
125
+
126
+ # If we have a whitelist of source addresses then check it claims to come
127
+ # from one of them
128
+ if self.ip_whitelist:
129
+ if packet.ip_src_addr not in self.ip_whitelist:
130
+ return False
131
+
132
+ return True
133
+
134
+ def l4filter(self, packet: Packet) -> bool:
135
+ """Tests to perform on Level4 of packet, i.e. UDP Protocol"""
136
+ if packet.udp_dst_port != self.local_port:
137
+ logger.debug("Wrong UDP destination port: %i", packet.udp_dst_port)
138
+ return False
139
+
140
+ return True
141
+
142
+ async def start(self) -> None:
143
+ """Monitor for UDP broadcasts on the specified port"""
144
+ # create a AF_PACKET type raw socket (thats basically packet level)
145
+ # define ETH_P_ALL 0x0003 /* Every packet (be careful!!!) */
146
+ # define ETH_P_IP 0x0800 IP packets only; I believe this is IPv4
147
+ with socket.socket(
148
+ socket.AF_PACKET,
149
+ socket.SOCK_RAW,
150
+ socket.ntohs(0x0800), # pylint: disable=no-member
151
+ ) as sock:
152
+ sock.setblocking(False)
153
+
154
+ while self._loop_forever:
155
+ loop = asyncio.get_running_loop()
156
+
157
+ raw_packet = await loop.sock_recvfrom(sock, 1024)
158
+ (ifname, proto, pkttype, hatype, addr) = raw_packet[1]
159
+ raw_packet = raw_packet[0]
160
+ logger.debug(
161
+ "Received on iface %s (proto %r, pktytype %r, hatype %r, addr %r) data %r",
162
+ ifname,
163
+ socket.ntohs(proto),
164
+ identify_pkttype(pkttype),
165
+ socket.ntohs(hatype),
166
+ human_readable_mac(addr),
167
+ raw_packet,
168
+ )
169
+
170
+ try:
171
+ # Check Level 1 physical layer, i.e. network interface
172
+ if not self.l1filter(ifname):
173
+ logger.debug("Failed l1filter")
174
+ self._loop_forever = self._continue_while_loop()
175
+ continue
176
+
177
+ # Check Level 2 data link layer, i.e. ethernet header
178
+ packet = Packet(raw_packet)
179
+ if not self.l2filter(packet):
180
+ logger.debug("Failed l2filter")
181
+ self._loop_forever = self._continue_while_loop()
182
+ continue
183
+
184
+ # Check Level 3 network layer, i.e. IP protocol
185
+ packet.decode_ip()
186
+ if not self.l3filter(packet):
187
+ logger.debug("Failed l3filter")
188
+ self._loop_forever = self._continue_while_loop()
189
+ continue
190
+
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
197
+ except BadPacketException as bpe:
198
+ logger.debug("Malformed packet %r", bpe)
199
+ self._loop_forever = self._continue_while_loop()
200
+ continue
201
+
202
+ # Send to other relays
203
+ await self._send_to_relays_packet(packet)
204
+ self._loop_forever = self._continue_while_loop()
205
+
206
+ def _continue_while_loop(self) -> bool:
207
+ """This function exists purely to allow unit testing of the start() function above"""
208
+ return self._loop_forever
209
+
210
+ def stop(self) -> None:
211
+ """Stop the main event loop in the start function"""
212
+ self._loop_forever = False
213
+
214
+ def set_remote_relays(self, remote_relays: list[ipaddress.IPv4Address | ipaddress.IPv6Address]) -> None:
215
+ """Update the list of remote relays"""
216
+ # We check if there's a change because although it shouldn't much
217
+ # matter if there's a race condition from making a change we might
218
+ # as well minimise the risk anyway
219
+ if remote_relays != self.remote_relays:
220
+ logger.info("Updating remote relays, will use %s", remote_relays)
221
+ self.remote_relays = remote_relays
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.3
2
+ Name: SnowSignal
3
+ Version: 0.1.1
4
+ Summary: UDP Broadcast Relay
5
+ Project-URL: Repository, https://github.com/ISISNeutronMuon/SnowSignal
6
+ Author-email: Ivan Finch <ivan.finch@stfc.ac.uk>
7
+ Maintainer-email: Ivan Finch <ivan.finch@stfc.ac.uk>
8
+ License-File: LICENSE
9
+ Keywords: UDP,UDP broadcast,docker swarm,epics,pvaccess
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Topic :: System :: Networking
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: configargparse>=1.7
20
+ Requires-Dist: psutil>=5.9
21
+ Provides-Extra: dist
22
+ Requires-Dist: build>=1.2; extra == 'dist'
23
+ Requires-Dist: twine>=5.1; extra == 'dist'
24
+ Provides-Extra: test
25
+ Requires-Dist: coverage>=7.6; extra == 'test'
26
+ Requires-Dist: ruff>0.6; extra == 'test'
27
+ Requires-Dist: scapy~=2.0; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # SnowSignal
31
+ SnowSignal is designed to create a mesh network between instances of the program that will listen for UDP broadcasts received on one node of the network and rebroadcast on all other nodes.
32
+
33
+
34
+ [![pipeline status](https://gitlab.stfc.ac.uk/isis-accelerator-controls/playground/ivan/infrastructure/snowsignal/badges/main/pipeline.svg)](https://gitlab.stfc.ac.uk/isis-accelerator-controls/playground/ivan/infrastructure/snowsignal/-/commits/main)
35
+ [![coverage report](https://gitlab.stfc.ac.uk/isis-accelerator-controls/playground/ivan/infrastructure/snowsignal/badges/main/coverage.svg)](https://gitlab.stfc.ac.uk/isis-accelerator-controls/playground/ivan/infrastructure/snowsignal/-/commits/main)
36
+
37
+ [[_TOC_]]
38
+
39
+ ## Usage
40
+ ### General
41
+ Command line and environment variable options. Environment variables are defined in square brackets like `[env var: THIS]`. In general, command-line values override environment variables which override defaults.
42
+ ```
43
+ usage: snowsignal.py [-h] [-t TARGET_INTERFACE] [-b BROADCAST_PORT] [-m MESH_PORT]
44
+ [--other-relays OTHER_RELAYS [OTHER_RELAYS ...]]
45
+ [-l {debug,info,warning,error,critical}]
46
+ ```
47
+ #### Target Interface
48
+ ```
49
+ -t TARGET_INTERFACE, --target-interface TARGET_INTERFACE
50
+ Target network interface [env var: TARGET_INTERFACE]
51
+ ```
52
+ At this time SnowSignal only supports using a single network interface for receiving UDP broadcasts, sending to other relays, and rebroadcasting UDP messages received from other relays.
53
+ Defaults to `eth0`.
54
+
55
+ #### Broadcast Port
56
+ ```
57
+ -b BROADCAST_PORT, --broadcast-port BROADCAST_PORT
58
+ Port on which to receive and transmit UDP broadcasts [env var: BDCAST_PORT]
59
+ ```
60
+ SnowSignal listens for UDP broadcasts on a single port and rebroadcasts messages received from other SnowSignal instances on the same port. Defaults to port 5076.
61
+
62
+ #### Mesh Port
63
+ ```
64
+ -m MESH_PORT, --mesh-port MESH_PORT
65
+ Port on which this instance will communicate with others via UDP unicast [env var:
66
+ MESH_PORT]
67
+ ```
68
+ UDP port on which to listen for messages from other SnowSignal instances. Defaults to port 7124.
69
+
70
+ #### Other relays
71
+ ```
72
+ --other-relays OTHER_RELAYS [OTHER_RELAYS ...]
73
+ Manually select other relays to transmit received UDP broadcasts to
74
+ ```
75
+ Manually set a list of other SnowSignal instances with which to communicate. In Docker Swarm SnowSingal is capable of auto-discovering instances if the `SERVICENAME` environment variable is set, see "Mesh Network" below. If no other relays are defined via any of these means then SnowSignal will communicate with itself for testing purposes. Default is an empty list.
76
+
77
+ #### Log Level
78
+ ```
79
+ -ll {debug,info,warning,error,critical}, --log-level {debug,info,warning,error,critical}
80
+ Logging level [env var: LOGLEVEL]
81
+ ```
82
+ Set the logging level.
83
+
84
+ ### Docker Swarm
85
+ If run in a Docker Swarm then the default configuration should work well with PVAccess.
86
+
87
+ There is an additional requirement that the environment variable SERVICENAME be set with the Swarm service's name, e.g.
88
+ ```
89
+ environment:
90
+ SERVICENAME: '{{.Service.Name}}'
91
+ ```
92
+
93
+ This allows each node in the service to automatically located and connect to the other nodes. The mesh will automatically heal as members enter and leave.
94
+
95
+ ### Limitations ###
96
+ At this time this code has only been tested in Linux containers.
97
+
98
+ The `UDPRelayTransmit` class requires a raw socket to operate as it needs to
99
+ 1. Filter out UDP broadcasts with an Ethernet source originating from the local relay. The Ethernet source is rewritten to allow this filtering while the IP source is left alone.
100
+ 2. Differentiate UDP broadcast from UDP unicast messages, ignoring the latter.
101
+
102
+ These require Level 1 and 2 access and thus raw sockets. As the Python socket package does not support such access on Windows it has not been possible to make this tool compatible with that OS. (An earlier version using ScaPy was compatible.)
103
+
104
+ ## The Problem
105
+ The EPICS PVAccess protocol uses a mixture of UDP broadcast, UDP unicast and TCP (in roughly that order) to establish communication between a client and a server. In the case relevant to this package a client makes a query for a PV and its value (or some other field), e.g. a pvget while the server holds the requested PV.
106
+
107
+ The image below gives an example of the communication between a client and server and is taken from the [PVAccess Protocol Specification](https://epics-controls.org/wp-content/uploads/2018/10/pvAccess-Protocol-Specification.pdf).
108
+
109
+ ![PVAccess protocol specification communication example](docs/pvacess_communication_example.png)
110
+
111
+ The relevant part for this problem is the initial searchRequest - a UDP broadcast / multicast. (Although the specification requires multicast support at this time I have only ever seen broadcast used.) When a pvget (or equivalent) is performed the first step is a UDP broadcast search request, i.e. a cry to local machines asking if they have the requested PV. If any do they will reply back to the requesting process with a UDP unicast and establish a TCP connection to exchange information.
112
+
113
+ UDP broadcasts are restricted to the network segment of the network interface conducting the broadcast. This means that search requests will not reach machines not on the same network segment. Alternative means suchs as a PVA Gateway or `EPICS_PVA_NAME_SERVERS` must be used in such circumstances. Note that
114
+ - PVA Gateway allows communication between isolated network segments but all subsequent communications must pass through the Gateway, i.e. a many to one to many topology is implicitly created.
115
+ - `EPICS_PVA_NAME_SERVERS` requires only that TCP communication between server and client be possible, but requires servers to be specified in advance.
116
+
117
+ However, if unicast communication between two network segments is possible then we could simply relay the UDP broadcasts between the two networks, allowing UDP unicast and TCP communication to proceed as usual.
118
+
119
+ This is the purpose of SnowSignal. It relays UDP broadcasts received on a specified port to other instances of SnowSignal (i.e. forming a mesh network) that then rebroadcast those UDP broadcasts on their own network segments.
120
+
121
+ **Note**: PVAccess server UDP beacon messages also use UDP broadcast and will be relayed by SnowSignal. Their purpose and consequences is not explored further here.
122
+
123
+ ### Docker Swarm and Docker Networks
124
+ A docker swarm network may be created which crosses transparently between the nodes in the swarm. However, at the time of writing, docker swarm networks do not support UDP multicast or broadcast.
125
+
126
+ For PVAccess this means that search requests are isolated to individual nodes in the swarm. A pvget to a server-container on the same node will succeed, while one to a server-container on another node will fail. (Assuming that PVA Gateway or `EPICS_PVA_NAME_SERVERS` is not used to overcome this limitation.)
127
+
128
+ ## For Developers
129
+ See details on using the [local dev setup](docs/local_dev.md) as well as discussion below.
130
+
131
+ ## Implementation
132
+ SnowSignal is implemented in Python using base Python except for the libraries [ConfigArgParse](https://pypi.org/project/ConfigArgParse/) and [psutil](https://pypi.org/project/psutil/). The [scapy](https://scapy.readthedocs.io/en/latest/) library is used in integration and unit tests to create, send, receive, and manipulate UDP packets.
133
+
134
+ The SnowSignal code is in two main parts:
135
+
136
+ ### 1. udp_relay_transmit
137
+ The UDPTransmitRelay class uses a raw socket to monitor for UDP broadcasts on the specified UDP port and local interface. A set of filter functions are used to filter out broadcasts either originating from local interfaces' MAC addresses or from the local IP addresses. This prevents us from reacting to our own UDP broadcasts.
138
+
139
+ If a UDP broadcast packet passes the required filters then the whole packet is sent to the other SnowSignal instances in the mesh network. They will subsequently rebroadcast it.
140
+
141
+ ### 2. udp_relay_receive
142
+ A UDPReceiveRelay class listens for UDP unicast messages received on a specified port and broadcasts those messages on a specified local interface. The class is an implementation of the asynchio [DatagramProtocol](https://docs.python.org/3/library/asyncio-protocol.html#datagram-protocols) run by using [loop.create_datagram_endpoint()](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.create_datagram_endpoint).
143
+
144
+ When a UDP message is received from another SnowSignal its payload is turned into a UDP broadcast packet. We change only the Ethernet source MAC address of packet, setting it to that of the interface that will be used to send it. This means that it can be filtered out by the `udp_relay_transmit` and we do not create packet storms.
145
+
146
+ ### Mesh Network
147
+ The SnowSignal mesh network may be manually specified.
148
+
149
+ However, in a Docker Swarm environment we can identify the other services by using the DNS entries for `{{.Service.Name}}.tasks`. We then need only remove this nodes IP address from that list to get the other nodes in the mesh. We update the list of mesh nodes from this source every 10 seconds which allows us to accomodate container restarts or migrations and even, in theory, nodes entering and leaving the swarm.
150
+
151
+ ### Observations and Lessons Learned
152
+ A number of issues arose as I was developing this utility:
153
+ - I originally attempted to be clever around preventing a UDP broadcast storm by using a hashes of the UDP packets broadcast by a node member and then rejecting broadcast messages that were subsequently received by the same node. (More specifically a time-to-live dictionary so that packets weren't banned forever.) This proved overly complex and the current implementation simply filters out UDP broadcasts with sources with the same MAC address as the individual nodes.
154
+ - A PVAccess search request includes the IP address and ephemeral port that the unicast UDP reply should use. Experience shows that implementations ignore this in favour of the packet UDP source IP and port. This is why it's ultimately simpler to copy the whole packet and alter it rather than send the payload and construct a new packet around it.
155
+
156
+ ## Origin of Name
157
+ A sensible name for this program would be UDP Broadcast Relay, e.g. UBrR. And brr is being cold. Hence with some helps from a name generator the name SnowSignal.
@@ -0,0 +1,13 @@
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,13 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024 Science and Technology Facilities Council (STFC), UK
4
+
5
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
8
+
9
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
10
+
11
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
12
+
13
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.