netflume
NetFlow v5, NetFlow v9 and IPFIX collection and parsing, as a library. Standard library only, no dependencies, Python 3.9 and up.
from netflume import Collector
with Collector(port=2055) as collector:
for flow in collector.flows():
print(flow.src_addr, "->", flow.dst_addr, flow.octets, "bytes")
netflume decodes the three protocols a router is likely to speak, tracks the
templates v9 and IPFIX need in order to be decodable at all, and surfaces the
two things an exporter says about itself that change what its numbers mean: the
sampling rate, and gaps in the export sequence. It does not render, aggregate,
store or alert. Those are the caller's business, and keeping them out is what
makes the same package usable by a database writer, a message-bus forwarder and
an offline analyser without any of them fighting the others.
Three things are optimised for, in this order when they conflict:
Performance. A busy edge router emits tens of thousands of flows a second, and a collector that falls behind loses data silently, because UDP has no retransmission. Decoding is allocation-conscious, the typed layer is opt-in rather than mandatory, and the counters needed to prove nothing was lost are maintained as part of normal operation rather than bolted on.
Versatility. The parser is separable from the socket: feed it from a
capture, a queue or a test fixture. The socket layer offers a blocking
iterator, a bounded poll and a raw fileno(), so it fits a thread, a loop of
your own design, or a selectors reactor without adapters.
Ease of use. One import, four lines to a working collector. Field aliases that differ between exporters are resolved in one place instead of at every call site. Every diagnostic is an object you can branch on, and also a log record if that is all you want.
Contents
- Installing
- The two layers
- Collector
- Decoder and Message
- The flow shape
- Flow, the typed view
- Events
- Sampling
- Export gaps
- Counters
- Ceilings
- Logging
- Hostname resolution
- Parsing without a socket
- Sketch: logging flows to a database
- Sketch: forwarding a subset over MQTT
- Protocol support
- Tests
- Limitations
- Everything else exported
- Roadmap
- Licence
Installing
pip install netflume
Or from a checkout with pip install ., or by copying the netflume/ directory
next to your code. There are no dependencies to resolve either way.
import netflume
netflume.__version__ # "0.3.0"
The two layers
Parsing turns bytes into records. No socket, no threads, no clock the caller cannot control. Feed it from a pcap, a queue, or a test fixture.
Collection binds a UDP socket and hands you what arrives.
Either works without the other. If you already have datagrams from somewhere, skip to Parsing without a socket.
Collector
Collector(port=2055, bind="0.0.0.0", decoder=None, timeout=1.0,
rcvbuf=4*1024*1024, reuse_address=True, sock=None)
The socket is bound in the constructor, so a port already in use raises
OSError there rather than at first read. Use with, or call close().
port, bind |
where to listen. Name an interface if only one faces the exporters. |
decoder |
an existing Decoder, to share template state or turn tracking off. |
timeout |
how long the blocking reads inside iteration wait before coming up for air. Bounds how quickly stop() is noticed; it is not a deadline on receiving. |
rcvbuf |
kernel receive buffer to request. None leaves the system default. |
reuse_address |
set SO_REUSEADDR before binding, default True. A restart takes the port back immediately rather than waiting for the old socket. On UDP it also lets a second process bind the same port, after which only one of them receives, so if a bound collector sees no traffic, suspect this first. False makes the clash raise OSError at construction instead. |
sock |
an already-bound socket to use instead of making one. |
Three ways to read it
for rec, hdr in collector: plain dicts, the cheapest form
for flow in collector.flows(): typed, built on demand
for message in collector.messages(): one per datagram
message = collector.poll(timeout=0) one datagram, or None
__iter__ yields (record, header) rather than a bare record because a v5 or
v9 record cannot be timestamped without its header. The flow start is
milliseconds since the exporter booted, and turning that into a wall clock time
needs the header's uptime and export time.
All three iterators are endless: a collector with nothing to report is a quiet
network, not a finished job. Break out of the loop, or call stop().
Fitting into an existing event loop
Collector has a fileno(), so it goes straight into selectors or
asyncio.loop.add_reader. Pair it with poll(timeout=0), which never blocks.
import selectors
sel = selectors.DefaultSelector()
sel.register(collector, selectors.EVENT_READ)
while True:
for key, _ in sel.select(timeout=1.0):
message = collector.poll(timeout=0)
if message is not None:
handle(message)
poll returns None both when nothing arrived and when what arrived would not
decode. Neither is an error; read collector.decoder.take_events() if you want
to know which.
Shutting down
import signal
signal.signal(signal.SIGTERM, lambda *_: collector.stop())
stop() is safe from a signal handler or another thread and returns
immediately; the iterators finish within one timeout. It leaves the socket
open, so a stopped collector can still be polled. close() releases it and
is implied by leaving a with block.
| method | |
|---|---|
poll(timeout=0) |
one Message or None. timeout=None waits indefinitely. Raises ValueError once the collector is closed. |
messages(timeout=None) |
endless iterator of Message |
flows(now=None) |
endless iterator of Flow |
stop() |
end the iterators; safe from any thread |
close() |
release the socket; idempotent |
address |
the (host, port) actually bound, which matters when port=0 |
stats |
the decoder's counters |
fileno() |
the socket descriptor |
Decoder and Message
Decoder is the parsing layer with its state attached: the template store,
the sequence watch, the sampling watch. It has no socket in it.
from netflume import Decoder
decoder = Decoder() # track_sequence=True, track_sampling=True
message = decoder.decode(data, "10.0.0.1")
decode returns a Message, or None if the datagram was too short, carried an
unsupported version, or would not parse. It never raises: a decoder that
dies on one bad packet is useless on a real network. Every failure is counted
and queued as a DecodeError.
A Message with no flows in it is not a failure. Template sets, option
records, and data sets whose template has not arrived yet all produce one, and
an exporter's first few datagrams are routinely all template. len(message)
counts the flows; a message is never falsy, so if message: after a poll
does not quietly discard the template-only ones.
decoder.flows(data, exporter, typed=False, now=None) is the short form for a
caller who has bytes and wants only the records: it decodes and returns the
flow list, or the Flow objects with typed=True, and
gives an empty list rather than None when the datagram did not decode.
Message |
|
|---|---|
.header |
the message header dict |
.flows |
list of flow record dicts |
.options |
list of option record dicts, what the exporter says about itself |
.gap |
exports the sequence counter says never arrived before this message |
.sampling_rate |
the 1-in-N rate in force for this message's exporter and observation domain |
.exporter, .version, .sequence |
read off the header |
.typed_flows(now=None) |
the flows as Flow objects |
One Decoder serves every exporter sending to you. Templates, sequence
tracking and sampling are all keyed by (exporter, observation domain) inside,
because one chassis can run several domains, sample them differently and count
their sequence numbers separately. It is not thread safe. Give each thread
its own, or put one behind a queue.
The flow shape
A flow record is a plain dict. The keys are normalised, so v5, v9 and IPFIX
produce the same key for the same idea and you write one schema, not three.
Every key is optional. Exporters send what they are configured to send, and
absent is not zero: a flow with no octets is not a flow that carried
nothing.
Common keys
| key | from | meaning |
|---|---|---|
src_addr, dst_addr |
IE 8/12, 27/28 | address strings. IPv4 and IPv6 share these keys, and one field carries either. A template carrying both families zero-fills the unused one, and the zeros never displace the populated family. |
src_port, dst_port |
IE 7, 11 | int |
proto |
IE 4 | IP protocol number |
octets, packets |
IE 1, 2 | this flow's counts |
octets_total, packets_total |
IE 85, 86 | the same idea, different elements. Exporters send one pair or the other. |
tcp_flags |
IE 6 | flags seen over the flow's life, as an int |
first_switched, last_switched |
IE 22, 21 | ms since the exporter booted (v5, v9) |
flow_start_ms, flow_end_ms |
IE 152, 153 | absolute epoch ms (IPFIX) |
flow_start_s, flow_end_s |
IE 150, 151 | absolute epoch seconds |
flow_start_us, flow_end_us |
IE 154, 155 | UNIX seconds, as a float. NTP on the wire, see below |
flow_start_ns, flow_end_ns |
IE 156, 157 | UNIX seconds, as a float. NTP on the wire, see below |
in_if, out_if |
IE 10, 14 | interface indices |
src_mask, dst_mask |
IE 9, 13 | prefix lengths |
src_as, dst_as |
IE 16, 17 | AS numbers |
next_hop |
IE 15, 62 | address string |
post_nat_src_addr, post_nat_dst_addr |
IE 225, 226 | may be the only addresses present |
tos, vlan, src_mac, dst_mac, if_name, … |
see netflume/ie.py for the full table |
netflume.IE is that table, {element id: (name, kind)}, and it is the whole
list.
The microsecond and nanosecond timestamps are NTP
RFC 7011 §6.1.9 and §6.1.10 define dateTimeMicroseconds and
dateTimeNanoseconds as 64-bit NTP timestamps: seconds since 1900 in the
high word, and a fraction in units of 1/2³² in the low one. They are not counts
since the UNIX epoch. dateTimeSeconds and dateTimeMilliseconds (IE 150 to
153) are plain epoch counts, so only two of the four pairs are affected.
netflume converts them at decode time, so flow_start_us holds UNIX seconds
as a float, the same currency flow_timestamp deals in. The key name
describes the precision the information element declares, not the unit of the
value under it. Do not divide it by 1e6: reading the raw NTP word as
microseconds puts the flow in the year 540,000 and makes every duration 2³²/1e6
= 4295× too long.
An all-zero timestamp field decodes to 0, meaning unset, rather than to
midnight in 1900.
Keys you did not expect
Unrecognised elements are kept, not dropped:
- a standard element with no entry in the table →
ie<id>, e.g.ie9999 - an enterprise-specific element →
e<enterprise>.<id>, e.g.e9.33
A vendor field nobody has modelled is exactly what a forwarder might be built to ship, so nothing is thrown away to keep the shape tidy.
The header
| key | |
|---|---|
version |
5, 9 or 10 |
exporter |
source address of the device, as a string |
domain |
observation domain; the engine ID for v5 |
sequence |
the export sequence counter |
unix_secs |
export time, epoch seconds |
sys_uptime |
ms since the exporter booted; None for IPFIX |
Values
Addresses and MACs are strings; integers are ints; strings are decoded UTF-8 with trailing padding stripped. Anything the parser cannot make sense of becomes an int if it is eight bytes or fewer, and a hex string otherwise. All of it is JSON-native.
Flow, the typed view
The dict is the parser's output and stays that way. Flow is an opt-in
wrapper over it, because a forwarder that wants three fields should not pay
to build thirty. What it is actually for is not the annotations; it is the
normalisations, each of which is otherwise open-coded at every call site:
from netflume import Flow
flow = Flow.from_record(rec, hdr, sampling_rate=1, now=None)
flow.octetsreadsoctetsoroctets_total. Same for packets. Readrec["octets"]alone and every IE 85 exporter contributes zero bytes: a wrong total rather than a missing one, which is the harder kind to notice.flow.src_addrfalls back to the post-NAT address when there is no other.flow.startis derived from the record and the header, which is whyfrom_recordtakes both.- everything optional stays
None, never a sentinel zero.
| attribute | |
|---|---|
raw |
the parser's dict, not a copy. Treat as read-only. |
exporter, version, domain |
always present |
start |
flow start, unix epoch float. Always present, see below |
duration |
seconds, or None |
src_addr, dst_addr, src_port, dst_port, proto |
or None |
octets, packets, tcp_flags, in_if, out_if |
or None |
sampling_rate |
1-in-N, or 1 |
| property / method | |
|---|---|
endpoints |
(src, dst) |
end |
start + duration, or None |
src_kind, dst_kind |
"private", "public", "multicast", "special", "unknown" |
is_external |
one end local, one not |
proto_name |
"TCP", "UDP", … or None |
service |
well known name for the destination port, or None |
flags |
"...AP...", "" if the exporter sent none |
started_at() |
start as an aware UTC datetime |
scaled(value) |
value corrected for the sampling rate; None in, None out |
get(key, default) |
any field of the record, modelled or not |
as_dict(include_raw=True) |
flat, JSON-safe dict |
start is best effort and never None. IPFIX absolute timestamps win; failing
those it is rebuilt from first_switched plus the header's uptime, and that
reconstruction is rejected in favour of the export time if it lands more than
a day away, which is what a wrapped uptime counter looks like.
Getting out to plain data
flow.as_dict() # every decoded field + metadata keys
flow.as_dict(include_raw=False) # only MODELLED_FIELDS, a fixed column set
as_dict() adds _exporter, _version, _timestamp, _domain, _duration
and _sampling_rate, which together are METADATA_KEYS. They are underscored
so that they cannot collide with an information element name, present or
future.
include_raw=False gives MODELLED_FIELDS and nothing else, which is the form
a database table wants: no surprise columns when somebody plugs in a router
that sends a vendor element.
Timestamps leave as unix epoch floats, deliberately. Every database and
every message payload can store one without a serialiser, and started_at() is
one call away for anyone who wants a datetime.
Events
Anything a console tool would have printed is an object here.
for event in decoder.take_events():
...
| class | fields | |
|---|---|---|
SamplingChange |
exporter, domain, rate, previous |
a domain stated, or restated, how much it is leaving out |
ExportGap |
exporter, domain, version, missed, unit |
exports that never arrived |
TemplateLearned |
exporter, domain, template_id, fields, options, previous |
a template arrived that this decoder did not already hold under that ID, or held differently. previous is the layout it replaced, or None when the template is new |
DecodeError |
exporter, reason, detail |
a datagram that would not decode. reason is "short", "unsupported" or "malformed" |
take_events() hands over what has accumulated and forgets it. Empty is the
normal answer on a settled network, so it is cheap to call in a loop. It is
not the answer at the start of one: the first datagram carrying each of an
exporter's templates raises a TemplateLearned, so a caller that reads any
event at all as trouble will be wrong about the first minute of every run.
The queue holds MAX_PENDING_EVENTS and then drops the oldest, counting each
drop in decoder.stats["events_dropped"]: a collector on an open UDP port
receives things that are not NetFlow, and a caller that never drains must not
accumulate one event per junk datagram for the life of the process. Each class
has a __str__ that explains itself in a sentence, and each is also written to
the log.
Only new and changed templates raise a TemplateLearned, exactly as only
changed rates raise a SamplingChange. Exporters resend every template they
hold every few minutes, which is what lets a collector that started late decode
anything at all, and an event per resend would be an event per datagram from
the exporters that prepend their templates to everything. A template evicted
under MAX_TEMPLATES and later resent is new again, which is
truthful: the layout was not held in between, and the data sets that arrived
meanwhile are counted in deferred.
decoder.templates.take_events() is where those come from, and a caller
driving parse_message rather than a Decoder
reads them there instead. Decoder.decode drains the store on every datagram,
including one whose body then failed to parse: a template set can be sound and
the set behind it be what raised, and the layout learned from the first is
still true.
Sampling
An exporter that samples reports one flow in N, and its byte and packet counts are correspondingly short. Nothing downstream can correct for that without knowing N.
decoder.sampling_rate("10.0.0.1", domain=0) # 1000 means 1-in-1000. Never None.
flow.scaled(flow.octets) # the count corrected for it
Rates belong to an observation domain, not to a device, so name one. Omitting
domain asks about the exporter as a whole and answers only when that is
unambiguous: every domain heard from agrees, or only one has spoken. When they
disagree it returns 1 rather than applying one domain's rate to another's
counts.
The rate is read out of option records automatically, in every form exporters
advertise it: the v5 header word, samplingInterval, samplerRandomInterval,
IPFIX interval/space, and IPFIX size/population. A change raises a
SamplingChange. An exporter repeating the same rate, which they do in every
options record, raises one the first time and nothing after.
sampling_rate(rec) on its own, for a record you have in hand, returns None
when the record says nothing about sampling and 1 when it explicitly describes
an unsampled exporter. Those are different answers, and a caller holding a
stale rate needs to tell them apart.
Export gaps
Every export message carries a sequence counter, and a jump in it means messages went missing on the way here, whether a saturated link or this process not keeping up. Without watching it the loss is silent: a flow that never arrives looks exactly like a flow that never happened.
message.gap # exports missing before this message
decoder.export_gaps() # cumulative, per stream, worst first
What the counter counts differs by version: v5 counts flow records, IPFIX counts data records, and v9 is specified to count export packets but is widely built to count records instead. Rather than trust the version, each stream is watched until one reading lands exactly on the next message, and that becomes the rule for that stream. Until one does, nothing is reported, since a wrong rule would invent a loss on every message, and a collector that cries wolf about dropped flows is worse than one that stays quiet. An exporter sending a single record per message is ambiguous forever and is watched without ever being judged.
Repeats, reordering and counter restarts are recognised and are not losses.
Streams are keyed by (exporter, domain, version) and never summed together:
adding a count of export messages to a count of data records gives a number
that means nothing, which is why ExportGap carries its unit.
Counters
collector.stats / decoder.stats is a collections.Counter. Absent means
zero.
| key | |
|---|---|
packets |
datagrams received |
bytes_rx |
bytes received |
flows |
flow records decoded |
option_records |
option records decoded |
templates_new |
templates learned or changed |
deferred |
data sets dropped because their template had not arrived |
malformed |
datagrams too short, or with a truncated header |
unsupported_version |
not v5, v9 or IPFIX, usually sFlow on the wrong port |
parse_errors |
datagrams whose body would not parse |
missed_exports |
total gap, all streams |
v5_msgs, v9_msgs, v10_msgs |
messages per version |
events_dropped |
events discarded because the queue was full, see Events |
deferred climbing at the start is normal and not a fault: v9 and IPFIX
exporters resend templates periodically, often every few minutes, and data
before the first one cannot be decoded by anybody.
Ceilings
Every table keyed by exporter has a ceiling. A UDP source address is whatever the sender typed, so anything keyed by one and never evicted is a memory leak that anyone able to reach the socket can pull on.
| constant | default | what it bounds | on overflow |
|---|---|---|---|
netflume.parse.MAX_TEMPLATES |
10,000 | learned templates | least recently used evicted |
netflume.parse.MAX_PENDING_TEMPLATES |
10,000 | TemplateLearned events awaiting decoder.templates.take_events() |
oldest dropped, counted in decoder.templates.dropped |
netflume.sequence.MAX_STREAMS |
10,000 | tracked (exporter, domain, version) streams, and their gap counters |
least recently seen evicted |
netflume.sampling.MAX_SAMPLING_STREAMS |
10,000 | remembered sampling rates | least recently seen evicted |
netflume.decoder.MAX_PENDING_EVENTS |
10,000 | events awaiting take_events() |
oldest dropped, counted in stats["events_dropped"] |
netflume.values.MAX_SERVICE_CACHE |
100,000 | cached service-name lookups | stops caching |
TemplateStore, SequenceWatch and SamplingWatch each take the limit as a
constructor argument, so a collector facing an unusual number of exporters can
raise it rather than patch the module. TemplateStore takes two, the second
being max_pending.
A Decoder drains the store on every datagram, so MAX_PENDING_TEMPLATES
binds nobody using one: it is there for a caller parsing without a Decoder
and never draining, since an exporter alternating two layouts under one
template ID raises an event every time it switches.
The defaults are far above any real deployment, since a collector sees tens of
exporters, each with a handful of domains and templates. Eviction is not free
when it happens. Evicting a template means the flows in its data sets
are undecodable until the exporter resends it, commonly one to ten minutes
later. Reading a template counts as using it, so a template still receiving
data survives a flood of addresses that never send any. Watch
decoder.templates.evicted, decoder.sequence.evicted and
decoder.sampling.evicted; all three should stay at zero.
Logging
Everything goes to the netflume logger. The package installs a
NullHandler and nothing else, so records go nowhere until you configure a
handler.
import logging
logging.basicConfig(level=logging.INFO)
| logger | what |
|---|---|
netflume.collector |
INFO once at bind, DEBUG for a refused SO_RCVBUF and for stale ICMP errors |
netflume.sampling |
WARNING when a domain starts sampling, INFO when it stops |
netflume.sequence |
WARNING on the first gap for an exporter |
netflume.decoder |
DEBUG per undecodable datagram |
Hostname resolution
Not in this package. It lives in lanname, which turns an address into a name by reverse DNS, mDNS or NetBIOS, caches the answer, and never blocks the caller asking for it.
pip install lanname
from lanname import Resolver
with Resolver(mode="dns", workers=4) as resolver:
name = resolver.lookup(flow["src_addr"]) # None until it is known
It used to be an optional module here, netflume.names, and it moved out
because nothing in this package ever imported it and nothing about it was
specific to flow records. Nothing here depends on it now; install it if a
consumer wants names, and skip it otherwise. A database logger usually does
want them, an IP address in a year-old row being much less useful than a
hostname.
Two of its properties are worth knowing before it goes anywhere near a
collector, and both are the reasons it is separate rather than wired in.
lookup() reads a cache and returns immediately, because UDP has no
backpressure and anything that blocks a receive loop drops flows silently. And
its widest mode sends probes onto the LAN, which is why it does nothing at all
until a mode is chosen.
Parsing without a socket
from netflume import TemplateStore, parse_message
store = TemplateStore() # must outlive the datagram
for data, exporter in datagrams_from_a_pcap():
hdr, flows, options = parse_message(data, exporter, store)
parse_message dispatches on the version and raises ValueError for one it
does not decode, so an unsupported exporter and a truncated datagram stay
distinguishable. parse_v5 and parse_v9_or_ipfix are there if you already
know which you have.
The store must persist across datagrams. Exporters resend templates only
periodically, and a data set arriving before its template cannot be decoded at
all, which is what deferred counts.
Also exported, for a caller working with the records directly: flow_endpoints,
flow_timestamp, flow_duration.
Sketch: logging flows to a database
Real sqlite3 rather than pseudocode, but the shape is what matters and
record_gap and record_sampling are yours to write. Swap in whatever you
actually use. MODELLED_FIELDS is the column set precisely so that a new
exporter on the network does not mean a migration.
import signal
import sqlite3
from netflume import Collector, MODELLED_FIELDS
from netflume.events import ExportGap, SamplingChange
COLUMNS = ", ".join(MODELLED_FIELDS)
PLACEHOLDERS = ", ".join("?" * len(MODELLED_FIELDS))
db = sqlite3.connect("flows.db")
db.execute(f"CREATE TABLE IF NOT EXISTS flows ({COLUMNS})")
collector = Collector(port=2055)
signal.signal(signal.SIGTERM, lambda *_: collector.stop())
batch = []
with collector, db:
for message in collector.messages():
for flow in message.typed_flows():
row = flow.as_dict(include_raw=False)
batch.append([row[name] for name in MODELLED_FIELDS])
# Batched: one commit per flow would spend the whole time in fsync,
# and a stalled receive loop drops datagrams with no way to know.
if len(batch) >= 500:
db.executemany(
f"INSERT INTO flows ({COLUMNS}) VALUES ({PLACEHOLDERS})", batch)
db.commit()
batch.clear()
for event in collector.decoder.take_events():
if isinstance(event, ExportGap):
# Rows are missing and the table cannot show that by itself.
# Record it, or the gap becomes a quiet lie about the traffic.
# Store the whole stream key: gaps from different domains or
# versions count different things and must never be summed.
record_gap(event.exporter, event.domain, event.version,
event.missed, event.unit)
elif isinstance(event, SamplingChange):
# Every count from this domain is now 1/N of the truth. The
# rate belongs to the domain, not to the device, so a second
# domain would otherwise overwrite the first.
record_sampling(event.exporter, event.domain, event.rate)
Three things worth doing that the sketch only gestures at:
- Store the sampling rate alongside the rows, not in a separate config. It
changes while you are running, and a row is only interpretable with the rate
that was in force when it arrived.
flow.sampling_rateis already on each flow for this reason. - Keep the writes off the receive loop once the flow rate is more than a
home link. A bounded
queue.Queueand a writer thread; drop on a full queue and count it, because blocking the reader loses datagrams silently, which is strictly worse than knowingly dropping a flow. - Store
rawtoo if you may ever want a field you did not model. A JSON column offlow.as_dict()costs space but means an exporter's vendor elements are still there when someone asks.
Sketch: forwarding a subset over MQTT
Pseudocode for the client; no MQTT library is a dependency here.
import json
from netflume import Collector
client = connect_to_broker() # your MQTT client of choice
with Collector(port=2055) as collector:
for rec, hdr in collector:
# The dict form, not the typed one: three fields do not justify
# building an object per flow, and a busy exporter sends tens of
# thousands a second into a single-threaded loop.
if rec.get("dst_port") not in WATCHED_PORTS:
continue
topic = f"netflow/{hdr['exporter']}/{rec.get('proto')}"
client.publish(topic, json.dumps({
"src": rec.get("src_addr"),
"dst": rec.get("dst_addr"),
"port": rec.get("dst_port"),
"bytes": rec.get("octets", rec.get("octets_total")),
}))
Notes for a real one:
- Filter before you serialise. Most flows are not interesting, and the cheapest flow is the one you never build a payload for.
octetsoroctets_total, as above. If you find yourself writing that.get(..., .get(...))more than once, useFlowand let it do it.- Publish sampling changes on their own topic. A subscriber doing arithmetic on byte counts needs to know the multiplier changed, and it will never guess.
- Do not block the receive loop on the broker. A publish that waits for a reconnect stalls the reader, and UDP has no backpressure. Hand payloads to a bounded queue and let a publisher thread drain it.
Protocol support
| version | header | templates | notes |
|---|---|---|---|
| NetFlow v5 | 24 bytes | none, fixed 48-byte records | fully decoded on the first packet |
| NetFlow v9 | 20 bytes | FlowSet 0 (data), 1 (options) | |
| IPFIX / v10 | 16 bytes | Set 2 (data), 3 (options) | enterprise fields and variable-length encoding |
Templates are keyed by (exporter address, observation domain, template ID).
Different exporters reuse the same IDs for different layouts, so all three are
needed.
IPFIX variable-length fields are handled in both forms: a declared length of
0xFFFF means a one-byte length prefix, or 0xFF followed by a two-byte
length for values of 255 bytes or more.
Option records are decoded and returned separately from flows. They describe the exporter, not traffic; mixed into the flow list they look like flows with no addresses and inflate every count downstream. The sampling rate is read out of them automatically; interface names, application ID mappings and the rest are decoded and handed to you to use or ignore.
Set lengths, record lengths and template field counts are all bounds-checked. A malformed datagram is counted and discarded, never raised.
Tests
python -m unittest discover
273 tests, no dependencies, about a second. Several use subTest, so the
number of individual checks is higher than the number of tests.
The suite is built around synthetic messages assembled byte by byte in
tests/packets.py, which means the awkward cases are reachable: a template set
carrying several templates, an options template whose scope length overruns its
set, a data record truncated mid-field, a message that ends inside its own
header. Every prefix of a full message is decoded, so anything that would only
break on a fragment breaks in the suite first.
tests/test_hardening.py covers the cases that decode without raising and
return something untrue, which is the failure class the fuzzer cannot see: NTP
timestamps read as epoch counts, a truncated template fabricating flows, a
dual-stack template erasing its own addresses, a sampling rate wiped by another
observation domain, and the ceilings, which are asserted rather
than assumed.
Decoder.decode promises never to raise, and that promise is attacked rather
than trusted:
python tools/fuzz.py --seconds 60
It mutates valid v5, v9 and IPFIX messages with bit flips, truncations, splices and implausible length fields, then asserts nothing escapes. One decoder runs across the whole session, so template state accumulates and a later datagram meets whatever an earlier one left behind. A failure prints a hex reproducer and the seed that produced it.
Everything is synthetic. No real exporter was involved, so what the suite proves is conformance to the specifications and to its own reading of them, not agreement with any particular vendor's interpretation.
Limitations
- No sFlow. Different protocol, different wire format. It arrives on the
same port often enough that
unsupported_versionis worth watching. - No IPv6 transport. The socket is
AF_INET. IPv6 addresses inside flow records decode fine; the exporter has to reach you over IPv4. On the roadmap. - Single-threaded receive. One
Collector, one socket, one reader. Under heavy load, move the socket read into its own thread feeding a bounded queue, and watchmissed_exports, which is how you find out you needed to. Decoderis not thread safe. One per thread, or one behind a queue.- Per-exporter state is capped and evicts. See Ceilings. The defaults are far above a real deployment, but a collector facing tens of thousands of distinct source addresses, or a spoofing flood, loses the least recently used templates and cannot decode their flows until the exporter resends.
- A truncated template is refused, not salvaged. A template set that ends mid-template is dropped rather than stored short, so the flows in that datagram are lost. The alternative is worse: a template missing fields cuts every later record for that ID into one real flow plus fabricated ones, and those reach the caller as genuine traffic until the next template refresh.
- No persistence, no aggregation, no alerting. By design; that is what the two sketches above are for. Template state is a different question and is on the roadmap. See below for why a restart currently costs you flows.
- Flow data is 5-tuple only. No SNI, no JA3, no DNS names, no payload. If you need those, NetFlow is the wrong data source and a mirror port is the right one.
Everything else exported
The sections above cover what most callers need. Everything below is public API too, listed here so that the export list and the documentation agree.
Names written bare are exported from the package itself, as in from netflume import service_name. Names written with a module path are exported from that
module only, so from netflume.parse import NTP_EPOCH works and from netflume import NTP_EPOCH does not.
| name | |
|---|---|
SequenceWatch, SamplingWatch |
the trackers behind export gaps and sampling, usable on their own against records you already hold |
sampling_rate(rec) |
read a 1-in-N rate out of one option record: None for silence, 1 for explicitly unsampled |
addr_kind(addr) |
"private", "public", "multicast", "special" or "unknown"; ADDR_KINDS is the tuple |
service_name(port, proto) |
well-known service name, or None for ephemeral ports and non-TCP/UDP |
netflume.values.EPHEMERAL_FLOOR |
the port at which service_name stops naming, for a consumer drawing the same line |
proto_name(proto), PROTO_NAMES |
IP protocol number to name |
tcp_flags_str(flags), TCP_FLAG_BITS |
flag byte to a readable string |
flow_end_reason_name(code), FLOW_END_REASON |
IE 136 code to a description |
DEFAULT_PORT, DEFAULT_RCVBUF |
2055, and the 4 MB receive buffer Collector asks for |
SUPPORTED_VERSIONS |
(5, 9, 10) |
netflume.parse.NTP_EPOCH |
2208988800, for the timestamp conversion described above |
netflume.parse.UNSPECIFIED |
the values treated as "not filled in" when a template repeats a key |
The ceiling constants are in Ceilings.
Roadmap
Planned, not promised, and ordered within each group by expected value. The three headings are the three things this package optimises for.
Performance
- Template-compiled unpacking. When every field in a template is
fixed-length, which is the common case, the layout is known the moment the
template is learned. Building one
struct.Structper template and unpacking a whole record in a single call replaces per-field slicing and integer conversion. This is the largest single decode win available and the reason the template store is a first-class object rather than a dictionary. memoryviewover the datagram. Field extraction currently slices, and a slice copies. A view does not.- Selective decoding. An optional field allow-list on the
Decoder, so a forwarder that publishes five fields stops paying to decode forty. The wire offsets still have to be walked; the values do not have to be built. - A benchmark suite, tracked in CI. Flows per second by version and by template shape, so the claims above become measurements and a regression shows up as a number rather than as a complaint.
Versatility
- Collection over IPv6. An
AF_INET6socket withIPV6_V6ONLYcleared, so exporters reaching the collector over IPv6 are decodable at all. This is about the transport; IPv6 addresses inside flow records already decode, and a template carrying both families is handled under Common keys. - An asyncio interface.
async for flow in collectorfor daemons already built around an event loop, without the thread-and-queue adapter every such caller currently writes. - A persistent template store. v9 and IPFIX data records are undecodable until their template arrives, and exporters resend templates on their own schedule, commonly every one to ten minutes. A process restart therefore drops every flow until each exporter happens to resend. Saving and restoring the store removes that window, and matters more than anything else here for a long-running daemon.
- An enterprise element registry. Vendor elements decode today as
e<pen>.<id>with raw bytes. Letting a caller register a name, a type and a length turns a vendor's flow direction or application ID into a first-class field. - A capture source. Reading datagrams from a pcap or a flat file, for replaying a problem, testing a consumer against real traffic, and attaching something reproducible to a bug report.
Ease of use
python -m netflume. A JSONL dump to stdout, enough to confirm an exporter is configured correctly and reaching you before any code is written against it. Not a console application; a smoke test.- Wider modelling on
Flow. More fields promoted from the raw dict, with the aliasing handled once, as the existing four are. - Worked consumers. The database and MQTT sketches carried through to runnable examples, including the parts that are easy to get wrong: batching, back-pressure, and what to do when the sink is slower than the exporter.
Explicit non-goals: sFlow, which is a different protocol rather than a variant; aggregation, alerting and storage, which belong to the consumer; and any form of presentation.
Licence
MIT. See LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file netflume-0.3.0.tar.gz.
File metadata
- Download URL: netflume-0.3.0.tar.gz
- Upload date:
- Size: 103.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ebd41f2081c65da536d99f22fde155b5d7c7174a0f3840f9640e62b076804eae
|
|
| MD5 |
1a41e70192649a915b6d052ae7e13632
|
|
| BLAKE2b-256 |
e9ca24b499e33f7fbd83dd6a02f9a3a52c79f9442b3bd05c9d058a328dd73b7a
|
Provenance
The following attestation bundles were made for netflume-0.3.0.tar.gz:
Publisher:
release.yml on mjaksn/netflume
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
netflume-0.3.0.tar.gz -
Subject digest:
ebd41f2081c65da536d99f22fde155b5d7c7174a0f3840f9640e62b076804eae - Sigstore transparency entry: 2699117215
- Sigstore integration time:
-
Permalink:
mjaksn/netflume@09b6dd76c47b6e5085da9215c018a575f77cc121 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/mjaksn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@09b6dd76c47b6e5085da9215c018a575f77cc121 -
Trigger Event:
push
-
Statement type:
File details
Details for the file netflume-0.3.0-py3-none-any.whl.
File metadata
- Download URL: netflume-0.3.0-py3-none-any.whl
- Upload date:
- Size: 53.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
388c12b499d631dd4c932d9725a061dba96dceb12cf11f09423b87d65c97d9a2
|
|
| MD5 |
635cdd1b01a6893c3de6ec6f35482f88
|
|
| BLAKE2b-256 |
621725e880ec3dd573093fcc2e14b4a8c3c140eacb65198e145b96ebf59dd14b
|
Provenance
The following attestation bundles were made for netflume-0.3.0-py3-none-any.whl:
Publisher:
release.yml on mjaksn/netflume
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
netflume-0.3.0-py3-none-any.whl -
Subject digest:
388c12b499d631dd4c932d9725a061dba96dceb12cf11f09423b87d65c97d9a2 - Sigstore transparency entry: 2699117264
- Sigstore integration time:
-
Permalink:
mjaksn/netflume@09b6dd76c47b6e5085da9215c018a575f77cc121 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/mjaksn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@09b6dd76c47b6e5085da9215c018a575f77cc121 -
Trigger Event:
push
-
Statement type: