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 +0 -0
- snowsignal/__main__.py +12 -0
- snowsignal/configure.py +106 -0
- snowsignal/dockerfile +9 -0
- snowsignal/netutils.py +137 -0
- snowsignal/packet.py +174 -0
- snowsignal/snowsignal.py +130 -0
- snowsignal/udp_relay_receive.py +196 -0
- snowsignal/udp_relay_transmit.py +221 -0
- snowsignal-0.1.1.dist-info/METADATA +157 -0
- snowsignal-0.1.1.dist-info/RECORD +13 -0
- snowsignal-0.1.1.dist-info/WHEEL +4 -0
- snowsignal-0.1.1.dist-info/licenses/LICENSE +13 -0
snowsignal/__init__.py
ADDED
File without changes
|
snowsignal/__main__.py
ADDED
snowsignal/configure.py
ADDED
@@ -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:]
|
snowsignal/snowsignal.py
ADDED
@@ -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
|
+
[](https://gitlab.stfc.ac.uk/isis-accelerator-controls/playground/ivan/infrastructure/snowsignal/-/commits/main)
|
35
|
+
[](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
|
+

|
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,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.
|