bwninja 1.0.0__tar.gz

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.
bwninja-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.1
2
+ Name: bwninja
3
+ Version: 1.0.0
4
+ Summary: A modern replacement for iftop
5
+ Author-email: Tom Flanagan <tom@zkpq.ca>
6
+ Project-URL: Homepage, https://bwninja.zkpq.ca
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console :: Curses
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Intended Audience :: Telecommunications Industry
15
+ Classifier: License :: Free For Educational Use
16
+ Classifier: License :: Free For Home Use
17
+ Classifier: License :: Free for non-commercial use
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Topic :: Internet
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: System :: Networking :: Monitoring
24
+ Classifier: Topic :: Utilities
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: blessed
27
+ Requires-Dist: bcc
28
+ Requires-Dist: pandas
29
+
30
+ # Bandwidth Ninja (bwninja)
31
+
32
+ A modern replacement for `iftop`
33
+
@@ -0,0 +1,4 @@
1
+ # Bandwidth Ninja (bwninja)
2
+
3
+ A modern replacement for `iftop`
4
+
File without changes
@@ -0,0 +1 @@
1
+ version = __version__ = '1.0.0'
@@ -0,0 +1,74 @@
1
+ import functools
2
+ import ipaddress
3
+ import logging
4
+ import pathlib
5
+ import time
6
+
7
+ from bcc import BPF
8
+
9
+
10
+ LOG = logging.getLogger('net.net')
11
+
12
+ import dataclasses
13
+ @dataclasses.dataclass
14
+ class Flow:
15
+ src: ipaddress.IPv4Address
16
+ dst: ipaddress.IPv4Address
17
+ # TODO:
18
+ # ipv6
19
+ # protocol
20
+ # srcport
21
+ # dstport
22
+ # num packets
23
+ bytes: int
24
+
25
+
26
+ @functools.cache
27
+ def get_bpf():
28
+ src_path = str(pathlib.Path(__file__).parent/'net.c')
29
+ return BPF(src_file=src_path, debug=0)
30
+
31
+
32
+ def monitor(attachment, interface, printk=False):
33
+ b = get_bpf()
34
+ if attachment == 'socket':
35
+ fn=b.load_func("sock_peek_packet", BPF.SOCKET_FILTER)
36
+ BPF.attach_raw_socket(dev=interface, fn=fn)
37
+ elif attachment == 'xdp':
38
+ fn=b.load_func("xdp_peek_packet", BPF.XDP)
39
+ BPF.attach_xdp(dev=interface, fn=fn)
40
+ else:
41
+ # TODO: tc (traffic control)
42
+ raise ValueError(attachment)
43
+
44
+ flows_hash = b["flows"]
45
+ LOG.info('bpf ok')
46
+ if printk:
47
+ b.trace_print()
48
+
49
+ try:
50
+ while 1:
51
+ flows = list(flows_hash.items_lookup_and_delete_batch())
52
+ LOG.debug(f'{len(flows)} flows recoded')
53
+ results = []
54
+ for flow, stat in flows:
55
+ a = ipaddress.ip_address(flow.ip_a)
56
+ b = ipaddress.ip_address(flow.ip_b)
57
+ f = Flow(b, a, stat)
58
+ results.append(f)
59
+ # debugging
60
+ LOG.debug(f'{a} -> {b}: {stat}')
61
+ yield results
62
+
63
+ except KeyboardInterrupt:
64
+ pass
65
+
66
+
67
+ if __name__ == '__main__':
68
+ logging.basicConfig(
69
+ level=logging.DEBUG,
70
+ format='%(asctime)s:%(levelname)s:%(name)s:%(funcName)s:%(lineno)d %(message)s')
71
+
72
+ for data in monitor(attachment='socket', interface='eth0'):
73
+ print(data)
74
+ time.sleep(1)
@@ -0,0 +1,276 @@
1
+ import ipaddress
2
+ import argparse
3
+ import dataclasses
4
+ import logging
5
+ import socket
6
+ import threading
7
+ import time
8
+ import enum
9
+
10
+ import blessed
11
+ import pandas
12
+
13
+ from . import net
14
+
15
+ LOG = logging.getLogger('nettui')
16
+
17
+ # TODO print pretty bar graph
18
+ # TODO lan rdns somehow
19
+ # TODO process (pid) tracking
20
+ # TODO hsz() coloring
21
+ # TODO cursor
22
+ # TODO ipv6 support
23
+ # TODO tree of flows to expand host rollup to ports etc
24
+ # TODO root tree by pid, protocol, etc
25
+ # TODO mouse cursor
26
+ # TODO show extra data of selected
27
+ # TODO show time series graph of selected row
28
+
29
+
30
+ def get_local_addresses():
31
+ import fcntl
32
+ import struct
33
+ addrs = {}
34
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
35
+ for i, name in socket.if_nameindex():
36
+ try:
37
+ ip = socket.inet_ntoa(fcntl.ioctl(
38
+ s.fileno(),
39
+ 0x8915, # SIOCGIFADDR
40
+ struct.pack('256s', name[:15].encode('utf8'))
41
+ )[20:24])
42
+ addrs[ipaddress.IPv4Address(ip)] = name
43
+ except OSError:
44
+ LOG.warning(f'Could not resolve address for interface {name}')
45
+ return addrs
46
+
47
+
48
+ class DNSCache:
49
+ def __init__(self):
50
+ self.resolved = {}
51
+ self.pending = set()
52
+ self.event = threading.Event()
53
+ self.thread = threading.Thread(target=self.run)
54
+ self.thread.daemon = True
55
+ self.thread.start()
56
+
57
+ def run(self):
58
+ while 1:
59
+ self.event.wait(timeout=10)
60
+ self.event.clear()
61
+ for ip in list(self.pending):
62
+ try:
63
+ hostname, aliaslist, ipaddrlist = socket.gethostbyaddr(str(ip))
64
+ self.resolved[ip] = hostname
65
+ self.pending.remove(ip)
66
+ except socket.herror as e:
67
+ if e.errno == 1:
68
+ self.resolved[ip] = f'{ip} ?' # TODO use some coloring
69
+ self.pending.remove(ip)
70
+
71
+ def resolve(self, key):
72
+ try:
73
+ return self.resolved[key]
74
+ except KeyError: pass
75
+
76
+ if key in LOCAL:
77
+ s = f'[{HOST}.{LOCAL[key]}] {key}'
78
+ self.resolved[key] = s
79
+ return s
80
+
81
+ self.pending.add(key)
82
+ self.event.set()
83
+
84
+ return str(key) # TODO coloring
85
+
86
+
87
+ TERM = blessed.Terminal()
88
+ TERM.number_of_colors = 256
89
+ DNS = DNSCache()
90
+ HOST = socket.gethostname()
91
+ LOCAL = get_local_addresses()
92
+
93
+ class Address(ipaddress.IPv4Address):
94
+ # TODO IPv6
95
+ class Type(enum.IntEnum):
96
+ LOCAL = enum.auto()
97
+ UNKNOWN = enum.auto()
98
+ LINK_LOCAL = enum.auto()
99
+ LOOPBACK = enum.auto()
100
+ MULTICAST = enum.auto()
101
+ PRIVATE = enum.auto()
102
+ RESERVED = enum.auto()
103
+ UNSPECIFIED = enum.auto()
104
+ GLOBAL = enum.auto()
105
+
106
+
107
+ def type_key(self):
108
+ if self in LOCAL: return Address.Type.LOCAL
109
+ if self.is_global: return Address.Type.GLOBAL
110
+ if self.is_link_local: return Address.Type.LINK_LOCAL
111
+ if self.is_loopback: return Address.Type.LOOPBACK
112
+ if self.is_multicast: return Address.Type.MULTICAST
113
+ if self.is_private: return Address.Type.PRIVATE
114
+ if self.is_reserved: return Address.Type.RESERVED
115
+ if self.is_unspecified: return Address.Type.UNSPECIFIED
116
+ return Address.Type.UNKNOWN
117
+
118
+ def sort_key(self):
119
+ return self.type_key(), self
120
+
121
+ def pretty(self):
122
+ c = {
123
+ Address.Type.LOCAL: TERM.on_green,
124
+ Address.Type.PRIVATE: TERM.orange,
125
+ Address.Type.GLOBAL: TERM.cyan,
126
+ Address.Type.MULTICAST: TERM.red,
127
+ }.get(self.type_key(), TERM.grey)
128
+ s = DNS.resolve(self)
129
+ return c + s + TERM.normal
130
+
131
+ class Stats:
132
+ def __init__(self, ser):
133
+ self.ser = ser
134
+
135
+ @property
136
+ def duration(self):
137
+ ts = self.ser['timestamp']
138
+ return max(1, ts.max() - ts.min())
139
+
140
+ @property
141
+ def mean_bytes(self):
142
+ return self.ser['bytes'].sum() / self.duration
143
+
144
+ class NetTui:
145
+ def __init__(self, interval, interfaces, **kw):
146
+ self.interval = interval
147
+ self.dns = DNSCache()
148
+ if not interfaces:
149
+ interfaces = list(LOCAL.values())
150
+ self.mons = {dev: net.monitor(interface=dev, **kw) for dev in interfaces}
151
+ [next(m) for m in self.mons.values()]
152
+ self.df = pandas.DataFrame()
153
+ self.log = {}
154
+ self.history = 600
155
+
156
+
157
+ def run(self):
158
+ # TODO reconfigure logging to not print to screen now
159
+ print("press 'q' to quit.")
160
+ with TERM.cbreak(), TERM.hidden_cursor(), TERM.fullscreen():
161
+ while 1:
162
+ self.update_flows()
163
+ print(TERM.home, end='')
164
+ # print(TERM.home + TERM.clear, end='')
165
+
166
+ self.draw_flows()
167
+ try: val = TERM.inkey(timeout=self.interval)
168
+ except KeyboardInterrupt: break
169
+
170
+ # timeout
171
+ if val == '': continue
172
+
173
+ if val.is_sequence:
174
+ print("got sequence: {0}.".format((str(val), val.name, val.code)))
175
+ continue
176
+
177
+ print("got {0}.".format(val))
178
+ if val.lower() == 'q': break
179
+ print(f'bye!{TERM.normal}')
180
+
181
+ def update_flows(self):
182
+ # LOG.debug("update")
183
+
184
+ for name, mon in self.mons.items():
185
+ now = time.time()
186
+ rows = []
187
+ for flow in next(mon):
188
+ d = dataclasses.asdict(flow)
189
+ d['src'] = Address(d['src'].packed)
190
+ d['dst'] = Address(d['dst'].packed)
191
+ rows.append(d)
192
+ if rows:
193
+ df = pandas.DataFrame.from_records(rows, index=['src', 'dst'])
194
+ df['timestamp'] = now
195
+ self.log[now] = df
196
+
197
+ if self.log:
198
+ self.df = pandas.concat(self.log.values())
199
+ for ts in list(self.log.keys()):
200
+ if ts + self.history < now:
201
+ del self.log[ts]
202
+
203
+
204
+ def draw_flows(self):
205
+ now = time.time()
206
+ dur = 30
207
+ since = now - dur
208
+
209
+ if len(self.df) == 0: return
210
+ window = self.df.loc[self.df['timestamp'] > since]
211
+ if len(window) == 0: return
212
+
213
+ grouped = window.groupby(level=['src', 'dst'], observed=True)
214
+ pairs = set()
215
+ for pair in grouped.indices.keys():
216
+ key = tuple(sorted(pair, key=Address.sort_key))
217
+ pairs.add(key)
218
+
219
+ def sort_key(x):
220
+ a, b = x
221
+ return Address.sort_key(a) + Address.sort_key(b)
222
+
223
+ pairs = sorted(pairs, key=sort_key)
224
+
225
+ W = TERM.width
226
+
227
+ s = 0
228
+ e = len(pairs)
229
+ # print(TERM.move_xy(0, 1), end='')
230
+ print(f'{TERM.black_on_white} Network Flows past {dur}s {s+1}-{len(pairs)}{TERM.clear_eol}{TERM.normal}')
231
+ for i, (src, dst) in enumerate(pairs[s:e]):
232
+ try:
233
+ tx = grouped.get_group((src, dst))
234
+ tx_bytes = Stats(tx).mean_bytes
235
+ except KeyError:
236
+ tx_bytes = 0
237
+ try:
238
+ rx = grouped.get_group((dst, src))
239
+ rx_bytes = Stats(rx).mean_bytes
240
+ except KeyError:
241
+ rx_bytes = 0
242
+
243
+ P = 24
244
+ src_s = TERM.ljust(src.pretty(), W // 2 - P)
245
+ dst_s = TERM.rjust(dst.pretty(), W // 2 - P)
246
+ print(f'{TERM.dim}{i+s+1:3d}{TERM.normal} {src_s} {tx_bytes:10,.0f} <--> {rx_bytes:10,.0f} {dst_s}{TERM.clear_eol}')
247
+ print(f'{TERM.black_on_white}end{TERM.clear_eol}{TERM.normal}')
248
+
249
+ for w in range(e - s + 3, TERM.height):
250
+ print(TERM.normal + '>' + TERM.clear_eol)
251
+
252
+ # TODO debug logs
253
+ # print(TERM.clear)
254
+ # print(TERM.move_xy(0, 40))
255
+
256
+
257
+ def main():
258
+ parser = argparse.ArgumentParser(
259
+ formatter_class=argparse.RawTextHelpFormatter,
260
+ description=__doc__)
261
+
262
+ parser.add_argument('--interfaces', '-i', nargs='*', type=str, default=())
263
+ parser.add_argument('--interval', '-t', type=float, default=0.3)
264
+ parser.add_argument('--attachment', '-a', type=str, default='socket')
265
+ parser.add_argument('--printk', default=False, action='store_true')
266
+
267
+ args = parser.parse_args()
268
+ NetTui(**vars(args)).run()
269
+
270
+
271
+ if __name__ == '__main__':
272
+ logging.basicConfig(
273
+ level=logging.INFO,
274
+ format='%(asctime)s:%(levelname)s:%(name)s:%(funcName)s:%(lineno)d %(message)s')
275
+
276
+ main()
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.1
2
+ Name: bwninja
3
+ Version: 1.0.0
4
+ Summary: A modern replacement for iftop
5
+ Author-email: Tom Flanagan <tom@zkpq.ca>
6
+ Project-URL: Homepage, https://bwninja.zkpq.ca
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Environment :: Console :: Curses
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Intended Audience :: Telecommunications Industry
15
+ Classifier: License :: Free For Educational Use
16
+ Classifier: License :: Free For Home Use
17
+ Classifier: License :: Free for non-commercial use
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Topic :: Internet
22
+ Classifier: Topic :: System :: Monitoring
23
+ Classifier: Topic :: System :: Networking :: Monitoring
24
+ Classifier: Topic :: Utilities
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: blessed
27
+ Requires-Dist: bcc
28
+ Requires-Dist: pandas
29
+
30
+ # Bandwidth Ninja (bwninja)
31
+
32
+ A modern replacement for `iftop`
33
+
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ bwninja/__init__.py
4
+ bwninja/_version.py
5
+ bwninja/net.py
6
+ bwninja/tui.py
7
+ bwninja.egg-info/PKG-INFO
8
+ bwninja.egg-info/SOURCES.txt
9
+ bwninja.egg-info/dependency_links.txt
10
+ bwninja.egg-info/entry_points.txt
11
+ bwninja.egg-info/requires.txt
12
+ bwninja.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bwninja = bwninja.tui:main
@@ -0,0 +1,3 @@
1
+ blessed
2
+ bcc
3
+ pandas
@@ -0,0 +1 @@
1
+ bwninja
@@ -0,0 +1,51 @@
1
+
2
+ [project]
3
+ name = "bwninja"
4
+ description="A modern replacement for iftop"
5
+ readme = "README.md"
6
+ authors = [
7
+ {name="Tom Flanagan", email="tom@zkpq.ca"}
8
+ ]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console :: Curses",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Intended Audience :: Information Technology",
16
+ "Intended Audience :: System Administrators",
17
+ "Intended Audience :: Telecommunications Industry",
18
+ "License :: Free For Educational Use",
19
+ "License :: Free For Home Use",
20
+ "License :: Free for non-commercial use",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Programming Language :: Python :: 3",
23
+ "Programming Language :: Python",
24
+ "Topic :: Internet",
25
+ "Topic :: System :: Monitoring",
26
+ "Topic :: System :: Networking :: Monitoring",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dynamic = ["version"]
30
+
31
+ dependencies = [
32
+ "blessed",
33
+ "bcc",
34
+ "pandas",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://bwninja.zkpq.ca"
39
+
40
+ [project.scripts]
41
+ bwninja = "bwninja.tui:main"
42
+
43
+ [build-system]
44
+ requires = ["setuptools>=68"]
45
+ build-backend = "setuptools.build_meta"
46
+
47
+ [tool.setuptools]
48
+ packages = ["bwninja"]
49
+
50
+ [tool.setuptools.dynamic]
51
+ version = {attr = "bwninja._version.version"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+