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