netflume 0.1.0__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.
- netflume/__init__.py +109 -0
- netflume/collector.py +285 -0
- netflume/decoder.py +238 -0
- netflume/events.py +84 -0
- netflume/flow.py +267 -0
- netflume/ie.py +133 -0
- netflume/parse.py +519 -0
- netflume/py.typed +0 -0
- netflume/sampling.py +142 -0
- netflume/sequence.py +198 -0
- netflume/values.py +118 -0
- netflume-0.1.0.dist-info/METADATA +931 -0
- netflume-0.1.0.dist-info/RECORD +16 -0
- netflume-0.1.0.dist-info/WHEEL +5 -0
- netflume-0.1.0.dist-info/licenses/LICENSE +21 -0
- netflume-0.1.0.dist-info/top_level.txt +1 -0
netflume/__init__.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""NetFlow v5, NetFlow v9 and IPFIX collection and parsing, as a library.
|
|
2
|
+
|
|
3
|
+
Built for programs rather than for terminals: a daemon writing flows to a
|
|
4
|
+
database, a process forwarding a subset of fields over MQTT, an analyser
|
|
5
|
+
replaying a capture. Standard library only, so it installs anywhere Python
|
|
6
|
+
does and adds nothing to a deployment's dependency surface.
|
|
7
|
+
|
|
8
|
+
Two layers, and either can be used without the other:
|
|
9
|
+
|
|
10
|
+
**Parsing.** :func:`~netflume.parse.parse_message` and friends turn
|
|
11
|
+
bytes into records. No socket, no clock the caller cannot control, no state
|
|
12
|
+
beyond the :class:`~netflume.parse.TemplateStore` you hand in. Feed it
|
|
13
|
+
from a pcap, a queue or a test fixture.
|
|
14
|
+
|
|
15
|
+
**Collection.** :class:`Collector` binds a UDP socket and yields what arrives::
|
|
16
|
+
|
|
17
|
+
from netflume import Collector
|
|
18
|
+
|
|
19
|
+
with Collector(port=2055) as collector:
|
|
20
|
+
for flow in collector.flows():
|
|
21
|
+
print(flow.src_addr, flow.dst_addr, flow.octets)
|
|
22
|
+
|
|
23
|
+
A flow is a plain dict of normalised keys, the same keys whichever version the
|
|
24
|
+
exporter speaks, and :class:`~netflume.flow.Flow` is an opt-in typed view over
|
|
25
|
+
that dict which resolves the field aliases and keeps the dict attached. The
|
|
26
|
+
README documents both shapes.
|
|
27
|
+
|
|
28
|
+
Nothing here prints. Sampling rates, export gaps and undecodable datagrams are
|
|
29
|
+
:mod:`~netflume.events` objects from
|
|
30
|
+
:meth:`~netflume.decoder.Decoder.take_events`, and are also written to
|
|
31
|
+
the ``netflume`` logger for callers who want no more than that. As a
|
|
32
|
+
library should, this package adds nothing but a NullHandler; configure a real
|
|
33
|
+
handler, or records go nowhere.
|
|
34
|
+
|
|
35
|
+
Hostname resolution is not here. It was an optional module that nothing else
|
|
36
|
+
in this package imported, and it now lives on its own as ``lanname``: reverse
|
|
37
|
+
DNS, mDNS and NetBIOS behind a cache, non-blocking, and off until a mode is
|
|
38
|
+
chosen. Install it alongside if a consumer wants names. Nothing here needs it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import logging
|
|
42
|
+
|
|
43
|
+
from .collector import DEFAULT_PORT, DEFAULT_RCVBUF, Collector
|
|
44
|
+
from .decoder import Decoder, Message
|
|
45
|
+
from .events import DecodeError, ExportGap, SamplingChange
|
|
46
|
+
from .flow import METADATA_KEYS, MODELLED_FIELDS, Flow
|
|
47
|
+
from .ie import FLOW_END_REASON, IE, PROTO_NAMES, TCP_FLAG_BITS
|
|
48
|
+
from .parse import (
|
|
49
|
+
IPFIX_HDR,
|
|
50
|
+
SUPPORTED_VERSIONS,
|
|
51
|
+
V5_HDR,
|
|
52
|
+
V5_REC,
|
|
53
|
+
V9_HDR,
|
|
54
|
+
TemplateStore,
|
|
55
|
+
decode_value,
|
|
56
|
+
flow_duration,
|
|
57
|
+
flow_endpoints,
|
|
58
|
+
flow_timestamp,
|
|
59
|
+
parse_data_record,
|
|
60
|
+
parse_message,
|
|
61
|
+
parse_v5,
|
|
62
|
+
parse_v9_or_ipfix,
|
|
63
|
+
read_template_fields,
|
|
64
|
+
record_min_length,
|
|
65
|
+
)
|
|
66
|
+
from .sampling import SamplingWatch, sampling_rate
|
|
67
|
+
from .sequence import (
|
|
68
|
+
MAX_PLAUSIBLE_GAP,
|
|
69
|
+
MAX_REORDER,
|
|
70
|
+
RESYNC_AFTER,
|
|
71
|
+
SEQ_MODULUS,
|
|
72
|
+
SequenceWatch,
|
|
73
|
+
)
|
|
74
|
+
from .values import (
|
|
75
|
+
ADDR_KINDS,
|
|
76
|
+
addr_kind,
|
|
77
|
+
flow_end_reason_name,
|
|
78
|
+
proto_name,
|
|
79
|
+
service_name,
|
|
80
|
+
tcp_flags_str,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
__version__ = "0.1.0"
|
|
84
|
+
|
|
85
|
+
# A library that logs to an unconfigured root logger prints to stderr, which is
|
|
86
|
+
# exactly what this package exists not to do.
|
|
87
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
# collection
|
|
91
|
+
"Collector", "DEFAULT_PORT", "DEFAULT_RCVBUF", "Decoder", "Message",
|
|
92
|
+
# the flow shape
|
|
93
|
+
"Flow", "MODELLED_FIELDS", "METADATA_KEYS",
|
|
94
|
+
# parsing
|
|
95
|
+
"TemplateStore", "parse_message", "parse_v5", "parse_v9_or_ipfix",
|
|
96
|
+
"parse_data_record", "read_template_fields", "record_min_length",
|
|
97
|
+
"decode_value", "flow_endpoints", "flow_timestamp", "flow_duration",
|
|
98
|
+
"SUPPORTED_VERSIONS", "V5_HDR", "V5_REC", "V9_HDR", "IPFIX_HDR",
|
|
99
|
+
# what the exporter says about itself
|
|
100
|
+
"SamplingWatch", "sampling_rate", "SequenceWatch", "SEQ_MODULUS",
|
|
101
|
+
"MAX_PLAUSIBLE_GAP", "MAX_REORDER", "RESYNC_AFTER",
|
|
102
|
+
# events
|
|
103
|
+
"ExportGap", "SamplingChange", "DecodeError",
|
|
104
|
+
# field level helpers
|
|
105
|
+
"addr_kind", "ADDR_KINDS", "service_name", "proto_name", "tcp_flags_str",
|
|
106
|
+
"flow_end_reason_name", "IE", "PROTO_NAMES", "FLOW_END_REASON",
|
|
107
|
+
"TCP_FLAG_BITS",
|
|
108
|
+
"__version__",
|
|
109
|
+
]
|
netflume/collector.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Binding the socket and handing back flows.
|
|
2
|
+
|
|
3
|
+
:class:`Collector` is a UDP socket, a :class:`~netflume.decoder.Decoder`
|
|
4
|
+
and three ways to get at what arrives:
|
|
5
|
+
|
|
6
|
+
* ``for rec, hdr in collector:`` gives plain dicts, the cheapest form
|
|
7
|
+
* ``for flow in collector.flows():`` gives typed objects, built on demand
|
|
8
|
+
* ``message = collector.poll(timeout)`` gives one datagram, or None, for a
|
|
9
|
+
caller who owns the event loop
|
|
10
|
+
|
|
11
|
+
It prints nothing, exits nothing, and reads no arguments. Everything it would
|
|
12
|
+
otherwise have to say arrives as an event from
|
|
13
|
+
:meth:`~netflume.decoder.Decoder.take_events` or on the
|
|
14
|
+
``netflume`` logger.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import errno
|
|
18
|
+
import logging
|
|
19
|
+
import selectors
|
|
20
|
+
import socket
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from .decoder import Decoder
|
|
24
|
+
|
|
25
|
+
__all__ = ["Collector", "DEFAULT_PORT", "DEFAULT_RCVBUF"]
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
#: The port every NetFlow exporter defaults to. Not registered to anything and
|
|
30
|
+
#: not privileged, so a daemon need not run as root.
|
|
31
|
+
DEFAULT_PORT = 2055
|
|
32
|
+
|
|
33
|
+
#: 4 MB of kernel receive buffer. UDP has no backpressure: a buffer that fills
|
|
34
|
+
#: while this process is busy drops datagrams silently and the only trace is a
|
|
35
|
+
#: sequence gap. Ask for more than the system default, and do not fail if the
|
|
36
|
+
#: system says no.
|
|
37
|
+
DEFAULT_RCVBUF = 4 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
#: The largest datagram to accept. A NetFlow export never approaches this;
|
|
40
|
+
#: it is simply the largest a UDP payload can be.
|
|
41
|
+
MAX_DATAGRAM = 65535
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Collector:
|
|
45
|
+
"""A bound UDP socket that yields decoded NetFlow.
|
|
46
|
+
|
|
47
|
+
::
|
|
48
|
+
|
|
49
|
+
with Collector(port=2055) as collector:
|
|
50
|
+
for rec, hdr in collector:
|
|
51
|
+
store(rec, hdr)
|
|
52
|
+
|
|
53
|
+
The socket is bound in the constructor, so a port already in use raises
|
|
54
|
+
OSError there rather than at first read. Use ``with``, or call
|
|
55
|
+
:meth:`close`, so the socket is released promptly; the alternative is a
|
|
56
|
+
port left held until the garbage collector gets round to it.
|
|
57
|
+
|
|
58
|
+
Iteration is endless by design: a collector with nothing to report is a
|
|
59
|
+
quiet network, not a finished job. Break out of the loop, or call
|
|
60
|
+
:meth:`stop` from a signal handler or another thread.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, port=DEFAULT_PORT, bind="0.0.0.0", decoder=None,
|
|
64
|
+
timeout=1.0, rcvbuf=DEFAULT_RCVBUF, reuse_address=True,
|
|
65
|
+
sock=None):
|
|
66
|
+
"""Bind and get ready to receive.
|
|
67
|
+
|
|
68
|
+
port, bind where to listen. "0.0.0.0" is every IPv4 interface; name
|
|
69
|
+
one interface if the machine is multi-homed and only one
|
|
70
|
+
of them faces the exporters.
|
|
71
|
+
decoder an existing Decoder, if you want to share template state
|
|
72
|
+
with something else or to have turned tracking off.
|
|
73
|
+
timeout how long the blocking reads inside iteration wait before
|
|
74
|
+
coming up for air. It bounds how long :meth:`stop` takes
|
|
75
|
+
to be noticed; it is not a deadline on receiving.
|
|
76
|
+
rcvbuf kernel receive buffer to request, or None to leave the
|
|
77
|
+
system default alone.
|
|
78
|
+
reuse_address set SO_REUSEADDR before binding. Default True, which lets
|
|
79
|
+
a restart of this process take the port back immediately
|
|
80
|
+
instead of waiting for the old socket to clear. The cost
|
|
81
|
+
is that on UDP it also lets a *second* process bind the
|
|
82
|
+
same port, and then only one of them receives: a
|
|
83
|
+
collector that looks bound and healthy while another
|
|
84
|
+
process quietly takes its traffic. Pass False to make
|
|
85
|
+
that clash raise OSError here instead of going silent.
|
|
86
|
+
sock an already-bound socket to use instead of making one,
|
|
87
|
+
which is how a test drives this without a real exporter.
|
|
88
|
+
"""
|
|
89
|
+
self.decoder = decoder if decoder is not None else Decoder()
|
|
90
|
+
self.timeout = timeout
|
|
91
|
+
self._closed = False
|
|
92
|
+
self._stopping = False
|
|
93
|
+
|
|
94
|
+
if sock is None:
|
|
95
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
96
|
+
if reuse_address:
|
|
97
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
98
|
+
if rcvbuf:
|
|
99
|
+
try:
|
|
100
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
log.debug("could not set SO_RCVBUF to %d: %s", rcvbuf, exc)
|
|
103
|
+
try:
|
|
104
|
+
sock.bind((bind, port))
|
|
105
|
+
except OSError:
|
|
106
|
+
sock.close()
|
|
107
|
+
raise
|
|
108
|
+
self.socket = sock
|
|
109
|
+
self.socket.setblocking(False)
|
|
110
|
+
|
|
111
|
+
# A socketpair rather than a pipe: select() on Windows takes sockets
|
|
112
|
+
# and nothing else, and stop() has to be able to interrupt a wait from
|
|
113
|
+
# another thread or a signal handler.
|
|
114
|
+
self._waker_r, self._waker_w = socket.socketpair()
|
|
115
|
+
self._waker_r.setblocking(False)
|
|
116
|
+
self._selector = selectors.DefaultSelector()
|
|
117
|
+
self._selector.register(self.socket, selectors.EVENT_READ)
|
|
118
|
+
self._selector.register(self._waker_r, selectors.EVENT_READ)
|
|
119
|
+
|
|
120
|
+
log.info("listening for NetFlow/IPFIX on %s", self.address)
|
|
121
|
+
|
|
122
|
+
# == properties ==========================================================
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def address(self):
|
|
126
|
+
"""The (host, port) actually bound, which matters when port was 0."""
|
|
127
|
+
return self.socket.getsockname()
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def stats(self):
|
|
131
|
+
"""The decoder's running counters. See the README for the keys."""
|
|
132
|
+
return self.decoder.stats
|
|
133
|
+
|
|
134
|
+
def fileno(self):
|
|
135
|
+
"""The socket's descriptor, so this can go straight into a caller's
|
|
136
|
+
own ``selectors`` or ``asyncio`` loop. Pair it with
|
|
137
|
+
``poll(timeout=0)``, which never blocks."""
|
|
138
|
+
return self.socket.fileno()
|
|
139
|
+
|
|
140
|
+
# == receiving ===========================================================
|
|
141
|
+
|
|
142
|
+
def poll(self, timeout=0):
|
|
143
|
+
"""Wait up to `timeout` seconds for one datagram and decode it.
|
|
144
|
+
|
|
145
|
+
Returns a :class:`~netflume.decoder.Message`, or None if
|
|
146
|
+
nothing arrived in time or what arrived would not decode. The two are
|
|
147
|
+
deliberately not distinguished here: neither is an error, and a caller
|
|
148
|
+
who cares can read :meth:`~netflume.decoder.Decoder.take_events`.
|
|
149
|
+
|
|
150
|
+
``timeout=0`` polls without blocking, ``timeout=None`` waits
|
|
151
|
+
indefinitely.
|
|
152
|
+
|
|
153
|
+
Raises ValueError once :meth:`close` has been called. Iteration and
|
|
154
|
+
:meth:`flows` stop quietly instead, because a loop that has been shut
|
|
155
|
+
down is finishing normally, whereas asking a closed collector for one
|
|
156
|
+
more datagram is a mistake worth hearing about.
|
|
157
|
+
"""
|
|
158
|
+
if self._closed:
|
|
159
|
+
raise ValueError("collector is closed")
|
|
160
|
+
data, addr = self._receive(timeout, honour_stop=False)
|
|
161
|
+
if data is None:
|
|
162
|
+
return None
|
|
163
|
+
return self.decoder.decode(data, addr[0])
|
|
164
|
+
|
|
165
|
+
def messages(self, timeout=None):
|
|
166
|
+
"""Yield one :class:`~netflume.decoder.Message` per datagram.
|
|
167
|
+
|
|
168
|
+
Endless, and skips datagrams that do not decode. Use this rather than
|
|
169
|
+
:meth:`__iter__` when the sequence number, the export time or the
|
|
170
|
+
option records matter, since those belong to a message, not a flow.
|
|
171
|
+
"""
|
|
172
|
+
if timeout is None:
|
|
173
|
+
timeout = self.timeout
|
|
174
|
+
while not self._stopping and not self._closed:
|
|
175
|
+
data, addr = self._receive(timeout)
|
|
176
|
+
if data is None:
|
|
177
|
+
continue
|
|
178
|
+
message = self.decoder.decode(data, addr[0])
|
|
179
|
+
if message is not None:
|
|
180
|
+
yield message
|
|
181
|
+
|
|
182
|
+
def __iter__(self):
|
|
183
|
+
"""Yield (record, header) for every flow, as plain dicts.
|
|
184
|
+
|
|
185
|
+
The header comes along because a v5 or v9 record cannot be timestamped
|
|
186
|
+
without it. This is the cheapest shape: no objects are built beyond
|
|
187
|
+
what the parser already made.
|
|
188
|
+
"""
|
|
189
|
+
for message in self.messages():
|
|
190
|
+
header = message.header
|
|
191
|
+
for rec in message.flows:
|
|
192
|
+
yield rec, header
|
|
193
|
+
|
|
194
|
+
def flows(self, now=None):
|
|
195
|
+
"""Yield a :class:`~netflume.flow.Flow` for every flow.
|
|
196
|
+
|
|
197
|
+
The typed shape: aliases resolved, timestamp derived, sampling rate
|
|
198
|
+
attached, and the original dict still on ``flow.raw``.
|
|
199
|
+
"""
|
|
200
|
+
for message in self.messages():
|
|
201
|
+
for flow in message.typed_flows(now=now):
|
|
202
|
+
yield flow
|
|
203
|
+
|
|
204
|
+
# == shutting down =======================================================
|
|
205
|
+
|
|
206
|
+
def stop(self):
|
|
207
|
+
"""Ask the iterators to finish. Safe from a signal handler or thread.
|
|
208
|
+
|
|
209
|
+
Returns immediately; the loop stops after at most one `timeout`. The
|
|
210
|
+
socket is left open, so a stopped collector can be read again with
|
|
211
|
+
:meth:`poll`. Call :meth:`close` to release it.
|
|
212
|
+
"""
|
|
213
|
+
self._stopping = True
|
|
214
|
+
try:
|
|
215
|
+
self._waker_w.send(b"\x00")
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
def close(self):
|
|
220
|
+
"""Release the socket. Idempotent, and implied by leaving a ``with``."""
|
|
221
|
+
if self._closed:
|
|
222
|
+
return
|
|
223
|
+
self._closed = True
|
|
224
|
+
self._stopping = True
|
|
225
|
+
self._selector.close()
|
|
226
|
+
for handle in (self.socket, self._waker_r, self._waker_w):
|
|
227
|
+
try:
|
|
228
|
+
handle.close()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def __enter__(self):
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *exc):
|
|
236
|
+
self.close()
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
# == the one place that touches the socket ===============================
|
|
240
|
+
|
|
241
|
+
def _receive(self, timeout, honour_stop=True):
|
|
242
|
+
"""(data, addr), or (None, None) if nothing was ready in time.
|
|
243
|
+
|
|
244
|
+
`honour_stop` is what separates the two callers. An iterator wants a
|
|
245
|
+
stop() to end its wait immediately, however long the timeout was.
|
|
246
|
+
poll() is a single explicit read and answers the question it was
|
|
247
|
+
asked, stopped or not.
|
|
248
|
+
"""
|
|
249
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
250
|
+
while True:
|
|
251
|
+
remaining = (None if deadline is None
|
|
252
|
+
else max(0.0, deadline - time.monotonic()))
|
|
253
|
+
ready = self._selector.select(remaining)
|
|
254
|
+
if not ready:
|
|
255
|
+
return None, None
|
|
256
|
+
|
|
257
|
+
woken = False
|
|
258
|
+
for key, _events in ready:
|
|
259
|
+
if key.fileobj is self._waker_r:
|
|
260
|
+
woken = True
|
|
261
|
+
try:
|
|
262
|
+
self._waker_r.recv(4096) # drain, it carries no data
|
|
263
|
+
except OSError:
|
|
264
|
+
pass
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
return self.socket.recvfrom(MAX_DATAGRAM)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
if exc.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
|
|
270
|
+
return None, None
|
|
271
|
+
# WSAECONNRESET: Windows reports an ICMP port-unreachable
|
|
272
|
+
# caused by an earlier send as an error on the *next* read
|
|
273
|
+
# of a UDP socket. Nothing is wrong with this socket.
|
|
274
|
+
if getattr(exc, "winerror", None) == 10054:
|
|
275
|
+
log.debug("ignoring a stale ICMP error on the socket")
|
|
276
|
+
return None, None
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
# Only the wakeup fired. Go round again with whatever time is left,
|
|
280
|
+
# rather than reporting nothing: stop() must not cost the caller a
|
|
281
|
+
# datagram that was already queued behind it.
|
|
282
|
+
if not woken or (honour_stop and self._stopping):
|
|
283
|
+
return None, None
|
|
284
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
285
|
+
return None, None
|
netflume/decoder.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Turning datagrams into messages, and remembering what that implies.
|
|
2
|
+
|
|
3
|
+
:class:`Decoder` is the parsing layer with its state attached: the template
|
|
4
|
+
store that has to outlive a datagram, the sequence watch that can only find a
|
|
5
|
+
gap by comparing one message with the last, and the sampling watch that has to
|
|
6
|
+
remember what an exporter said an hour ago. It has no socket in it, so a caller
|
|
7
|
+
holding bytes from a pcap, a queue or a test fixture gets the same answers a
|
|
8
|
+
live collector would.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import struct
|
|
13
|
+
from collections import Counter, deque
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Dict, List, Mapping
|
|
16
|
+
|
|
17
|
+
from .events import DecodeError
|
|
18
|
+
from .flow import Flow
|
|
19
|
+
from .parse import SUPPORTED_VERSIONS, TemplateStore, parse_v5, parse_v9_or_ipfix
|
|
20
|
+
from .sampling import SamplingWatch
|
|
21
|
+
from .sequence import SequenceWatch
|
|
22
|
+
|
|
23
|
+
__all__ = ["Decoder", "MAX_PENDING_EVENTS", "Message"]
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
#: Events held for :meth:`Decoder.take_events` before the oldest are dropped.
|
|
28
|
+
#: A collector on an open UDP port receives things that are not NetFlow, such
|
|
29
|
+
#: as scanners, sFlow on the same port, or a misconfigured exporter, and each
|
|
30
|
+
#: one raises a DecodeError. A caller that never drains would otherwise
|
|
31
|
+
#: accumulate one per bad datagram for as long as the process runs. Drops are
|
|
32
|
+
#: counted in ``stats["events_dropped"]`` so that the loss is visible rather
|
|
33
|
+
#: than silent.
|
|
34
|
+
MAX_PENDING_EVENTS = 10000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Message:
|
|
39
|
+
"""One export datagram, decoded.
|
|
40
|
+
|
|
41
|
+
The message is the unit that arrives on the wire, and some things are only
|
|
42
|
+
true of a message rather than of a flow: the sequence number, the export
|
|
43
|
+
time, and whether anything was lost getting here.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
#: The header dict: version, exporter, domain, sequence, unix_secs,
|
|
47
|
+
#: sys_uptime. Needed to timestamp a v5 or v9 flow, so keep it with the
|
|
48
|
+
#: records rather than discarding it.
|
|
49
|
+
header: Mapping[str, Any]
|
|
50
|
+
#: Flow records, as plain dicts of normalised keys.
|
|
51
|
+
flows: List[Dict[str, Any]] = field(default_factory=list)
|
|
52
|
+
#: Option records: what the exporter says about itself, not about traffic.
|
|
53
|
+
#: Sampling rates are read out of these automatically; interface names and
|
|
54
|
+
#: the like are left for the caller to use or ignore.
|
|
55
|
+
options: List[Dict[str, Any]] = field(default_factory=list)
|
|
56
|
+
#: Exports the sequence counter says never arrived before this message. 0
|
|
57
|
+
#: is the normal answer, and is also the answer while the stream is still
|
|
58
|
+
#: being learned. See :class:`~netflume.sequence.SequenceWatch`.
|
|
59
|
+
gap: int = 0
|
|
60
|
+
#: The 1-in-N rate in force for this exporter, 1 if unsampled or unstated.
|
|
61
|
+
sampling_rate: int = 1
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def exporter(self):
|
|
65
|
+
return self.header.get("exporter")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def version(self):
|
|
69
|
+
return self.header.get("version")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def sequence(self):
|
|
73
|
+
return self.header.get("sequence")
|
|
74
|
+
|
|
75
|
+
def typed_flows(self, now=None):
|
|
76
|
+
"""The flow records as :class:`~netflume.flow.Flow` objects.
|
|
77
|
+
|
|
78
|
+
Built on demand, not up front: a consumer that wants two fields out of
|
|
79
|
+
a dict should not pay to construct thirty.
|
|
80
|
+
"""
|
|
81
|
+
return [Flow.from_record(rec, self.header, self.sampling_rate, now=now)
|
|
82
|
+
for rec in self.flows]
|
|
83
|
+
|
|
84
|
+
def __len__(self):
|
|
85
|
+
return len(self.flows)
|
|
86
|
+
|
|
87
|
+
def __bool__(self):
|
|
88
|
+
"""Always True. A Message exists or it is None; there is no empty one.
|
|
89
|
+
|
|
90
|
+
Without this, ``__len__`` makes a message carrying only template sets,
|
|
91
|
+
only option records, or only a sequence gap evaluate false, and the
|
|
92
|
+
natural ``if message:`` after :meth:`~netflume.collector.Collector.poll`
|
|
93
|
+
silently discards all three. Ask ``if message is not None`` to test for
|
|
94
|
+
a decode, and ``len(message)`` or ``message.flows`` for traffic.
|
|
95
|
+
"""
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Decoder:
|
|
100
|
+
"""Stateful decoding of one exporter's datagrams, or of many.
|
|
101
|
+
|
|
102
|
+
Templates, sequence tracking and sampling rates are all keyed by
|
|
103
|
+
(exporter, observation domain) inside, so one Decoder can serve every device
|
|
104
|
+
sending to a collector, and a device running several domains keeps them
|
|
105
|
+
apart. It is not thread safe; give each thread its own, or put one behind a
|
|
106
|
+
queue.
|
|
107
|
+
|
|
108
|
+
The per-exporter tables have ceilings, namely MAX_TEMPLATES, MAX_STREAMS
|
|
109
|
+
and MAX_SAMPLING_STREAMS, because a UDP source address is whatever the
|
|
110
|
+
sender typed, and anything keyed by one and never evicted is a memory leak
|
|
111
|
+
that anyone able to reach the socket can pull on.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, track_sequence=True, track_sampling=True):
|
|
115
|
+
self.templates = TemplateStore()
|
|
116
|
+
self.stats = Counter()
|
|
117
|
+
self.sequence = SequenceWatch() if track_sequence else None
|
|
118
|
+
self.sampling = SamplingWatch() if track_sampling else None
|
|
119
|
+
self._events = deque()
|
|
120
|
+
|
|
121
|
+
def decode(self, data, exporter):
|
|
122
|
+
"""Decode one datagram. Returns a :class:`Message`, or None.
|
|
123
|
+
|
|
124
|
+
None means nothing usable was in it: too short, a version this package
|
|
125
|
+
does not decode, or a body that would not parse. Every one of those is
|
|
126
|
+
counted in :attr:`stats` and queued as a
|
|
127
|
+
:class:`~netflume.events.DecodeError`; none of them raises,
|
|
128
|
+
because a decoder that dies on one bad packet is useless on a real
|
|
129
|
+
network.
|
|
130
|
+
|
|
131
|
+
A Message with no flows in it is not a failure. Template sets, option
|
|
132
|
+
records and data sets whose template has not arrived yet all produce
|
|
133
|
+
one, and an exporter's first few datagrams are routinely all template.
|
|
134
|
+
"""
|
|
135
|
+
self.stats["packets"] += 1
|
|
136
|
+
self.stats["bytes_rx"] += len(data)
|
|
137
|
+
|
|
138
|
+
if len(data) < 2:
|
|
139
|
+
return self._fail(exporter, "short", "fewer than two bytes")
|
|
140
|
+
|
|
141
|
+
version = struct.unpack_from("!H", data, 0)[0]
|
|
142
|
+
if version not in SUPPORTED_VERSIONS:
|
|
143
|
+
self.stats["unsupported_version"] += 1
|
|
144
|
+
return self._fail(exporter, "unsupported",
|
|
145
|
+
f"version {version}", counted=True)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
if version == 5:
|
|
149
|
+
hdr, records, opts = parse_v5(data, exporter)
|
|
150
|
+
else:
|
|
151
|
+
hdr, records, opts = parse_v9_or_ipfix(
|
|
152
|
+
data, exporter, self.templates, self.stats)
|
|
153
|
+
except Exception as exc: # keep listening even on a bad datagram
|
|
154
|
+
self.stats["parse_errors"] += 1
|
|
155
|
+
return self._fail(exporter, "malformed",
|
|
156
|
+
f"{type(exc).__name__}: {exc}", counted=True)
|
|
157
|
+
|
|
158
|
+
if hdr is None:
|
|
159
|
+
return self._fail(exporter, "short", "truncated header")
|
|
160
|
+
|
|
161
|
+
self.stats[f"v{version}_msgs"] += 1
|
|
162
|
+
self.stats["flows"] += len(records)
|
|
163
|
+
self.stats["option_records"] += len(opts)
|
|
164
|
+
|
|
165
|
+
gap = 0
|
|
166
|
+
if self.sequence is not None:
|
|
167
|
+
gap = self.sequence.observe(exporter, hdr.get("domain"), version,
|
|
168
|
+
hdr.get("sequence"), len(records),
|
|
169
|
+
len(opts))
|
|
170
|
+
if gap:
|
|
171
|
+
self.stats["missed_exports"] += gap
|
|
172
|
+
self._queue(self.sequence.take_events())
|
|
173
|
+
|
|
174
|
+
rate = 1
|
|
175
|
+
if self.sampling is not None:
|
|
176
|
+
domain = hdr.get("domain")
|
|
177
|
+
for opt in opts:
|
|
178
|
+
self.sampling.note(exporter, domain, opt)
|
|
179
|
+
self._queue(self.sampling.take_events())
|
|
180
|
+
rate = self.sampling.rate_for(exporter, domain)
|
|
181
|
+
|
|
182
|
+
return Message(header=hdr, flows=records, options=opts, gap=gap,
|
|
183
|
+
sampling_rate=rate)
|
|
184
|
+
|
|
185
|
+
def flows(self, data, exporter, typed=False, now=None):
|
|
186
|
+
"""Decode a datagram straight to its flows, discarding the rest.
|
|
187
|
+
|
|
188
|
+
The convenience form for a caller who has bytes and wants records. The
|
|
189
|
+
header is still needed to timestamp them, so `typed=True` is the shape
|
|
190
|
+
to use if you want start times; the dict form leaves that to you.
|
|
191
|
+
"""
|
|
192
|
+
message = self.decode(data, exporter)
|
|
193
|
+
if message is None:
|
|
194
|
+
return []
|
|
195
|
+
return message.typed_flows(now=now) if typed else message.flows
|
|
196
|
+
|
|
197
|
+
# == what the caller may want to know ====================================
|
|
198
|
+
|
|
199
|
+
def _queue(self, events):
|
|
200
|
+
"""Add events to the pending queue, oldest dropped past the ceiling."""
|
|
201
|
+
for event in events:
|
|
202
|
+
if len(self._events) >= MAX_PENDING_EVENTS:
|
|
203
|
+
self._events.popleft()
|
|
204
|
+
self.stats["events_dropped"] += 1
|
|
205
|
+
self._events.append(event)
|
|
206
|
+
|
|
207
|
+
def take_events(self):
|
|
208
|
+
"""Hand over everything raised since the last call, and forget it.
|
|
209
|
+
|
|
210
|
+
A list of :class:`~netflume.events.ExportGap`,
|
|
211
|
+
:class:`~netflume.events.SamplingChange` and
|
|
212
|
+
:class:`~netflume.events.DecodeError`. Empty is the normal
|
|
213
|
+
answer on a healthy network, so this is cheap to call in a loop.
|
|
214
|
+
"""
|
|
215
|
+
events = list(self._events)
|
|
216
|
+
self._events.clear()
|
|
217
|
+
return events
|
|
218
|
+
|
|
219
|
+
def sampling_rate(self, exporter, domain=None):
|
|
220
|
+
"""The 1-in-N rate last advertised, or 1.
|
|
221
|
+
|
|
222
|
+
Rates are scoped to an observation domain, so name one. Omitting it
|
|
223
|
+
answers for the exporter as a whole, and only when its domains agree.
|
|
224
|
+
See :meth:`~netflume.sampling.SamplingWatch.rate_for`.
|
|
225
|
+
"""
|
|
226
|
+
return self.sampling.rate_for(exporter, domain) if self.sampling else 1
|
|
227
|
+
|
|
228
|
+
def export_gaps(self):
|
|
229
|
+
"""Cumulative loss per stream, as ExportGap records, worst first."""
|
|
230
|
+
return self.sequence.gaps() if self.sequence else []
|
|
231
|
+
|
|
232
|
+
def _fail(self, exporter, reason, detail, counted=False):
|
|
233
|
+
if not counted:
|
|
234
|
+
self.stats["malformed"] += 1
|
|
235
|
+
error = DecodeError(exporter, reason, detail)
|
|
236
|
+
self._queue((error,))
|
|
237
|
+
log.debug("%s", error)
|
|
238
|
+
return None
|