netflower 1.0.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.
netflower/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from ._convert import convert_pcap_to_csv
2
+ from ._capture import capture_live
3
+
4
+ __all__ = ["convert_pcap_to_csv", "capture_live"]
5
+ __version__ = "1.0.0"
netflower/_capture.py ADDED
@@ -0,0 +1,222 @@
1
+ """
2
+ Live packet capture using system libpcap via ctypes.
3
+
4
+ Usage:
5
+ from netflower import capture_live
6
+
7
+ handle = capture_live("eth0", on_flow=lambda flow: print(flow), idle_timeout=30)
8
+ handle.start()
9
+ # ... do work ...
10
+ handle.stop()
11
+ """
12
+
13
+ import ctypes
14
+ import threading
15
+
16
+ from ._libpcap import (
17
+ PcapHandler_cb, PktHdr,
18
+ pcap_open_live, pcap_loop, pcap_breakloop, pcap_close,
19
+ pcap_dump_open, pcap_dump, pcap_dump_close,
20
+ )
21
+ from ._parser import _parse_packet
22
+ from ._session import FlowSession
23
+ from ._constants import FLOW_TIMEOUT
24
+
25
+
26
+ class CallbackWriter:
27
+ """Writer that calls on_flow(dict) instead of writing to a CSV file."""
28
+
29
+ def __init__(self, on_flow: callable) -> None:
30
+ self._on_flow = on_flow
31
+
32
+ def write(self, flow_dict: dict) -> None:
33
+ self._on_flow(flow_dict)
34
+
35
+
36
+ class _PcapSavingWriter:
37
+ """
38
+ Wraps on_flow callable. When a flow is emitted, also dumps its raw packets
39
+ to a .pcap file in pcap_dir before clearing the buffer.
40
+ """
41
+
42
+ def __init__(self, on_flow: callable, pcap_dir: str, pcap_handle) -> None:
43
+ self._on_flow = on_flow
44
+ self._pcap_dir = pcap_dir
45
+ self._pcap_handle = pcap_handle
46
+ # flow_key -> list of (PktHdr, bytes)
47
+ self._buffers: dict[tuple, list] = {}
48
+
49
+ def write(self, flow_dict: dict) -> None:
50
+ key = (
51
+ flow_dict.get("protocol"),
52
+ flow_dict.get("src_ip"),
53
+ flow_dict.get("dst_ip"),
54
+ flow_dict.get("src_port"),
55
+ flow_dict.get("dst_port"),
56
+ )
57
+ packets = self._buffers.pop(key, [])
58
+ if packets:
59
+ ts_us = int(flow_dict.get("timestamp", 0) * 1_000_000)
60
+ fname = (
61
+ f"{self._pcap_dir}/"
62
+ f"{flow_dict.get('src_ip')}-{flow_dict.get('src_port')}-"
63
+ f"{flow_dict.get('dst_ip')}-{flow_dict.get('dst_port')}-"
64
+ f"{flow_dict.get('protocol')}-{ts_us}.pcap"
65
+ ).encode()
66
+ dumper = pcap_dump_open(self._pcap_handle, fname)
67
+ if dumper:
68
+ for hdr, data in packets:
69
+ pcap_dump(dumper, ctypes.byref(hdr), data)
70
+ pcap_dump_close(dumper)
71
+ self._on_flow(flow_dict)
72
+
73
+ def buffer_packet(self, key: tuple, hdr: PktHdr, data: bytes) -> None:
74
+ if key not in self._buffers:
75
+ self._buffers[key] = []
76
+ self._buffers[key].append((hdr, data))
77
+
78
+
79
+ class CaptureHandle:
80
+ """
81
+ Handle returned by capture_live(). Controls start/stop of capture thread.
82
+
83
+ Parameters
84
+ ----------
85
+ interface: Network interface name as bytes (e.g. b"eth0").
86
+ on_flow: Callable receiving a flow dict when a flow completes.
87
+ idle_timeout: Seconds of inactivity before a flow is emitted.
88
+ flow_timeout: Absolute max flow duration before forced emit.
89
+ save_pcap: If True, save raw packets of each completed flow to pcap_dir.
90
+ pcap_dir: Directory path for .pcap files. Required when save_pcap=True.
91
+ """
92
+
93
+ _GC_INTERVAL = 1000
94
+
95
+ def __init__(
96
+ self,
97
+ interface: bytes,
98
+ on_flow: callable,
99
+ idle_timeout: float,
100
+ flow_timeout: float,
101
+ save_pcap: bool,
102
+ pcap_dir: str | None,
103
+ ) -> None:
104
+ if save_pcap and pcap_dir is None:
105
+ raise ValueError("pcap_dir is required when save_pcap=True")
106
+ self._interface = interface
107
+ self._on_flow = on_flow
108
+ self._idle_timeout = idle_timeout
109
+ self._flow_timeout = flow_timeout
110
+ self._save_pcap = save_pcap
111
+ self._pcap_dir = pcap_dir
112
+ self._handle = None
113
+ self._thread: threading.Thread | None = None
114
+ self._session = None
115
+ self._writer = None
116
+ self._callback = None
117
+
118
+ def start(self) -> None:
119
+ errbuf = ctypes.create_string_buffer(256)
120
+ self._handle = pcap_open_live(self._interface, 65535, 1, 1000, errbuf)
121
+ if not self._handle:
122
+ raise RuntimeError(f"pcap_open_live failed: {errbuf.value.decode()}")
123
+
124
+ if self._save_pcap:
125
+ self._writer = _PcapSavingWriter(self._on_flow, self._pcap_dir, self._handle)
126
+ else:
127
+ self._writer = CallbackWriter(self._on_flow)
128
+
129
+ self._session = FlowSession(self._writer, self._flow_timeout)
130
+ self._pkt_counter = 0
131
+
132
+ @PcapHandler_cb
133
+ def _callback(user, pkthdr_p, pkt_data):
134
+ hdr = pkthdr_p.contents
135
+ ts = hdr.ts_sec + hdr.ts_usec / 1_000_000
136
+ buf = ctypes.string_at(pkt_data, hdr.caplen)
137
+ parsed = _parse_packet(buf)
138
+ if parsed is None:
139
+ return
140
+ src_ip, dst_ip, src_port, dst_port, proto, pkt_len, hdr_len, pay_len, flags, win = parsed
141
+ if self._save_pcap:
142
+ fwd_key = (proto, src_ip, dst_ip, src_port, dst_port)
143
+ bwd_key = (proto, dst_ip, src_ip, dst_port, src_port)
144
+ key = fwd_key if (fwd_key in self._session._flows
145
+ or bwd_key not in self._session._flows) else bwd_key
146
+ self._writer.buffer_packet(key, hdr, buf)
147
+ self._session.process(ts, src_ip, dst_ip, src_port, dst_port,
148
+ proto, pkt_len, hdr_len, pay_len, flags, win)
149
+ self._pkt_counter += 1
150
+ if self._pkt_counter % self._GC_INTERVAL == 0:
151
+ self._session.gc(ts)
152
+
153
+ # Keep reference to prevent ctypes GC
154
+ self._callback = _callback
155
+
156
+ self._thread = threading.Thread(
157
+ target=pcap_loop,
158
+ args=(self._handle, -1, self._callback, None),
159
+ daemon=True,
160
+ )
161
+ self._thread.start()
162
+
163
+ def stop(self) -> None:
164
+ if self._handle:
165
+ pcap_breakloop(self._handle)
166
+ if self._thread:
167
+ self._thread.join(timeout=5)
168
+ if self._session:
169
+ self._session.flush_all()
170
+ if self._handle:
171
+ pcap_close(self._handle)
172
+ self._handle = None
173
+
174
+ def __enter__(self):
175
+ return self
176
+
177
+ def __exit__(self, *_):
178
+ self.stop()
179
+
180
+
181
+ def capture_live(
182
+ interface: str,
183
+ on_flow: callable,
184
+ idle_timeout: float = 30.0,
185
+ flow_timeout: float = FLOW_TIMEOUT,
186
+ save_pcap: bool = False,
187
+ pcap_dir: str | None = None,
188
+ ) -> CaptureHandle:
189
+ """
190
+ Capture live packets from a network interface and emit completed flows.
191
+
192
+ Requires root or CAP_NET_RAW privilege.
193
+
194
+ Parameters
195
+ ----------
196
+ interface: Network interface name (e.g. "eth0").
197
+ on_flow: Called with a flow dict (82 fields) when a flow completes.
198
+ Same column names as convert_pcap_to_csv output.
199
+ idle_timeout: Seconds of inactivity before a flow is emitted (default 30).
200
+ flow_timeout: Absolute max flow duration in seconds (default 120).
201
+ save_pcap: Save raw packets of each completed flow to a .pcap file.
202
+ pcap_dir: Directory for .pcap files. Required when save_pcap=True.
203
+
204
+ Returns
205
+ -------
206
+ CaptureHandle — call .start() to begin capture, .stop() to end.
207
+
208
+ Example
209
+ -------
210
+ >>> handle = capture_live("eth0", on_flow=lambda f: print(f["flow_duration"]))
211
+ >>> handle.start()
212
+ >>> import time; time.sleep(60)
213
+ >>> handle.stop()
214
+ """
215
+ return CaptureHandle(
216
+ interface=interface.encode() if isinstance(interface, str) else interface,
217
+ on_flow=on_flow,
218
+ idle_timeout=idle_timeout,
219
+ flow_timeout=flow_timeout,
220
+ save_pcap=save_pcap,
221
+ pcap_dir=pcap_dir,
222
+ )
@@ -0,0 +1,18 @@
1
+ FLOW_TIMEOUT = 120.0 # seconds of inactivity before a flow is flushed
2
+ ACTIVE_TIMEOUT = 5.0 # gap > this separates active from idle periods
3
+ CLUMP_TIMEOUT = 1.0 # gap > this starts a new subflow
4
+ BULK_BOUND = 4 # minimum packets required to register a bulk transfer
5
+ CSV_BUFFER_ROWS = 500 # rows buffered in memory before writing to disk
6
+
7
+ # TCP flag bitmasks (RFC 793 / RFC 3168)
8
+ TCP_FIN = 0x01
9
+ TCP_SYN = 0x02
10
+ TCP_RST = 0x04
11
+ TCP_PSH = 0x08
12
+ TCP_ACK = 0x10
13
+ TCP_URG = 0x20
14
+ TCP_ECE = 0x40
15
+ TCP_CWR = 0x80
16
+
17
+ FORWARD = 0
18
+ BACKWARD = 1
netflower/_convert.py ADDED
@@ -0,0 +1,248 @@
1
+ """
2
+ Main conversion pipeline: PCAP → FlowSession → CSV.
3
+
4
+ Supports both pcap and pcapng formats.
5
+ Packets are streamed one by one; no packet objects are retained after processing.
6
+
7
+ Parallelism strategy (n_jobs > 1):
8
+ The main process reads packets sequentially and routes each one to a worker
9
+ subprocess based on a deterministic hash of the bidirectional flow key.
10
+ Each worker owns a disjoint subset of flows and writes to a temp CSV.
11
+ Temp CSVs are merged into the final output after all workers finish.
12
+ """
13
+
14
+ import os
15
+ import tempfile
16
+ from multiprocessing import Process, Queue
17
+ from typing import Iterator
18
+
19
+ import dpkt
20
+ import dpkt.pcapng
21
+
22
+ from ._session import FlowSession
23
+ from ._writer import CsvWriter
24
+ from ._parser import _parse_packet
25
+ from ._constants import FLOW_TIMEOUT, CSV_BUFFER_ROWS
26
+
27
+
28
+ def _open_pcap(path: str):
29
+ """Return an iterable of (timestamp, raw_bytes) from a pcap or pcapng file."""
30
+ with open(path, "rb") as fh:
31
+ magic = fh.read(4)
32
+
33
+ if magic == b"\x0a\x0d\x0d\x0a":
34
+ return _iter_pcapng(path)
35
+ return _iter_pcap(path)
36
+
37
+
38
+ def _iter_pcap(path: str) -> Iterator[tuple[float, bytes]]:
39
+ with open(path, "rb") as fh:
40
+ yield from dpkt.pcap.Reader(fh)
41
+
42
+
43
+ def _iter_pcapng(path: str) -> Iterator[tuple[float, bytes]]:
44
+ with open(path, "rb") as fh:
45
+ yield from dpkt.pcapng.Reader(fh)
46
+
47
+
48
+ def _route(src_ip: str, dst_ip: str, src_port: int, dst_port: int, proto: int, n_jobs: int) -> int:
49
+ """Return the worker index for a bidirectional flow (order-independent)."""
50
+ a = (src_ip, src_port)
51
+ b = (dst_ip, dst_port)
52
+ if a > b:
53
+ a, b = b, a
54
+ return hash((a, b, proto)) % n_jobs
55
+
56
+
57
+ def _worker(task_queue: Queue, result_queue: Queue, output_path: str,
58
+ flow_timeout: float, buffer_rows: int) -> None:
59
+ """Subprocess: drain task_queue, track flows, write CSV to output_path."""
60
+ with CsvWriter(output_path, buffer_rows) as writer:
61
+ session = FlowSession(writer, flow_timeout)
62
+ while True:
63
+ item = task_queue.get()
64
+ if item is None:
65
+ break
66
+ kind = item[0]
67
+ if kind == "pkt":
68
+ _, ts, src_ip, dst_ip, src_port, dst_port, proto, pkt_len, hdr_len, pay_len, flags, win = item
69
+ session.process(ts, src_ip, dst_ip, src_port, dst_port, proto,
70
+ pkt_len, hdr_len, pay_len, flags, win)
71
+ elif kind == "gc":
72
+ session.gc(item[1])
73
+ session.flush_all()
74
+ result_queue.put(writer.row_count)
75
+
76
+
77
+ def _merge_csvs(sources: list[str], output_path: str) -> None:
78
+ import csv
79
+
80
+ header_written = False
81
+ fieldnames = None
82
+ with open(output_path, "w", newline="", buffering=1 << 16) as out_fh:
83
+ out_writer = None
84
+ for src in sources:
85
+ if not os.path.exists(src) or os.path.getsize(src) == 0:
86
+ continue
87
+ with open(src, "r", newline="") as in_fh:
88
+ reader = csv.DictReader(in_fh)
89
+ if not header_written:
90
+ fieldnames = reader.fieldnames
91
+ out_writer = csv.DictWriter(out_fh, fieldnames=fieldnames)
92
+ out_writer.writeheader()
93
+ header_written = True
94
+ for row in reader:
95
+ out_writer.writerow(row)
96
+
97
+
98
+ def convert_pcap_to_csv(
99
+ input_path: str,
100
+ output_path: str,
101
+ flow_timeout: float = FLOW_TIMEOUT,
102
+ gc_interval: int = 1000,
103
+ buffer_rows: int = CSV_BUFFER_ROWS,
104
+ n_jobs: int = 1,
105
+ ) -> int:
106
+ """
107
+ Convert a PCAP (or pcapng) file into a flow-based CSV file.
108
+
109
+ Each row in the output CSV represents one bidirectional network flow and
110
+ contains 82 features compatible with the CICFlowMeter feature set.
111
+
112
+ Parameters
113
+ ----------
114
+ input_path: Path to the input .pcap or .pcapng file.
115
+ output_path: Path for the output .csv file (created or overwritten).
116
+ flow_timeout: Seconds of inactivity before a flow is flushed (default 120).
117
+ gc_interval: Run idle-flow garbage collection every N packets (default 1000).
118
+ buffer_rows: Rows buffered in memory before a disk write (default 500).
119
+ n_jobs: Number of worker processes (default 1).
120
+ -1 uses all available CPUs.
121
+
122
+ Returns
123
+ -------
124
+ Number of flow rows written to the CSV.
125
+ """
126
+ if n_jobs == 0:
127
+ raise ValueError("n_jobs=0 is invalid. Use n_jobs=1 for single-process or n_jobs=-1 for all CPUs.")
128
+ if n_jobs == -1:
129
+ n_jobs = os.cpu_count() or 1
130
+ elif n_jobs < -1:
131
+ n_jobs = max(1, (os.cpu_count() or 1) + n_jobs + 1)
132
+
133
+ if n_jobs == 1:
134
+ return _convert_single(input_path, output_path, flow_timeout, gc_interval, buffer_rows)
135
+ return _convert_parallel(input_path, output_path, flow_timeout, gc_interval, buffer_rows, n_jobs)
136
+
137
+
138
+ def _convert_single(
139
+ input_path: str,
140
+ output_path: str,
141
+ flow_timeout: float,
142
+ gc_interval: int,
143
+ buffer_rows: int,
144
+ ) -> int:
145
+ with CsvWriter(output_path, buffer_rows) as writer:
146
+ session = FlowSession(writer, flow_timeout)
147
+ pkt_counter = 0
148
+ last_ts = 0.0
149
+
150
+ for ts, buf in _open_pcap(input_path):
151
+ parsed = _parse_packet(buf)
152
+ if parsed is None:
153
+ continue
154
+
155
+ src_ip, dst_ip, src_port, dst_port, proto, pkt_len, hdr_len, pay_len, flags, win = parsed
156
+ session.process(ts, src_ip, dst_ip, src_port, dst_port, proto,
157
+ pkt_len, hdr_len, pay_len, flags, win)
158
+
159
+ pkt_counter += 1
160
+ last_ts = ts
161
+
162
+ if pkt_counter % gc_interval == 0:
163
+ session.gc(last_ts)
164
+
165
+ session.flush_all()
166
+
167
+ return writer.row_count
168
+
169
+
170
+ def _convert_parallel(
171
+ input_path: str,
172
+ output_path: str,
173
+ flow_timeout: float,
174
+ gc_interval: int,
175
+ buffer_rows: int,
176
+ n_jobs: int,
177
+ ) -> int:
178
+ tmp_dir = tempfile.mkdtemp(prefix="netflower_")
179
+ tmp_files = [os.path.join(tmp_dir, f"worker_{i}.csv") for i in range(n_jobs)]
180
+ task_queues = [Queue(maxsize=2000) for _ in range(n_jobs)]
181
+ result_queue: Queue = Queue()
182
+
183
+ workers = [
184
+ Process(
185
+ target=_worker,
186
+ args=(task_queues[i], result_queue, tmp_files[i], flow_timeout, buffer_rows),
187
+ daemon=True,
188
+ )
189
+ for i in range(n_jobs)
190
+ ]
191
+ for w in workers:
192
+ w.start()
193
+
194
+ try:
195
+ pkt_counter = 0
196
+ last_ts = 0.0
197
+
198
+ for ts, buf in _open_pcap(input_path):
199
+ parsed = _parse_packet(buf)
200
+ if parsed is None:
201
+ continue
202
+
203
+ src_ip, dst_ip, src_port, dst_port, proto, pkt_len, hdr_len, pay_len, flags, win = parsed
204
+ wid = _route(src_ip, dst_ip, src_port, dst_port, proto, n_jobs)
205
+ task_queues[wid].put((
206
+ "pkt", ts, src_ip, dst_ip, src_port, dst_port, proto,
207
+ pkt_len, hdr_len, pay_len, flags, win,
208
+ ))
209
+
210
+ pkt_counter += 1
211
+ last_ts = ts
212
+
213
+ if pkt_counter % gc_interval == 0:
214
+ for q in task_queues:
215
+ q.put(("gc", last_ts))
216
+
217
+ for q in task_queues:
218
+ q.put(None)
219
+
220
+ for w in workers:
221
+ w.join()
222
+ if w.exitcode != 0:
223
+ raise RuntimeError(f"Worker process exited with code {w.exitcode}")
224
+
225
+ except Exception:
226
+ for q in task_queues:
227
+ try:
228
+ q.put_nowait(None)
229
+ except Exception:
230
+ pass
231
+ for w in workers:
232
+ w.terminate()
233
+ raise
234
+
235
+ total = sum(result_queue.get() for _ in range(n_jobs))
236
+ _merge_csvs(tmp_files, output_path)
237
+
238
+ for f in tmp_files:
239
+ try:
240
+ os.unlink(f)
241
+ except OSError:
242
+ pass
243
+ try:
244
+ os.rmdir(tmp_dir)
245
+ except OSError:
246
+ pass
247
+
248
+ return total