A NetFlow and IPFIX collector that prints to the console or web terminal with hostnames
3.7K
A NetFlow and IPFIX collector that prints flow records to the console in a
readable table, annotating addresses with hostnames where it can find them.
tail -f for your network.
Built for pointing a UniFi Dream Machine Pro at a workstation and actually seeing what the network is doing. Works with any exporter that speaks NetFlow v5, NetFlow v9, or IPFIX (v10).
Two dependencies, both of them halves of this program that grew up and left:
netflume reads the wire, and
lanname turns an address into a hostname.
Neither has dependencies of its own, so pip install nettail brings in three
pure Python packages and nothing else.
TIME EXPORTER PROTO SOURCE DESTINATION PKTS BYTES DUR FLAGS
13:40:03.000 10.0.0.1 TCP 192.168.1.42:51234 (macbook-pro) ↑ 140.82.114.4:443/https (github) 23 4.1K 4.90s ...AP.SF
13:40:03.000 10.0.0.1 UDP 192.168.1.77:5353/mdns (hue) ⇄ 224.0.0.251:5353/mdns 2 180B 4.90s
13:39:56.103 10.0.0.1 TCP 10.0.1.5:44321 (nas) ↑ 104.244.42.1:443/https (twitter-edge) 412 57.5K 12.50s ...AP...
13:40:08.453 10.0.0.1 TCP 1.1.1.1:853/domain-s ↓ 10.0.1.5:39012 (nas) 4 320B 0.15s ......S.
Neither has dependencies of its own, so this brings in three pure Python packages and nothing else.
pip install nettail
Or, from a checkout, either of:
pip install -e . # the nettail command, pointed at the working tree
pip install lanname netflume && python -m nettail
Binding to the default port 2055 does not require root on Linux, since it is above 1024. You only need elevated privileges if you choose a port below 1024.
# Listen on the default port
nettail
# Pick a port and only show flows that touch the internet
nettail --port 2055 --external-only
# Passive name resolution plus your own static mappings
nettail --resolve dns --hosts ./lan-hosts
# Dump every decoded field under each flow
nettail --verbose
# Spell out each template an exporter sends, without the field lines
nettail --templates
# Machine readable, one object per line
nettail --json > flows.jsonl
# Or to a file of its own, with the table still on the screen
nettail --json flows.jsonl
# Keep thirty days of flow history rather than fourteen, for the browser to
# scroll back into and to replay a tab that was in the background
nettail --flow-retention-days 30
# Or keep no flow history at all
nettail --flow-store off
Press Ctrl-C to stop. A summary prints on exit with datagram counts, template
statistics, name resolution hit rates, and the top ten external addresses by
volume.
In the UniFi Network application:
Settings > CyberSecure > Traffic Logging
On some firmware versions this lives under Settings > System > Integrations instead. Look for a section named NetFlow or NetFlow (IPFIX).
| Setting | Value |
|---|---|
| NetFlow (IPFIX) | Enabled |
| Collector address | IP of the machine running this script |
| Collector port | 2055 |
| Sampling | Off |
| Networks / VLANs | Select every network you want visibility into |
This matters more than anything else on the page. Sampled export throws away most flows before they leave the router. A malware beacon is a handful of small flows per hour, and at 1:1024 sampling you will never see it. Sampling is fine for bandwidth graphs and useless for security monitoring.
If your firmware does not let you disable sampling, the flow data is not trustworthy for detection work and you should look at a mirror port with Zeek instead.
You do not have to take the router's word for it. The collector reads the sampling rate an exporter advertises and says so on stderr the first time it sees one:
10.0.0.1 reports 1-in-1000 sampling. The byte and packet counts shown are a sample,
so real traffic is roughly 1000x higher. Turn sampling off at the exporter for true
counts.
The rate is repeated in a Sampling section of the exit summary. It is read from
whichever form the exporter uses: samplingInterval or samplerRandomInterval on
v9, samplingPacketInterval with samplingPacketSpace or samplingSize out of
samplingPopulation on IPFIX, and the sampling field of the header on v5. Nothing
is printed when an exporter reports no sampling, or a rate of one in one.
If an exporter later reports that sampling is off, meaning an interval of one or a selection that skips nothing, the remembered rate is dropped and a line says the counts are complete again. An option record that says nothing about sampling, such as one carrying interface names, never changes what is remembered.
Detection depends on the exporter actually sending options data. Silence is not proof that sampling is off, so check the UniFi UI as well. On v5 there are no options records at all: sampling is read from the header, and an exporter that stops sampling simply clears the field, which is reported as nothing rather than as a change.
Selecting every network in the table above reads like a request for visibility into all of them. On a UDM Pro it is not what you get. Traffic to and from the internet arrives. Traffic between two local hosts may not, and the two reasons for that are worth telling apart, because only one of them is surprising.
Two hosts on the same VLAN never produce a flow record at all. They talk through a switch and the gateway routes nothing between them, so there is nothing for it to account. This is not a fault and no setting changes it. A flow exporter running on the router cannot see traffic that never reaches the router, and every tool reading NetFlow from a gateway has this property, this one included.
Two hosts on different VLANs are routed by the gateway and may still be missing.
This is the surprising one. Traffic between separate subnets has to pass through the
UDM Pro, and a tracert or traceroute between the hosts shows it as a hop, so the
router demonstrably handles the packets. They can still be absent from the export,
with every network selected.
Two mechanisms would account for that and they cannot be told apart from outside the box. The routing may be offloaded to hardware along a path the flow accounting does not sit on, or the accounting may be scoped to the internet-facing path by design. The consequence is the same either way, which is why the distinction matters less than it looks: neither this collector nor anything in the export configuration changes it.
This is behaviour observed on a UDM Pro rather than anything Ubiquiti documents, so establish it on your own hardware before concluding. Three checks, in order of effort:
tracert or traceroute between the two hosts. The UDM's address as a hop means
it routes them, and the rest of this section applies. A switch address instead
means a layer 3 switch is doing the routing and the gateway has nothing to report,
which is a different problem with a different answer.flows decoded under Summary with
flows under External traffic. Two equal figures mean every flow that arrived
had a public endpoint, so nothing internal is being exported at all.If it is east-west visibility you need, a gateway is the wrong vantage point for it. Mirror the VLANs you care about to a capture host at the switch and read packets rather than flow records, which is the same advice as for sampled export and for a different reason.
sudo ufw allow 2055/udp or the nftables equivalent.usage: nettail [-h] [--version] [--config FILE | --save-config [FILE]]
[--bind BIND] [--port PORT] [--external-only] [--names]
[--macs] [--verbose] [--templates] [--json [FILE]]
[--flow-store [FILE]] [--flow-retention-days DAYS]
[--flow-prune-every SECONDS] [--flow-commit-every SECONDS]
[--colour WHEN] [--no-color] [--header-every HEADER_EVERY]
[--sticky-header] [--hide-status] [--no-supplemental-services]
[--web] [--web-port PORT] [--web-bind ADDR] [--web-host NAME]
[--web-token TOKEN] [--web-colour WHEN] [--web-readonly]
[--web-detail-refresh SECONDS] [--size-scale-max BYTES |
--size-scale-dynamic] [--size-scale-window FLOWS] [--country]
[--country-db FILE] [--update-country-db]
[--country-style {auto,flag,code}] [--resolve {off,dns,all}]
[--hosts FILE] [--resolve-public] [--fqdn]
[--resolve-workers RESOLVE_WORKERS]
[--resolve-timeout RESOLVE_TIMEOUT]
| Option | Default | Description |
|---|---|---|
--version | Print the version and exit | |
--config FILE | searched | Read settings from this file instead of looking for one. See Settings file |
--save-config [FILE] | ~/.nettail/nettail.conf | Write what this run would have used to a file and exit without collecting anything |
--bind BIND | 0.0.0.0 | Address to bind the UDP socket to |
--port PORT | 2055 | UDP port to listen on. 0 has the system pick a free one, and the startup line names the port it picked |
--external-only | off | Only display flows where the source or destination is a public IP. Everything is still counted in the summary |
--names | off | Show a host by its name in place of its address, where a name is known. The n key turns it off and on while running |
--macs | off | Show hardware addresses on a line under each flow, on the exporters that send them. The p key turns it off and on while running |
--verbose | off | Print every decoded field on an indented line under each flow, and report datagrams that could not be decoded. The v key moves the same setting mid-run |
--templates | off | Spell out each template the first time an exporter sends it, and note in one line each time a template is sent again. v9 and IPFIX only; v5 carries no templates. The t key moves the same setting mid-run |
--json [FILE] | off | Emit one JSON object per flow. On its own, or given -, the objects go to stdout in place of the table. Given a path they are appended to that file instead, and the table, the keys and the browser view carry on as if the flag were not there |
--flow-store [FILE] | on | Where to keep the local SQLite history of shown flows. By default, and on its own, it writes under a per-user data directory (%APPDATA%\nettail on Windows, ~/Library/Application Support/nettail on macOS, and $XDG_DATA_HOME/nettail or ~/.local/share/nettail elsewhere); given a path it writes there instead, and off keeps no history. The history is what a browser scrolls back into and what a backgrounded tab is replayed from when it comes back. A store that cannot be opened stops the run and says so |
--flow-retention-days DAYS | 14 | How long to keep rows in the durable flow store. At least one day, and a value of 14 keeps the last two weeks unless you change it |
--flow-prune-every SECONDS | 43200 | How often old rows are pruned from the SQLite store. This is 12 hours by default, and it is a background maintenance pass rather than a freeze on the collector |
--flow-commit-every SECONDS | 1 | How often buffered writes are committed to the file. Lower values make the history more current, while higher values allow a little more batching |
--colour WHEN | auto | When to use ANSI colour on this terminal: auto, always or never. Under auto a terminal gets colour and a redirected stream does not, and NO_COLOR in the environment turns it off. The browser view has its own switch, --web-colour, and is not decided by this one. --color is accepted too |
--no-color | off | The same as --colour never, and like it, about this terminal |
--header-every N | 40 | Reprint the column header every N lines. 0 disables repeats |
--sticky-header | off | Pin the column header to the top row of the window. The k key moves the same setting mid-run. See below |
--hide-status | off | Turn off the two-line status bar at the foot of the window, which is shown by default whenever output is going to a terminal. The b key toggles it while the collector runs. See The status bar |
--no-supplemental-services | off | Name ports from the system services database alone. Without it, a short list shipped with this program fills in the ports the system does not know. See Service names |
All off unless --web is given. See The web interface.
| Option | Default | Description |
|---|---|---|
--web | off | Also serve the display to a browser |
--web-port PORT | 2056 | Port for the web interface. 0 has the system pick a free one, and the printed URL names the port it picked |
--web-bind ADDR | 127.0.0.1 | Address for the web interface. Anything other than loopback exposes this network's traffic over cleartext HTTP, and is warned about at startup |
--web-host NAME | none | A name the view answers to. Under the loopback default it is added beside localhost; under another --web-bind, which otherwise answers to any name, it restricts the view to the names given. May be repeated |
--web-colour WHEN | on | Colour in the browser view: on or off. A browser is a colour-capable reader whatever stdout is, so a redirected run does not take the colour out of it. --web-color is accepted too |
--web-token TOKEN | random | Use this token in the URL instead of a fresh random one, so a bookmark survives a restart. Taken from NETTAIL_WEB_TOKEN in the environment when the flag is not given, which is how an installed service receives one without it appearing in ps |
--web-readonly | off | Serve the display but accept no keys from the browser |
--web-detail-refresh SECONDS | 5 | How often the flow details dialog asks the collector for its figures again. 0 leaves them still until Refresh is pressed. See Clicking a flow |
--sticky-header keeps the column header on the top row while flows scroll
underneath it, so you never lose track of which column is which. It works by
setting a VT100 scroll region (DECSTBM) covering every row but the first, then
letting output scroll inside that region as usual.
The k key moves the same setting while the collector runs, in both
directions, which is worth having for the first item below: pin the header
while you read the columns, let it go when you want the scrollback back.
Letting it go leaves the flows where they are, and pinning it again scrolls
one row rather than clearing the screen, so an hour of history is not the
price of pressing a key. In a window too short to hold a header the key says
so and changes nothing, as the b key does for the bar.
Two things to know before turning it on:
--header-every, or keep the
display and write the flows to a file with --json flows.jsonl.--header-every. The header alone
needs six rows; with the status bar up, which is the default, it needs
eight.The header is redrawn when the window is resized (the size is re-measured every
16 flow lines), and the scroll region is released on Ctrl-C so the summary and
your shell prompt are not trapped inside it. --header-every is ignored while
the header is pinned, since the repeats would be redundant.
| Option | Default | Description |
|---|---|---|
--size-scale-max BYTES | 100K | Top of the BYTES colour scale. Accepts a plain byte count or a K, M, G, T suffix (powers of 1024, matching the column) |
--size-scale-dynamic | off | Re-range the scale to the largest flow seen so far instead of a fixed top. Mutually exclusive with --size-scale-max |
--size-scale-window FLOWS | off | Scope the dynamic scale to the last N flows instead of the whole run. Implies --size-scale-dynamic, so it cannot be combined with --size-scale-max |
See Size colour scale for what the colours mean.
| Option | Default | Description |
|---|---|---|
--country | off | Mark every public address with the country it is in. Implied by --country-db |
--country-db FILE | searched | The database to read. Without it, the first of /etc/nettail/country.mmdb, /usr/share/GeoIP/GeoLite2-Country.mmdb and /var/lib/GeoIP/GeoLite2-Country.mmdb that exists, and a few more besides |
--update-country-db | off | Fetch DB-IP's free country database, put it where the next run will read it, and exit without collecting anything. Replaces the database that is there, or fetches a first one where there is none. With --country-db it refreshes that file instead of searching |
--country-style {auto,flag,code} | auto | How this terminal is shown a country: flag for the emoji, code for the two letters, auto for the letters wherever a flag is known not to be drawn. The browser is sent the flag whatever this says, and draws it or not by its own fonts |
See Country marking for where the database comes from and what is marked.
| Option | Default | Description |
|---|---|---|
--resolve MODE | all | off, dns, or all. See below |
--hosts FILE | none | Static mappings in /etc/hosts format. Repeatable |
--resolve-public | off | Also reverse-resolve public addresses via PTR |
--fqdn | off | Show nas.lan instead of nas |
--resolve-workers N | 4 | Background lookup threads |
--resolve-timeout SEC | 1.0 | Budget for the mDNS and NetBIOS probes together, mDNS taking at most half |
Everything the collector takes on the command line it takes in a file instead, under the same name without its leading dashes. There is no second list of what can be set: the file is read against the same parser the command line is, so an option that exists is settable, and one that does not is reported as an unknown key.
# nettail.conf
port = 2055
external-only = true
web = true
web-bind = 127.0.0.1
flow-retention-days = 30
resolve = dns
hosts =
/etc/hosts.lan
/etc/hosts.iot
A [nettail] header is allowed and is not needed: there is one section and
nowhere else for a setting to be, so a file that opens with port = 2055 is
read as though it had one. A key may be written with dashes as the flag is or
with underscores as Python spells it, and an option with two spellings answers
to both: colour and color are one setting, as they are one flag. Writing
one option twice under two of its names is reported rather than silently
resolved, and the first is the one used. A switch takes true or false, and
yes, no, on, off, 1 and 0 besides. An option that may be repeated
takes one value a line, indented under the key. A comment is a line beginning
with # or ;, and a % is a % rather than anything clever.
The command line wins. What the file says becomes the default and anything typed overrides it, so a config file is where the settings you always want live and the command line is for today. What neither says falls back to what the program shipped with, which is how a default this program changes later still reaches you.
The one exception is an option that may be repeated, --hosts and
--web-host. Typing one of those adds to what the file listed rather than
replacing it, because that is what repeatable means everywhere else here. A run
that wants none of them wants --config pointed at a file that lists none.
The durable flow store is a different shape of option. It is on unless
something turns it off, so a file needs to say nothing to have one;
flow-store = /var/lib/nettail/flows.sqlite3 writes there instead of in the
default per-user path, and flow-store = off keeps no history at all. false
and no mean off too, since a file reads them as switches, and true means
the default path, as --flow-store on its own does. The retention and cadence
settings are ordinary values, so flow-retention-days = 30,
flow-prune-every = 3600, and flow-commit-every = 2.5 all work exactly as
the flags do.
Options that are alternatives win the same way, and it is worth saying because
they are the one place where winning means the file's setting is dropped
rather than replaced. With size-scale-max in your file, --size-scale-dynamic
on the command line gives you a dynamic scale and the file's fixed top is set
aside for that run, exactly as though it had not been there. The same goes for
--size-scale-window, which rules out a fixed top too. Neither is an error:
you asked for one of a pair of alternatives and got it.
There is a second thing worth knowing before you rely on it: a switch turned on
in a file cannot be turned off from the command line, because switches here
have no --no- form. Colour is not quite an exception and is worth spelling
out, since it is the setting most likely to be in a file: colour is a choice
rather than a switch, so colour = never in a file still yields to --colour always like any other value. no-color = true is a switch, so it yields to
nothing, and it beats --colour always besides, because --no-color means
--colour never however it arrived. Set colour, not no-color, in a file
you want to argue with later. Anything else, and the answer is a different
file.
The first of these that exists is read, and only that one. They are not merged: a setting that comes from two files at once is a sett
Content type
Image
Digest
sha256:c1085b138…
Size
48.7 MB
Last updated
1 minute ago
docker pull mjaksn/nettail