Skip to content

Commit a409d87

Browse files
committed
ft-orchestration add pmacct.py
1 parent bd25e36 commit a409d87

2 files changed

Lines changed: 397 additions & 2 deletions

File tree

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
from ipaddress import IPv6Address
2+
import logging
3+
import shutil
4+
import tempfile
5+
import time
6+
from abc import ABC
7+
from dataclasses import dataclass, field, fields
8+
from os import path
9+
from typing import List, Optional
10+
11+
from lbr_testsuite.executable import (
12+
Daemon,
13+
ExecutableProcessError,
14+
Executor,
15+
Rsync,
16+
Tool,
17+
)
18+
from src.common.tool_is_installed import assert_tool_is_installed
19+
from src.common.typed_dataclass import typed_dataclass
20+
from src.common.utils import duplicate_executor
21+
from src.config.common import InterfaceCfg
22+
from src.probe.interface import ProbeException, ProbeInterface
23+
from src.probe.mpstat import MpStat
24+
from src.probe.probe_target import ProbeTarget
25+
26+
27+
@typed_dataclass
28+
@dataclass
29+
class PmacctSettings(ABC):
30+
"""
31+
These settings can be set in probes.yml under `connector:`.
32+
Example:
33+
connector:
34+
time_elements: 1
35+
input:
36+
type: "pcap"
37+
For possible values see http://www.pmacct.net/CONFIG-KEYS-1.7.9
38+
"""
39+
40+
nfprobe_receiver: str
41+
pcap_interface: str
42+
daemonize: bool = False
43+
aggregate: list = field(
44+
default_factory=lambda: [
45+
"src_host",
46+
"dst_host",
47+
"src_port",
48+
"dst_port",
49+
"proto",
50+
]
51+
)
52+
plugins: list = field(default_factory=lambda: ["nfprobe"])
53+
nfprobe_version: int = 10
54+
nfprobe_engine: Optional[str] = None
55+
nfprobe_timeouts: dict = field(
56+
default_factory=lambda: {
57+
"tcp": 30,
58+
"udp": 30,
59+
"icmp": 30,
60+
"general": 30,
61+
"maxlife": 300,
62+
"expint": 1,
63+
}
64+
)
65+
nfprobe_maxflows: Optional[int] = None
66+
extras: Optional[dict] = None
67+
68+
69+
class Pmacct(ProbeInterface):
70+
host_statistics = None
71+
72+
def __init__(
73+
self,
74+
executor: Executor,
75+
target: ProbeTarget,
76+
protocols: List[str],
77+
interfaces: List[InterfaceCfg],
78+
verbose: bool = False,
79+
mtu: int = 2048,
80+
sudo: bool = False,
81+
inactive_timeout: int = 30,
82+
active_timeout: int = 300,
83+
cache_size=None,
84+
rss_queues: int = 1,
85+
binary: str = "pmacctd",
86+
**kwargs: dict,
87+
):
88+
# initialize PmacctSettings and map FlowTest values to pmacct values
89+
if len(interfaces) > 1:
90+
raise NotImplementedError
91+
kwargs["pcap_interface"] = interfaces[0].name if interfaces else None
92+
93+
if isinstance(target.host, IPv6Address):
94+
kwargs["nfprobe_receiver"] = f"[{target.host}]:{target.port}"
95+
else:
96+
kwargs["nfprobe_receiver"] = f"{target.host}:{target.port}"
97+
98+
if target.protocol != "udp":
99+
raise NotImplementedError(
100+
f"pmacctd only supports udp and dtls and only udp is implemented here, not {target.protocol}"
101+
)
102+
103+
if cache_size:
104+
kwargs["nfprobe_maxflows"] = 2**cache_size
105+
106+
default_timeouts: dict = PmacctSettings.__dataclass_fields__[
107+
"nfprobe_timeouts"
108+
].default_factory()
109+
user_timeouts = kwargs.pop("nfprobe_timeouts", {})
110+
merged_timeouts = {**default_timeouts, **user_timeouts}
111+
merged_timeouts.update(
112+
{
113+
"tcp": inactive_timeout,
114+
"tcp.rst": inactive_timeout,
115+
"tcp.fin": inactive_timeout,
116+
"udp": inactive_timeout,
117+
"icmp": inactive_timeout,
118+
"general": inactive_timeout,
119+
"maxlife": active_timeout,
120+
}
121+
)
122+
kwargs["nfprobe_timeouts"] = merged_timeouts
123+
124+
# Split known and extra settings and instantiate PmacctSettings from kwargs
125+
settings_names = [f.name for f in fields(PmacctSettings)]
126+
defined_settings = {k: v for k, v in kwargs.items() if k in settings_names}
127+
extras = {k: v for k, v in kwargs.items() if k not in settings_names}
128+
if "extras" in defined_settings.keys():
129+
extras.update(defined_settings.get("extras", {}))
130+
del defined_settings["extras"]
131+
self._settings = PmacctSettings(**defined_settings, extras=extras or None)
132+
133+
# store internally required variables
134+
self._executor = executor
135+
self._fallback_executor, stats_executor = duplicate_executor(executor, 2)
136+
if protocols:
137+
raise NotImplementedError(
138+
"To support protocol filtering ndpi must be used which is currently not implemented"
139+
)
140+
141+
self._verbose = verbose
142+
self._timeouts = (active_timeout, inactive_timeout)
143+
self._mtu = mtu
144+
self._sudo = sudo
145+
self._binary = binary
146+
147+
assert_tool_is_installed(self._binary, executor)
148+
149+
self._local_workdir = tempfile.mkdtemp()
150+
self._log_file = path.join(self._local_workdir, f"{self._binary}.log")
151+
self._config_file = path.join(self._local_workdir, "settings.conf")
152+
self._cmd = None
153+
self.host_statistics = MpStat(stats_executor, self._binary)
154+
self._rsync = Rsync(executor)
155+
self._rss_queues_pcap = rss_queues
156+
157+
def _write_config(self, settings: PmacctSettings):
158+
config = {}
159+
160+
def to_config_literal(value) -> str:
161+
match value:
162+
case bool():
163+
return "true" if value else "false"
164+
case set() | list():
165+
return ", ".join(to_config_literal(v) for v in value)
166+
case dict():
167+
val = []
168+
for k, v in value.items():
169+
val.append(f"{k}={to_config_literal(v)}")
170+
return ":".join(val)
171+
case None:
172+
return None
173+
case _:
174+
return str(value)
175+
176+
for f in fields(settings):
177+
value = to_config_literal(getattr(settings, f.name))
178+
if value is None or not value:
179+
continue
180+
config.update({f.name: value})
181+
182+
if settings.extras:
183+
for key, value in settings.extras.items():
184+
value = to_config_literal(value)
185+
if value is None or not value:
186+
continue
187+
if key in config.keys():
188+
logging.warning(
189+
f"Duplicate config: {key}: {value} overwrites {config[key]}"
190+
)
191+
config.update({key: value})
192+
193+
with open(self._config_file, "w") as f:
194+
f.write("! Generated by FlowTest for -f option\n\n")
195+
f.write("\n".join(f"{k}: {v}" for k, v in config.items()))
196+
f.write("\n")
197+
198+
def _prepare_cmd(self, config_file: str) -> str:
199+
args = [self._binary]
200+
args.extend(["-f", config_file])
201+
202+
return " ".join(args)
203+
204+
def _before_start(self):
205+
self._write_config(self._settings)
206+
self._cmd = self._prepare_cmd(self._rsync.push_path(self._config_file))
207+
208+
Tool(
209+
f"ip link set {self._settings.pcap_interface} up",
210+
executor=self._executor,
211+
sudo=self._sudo,
212+
).run()
213+
Tool(
214+
f"ip link set {self._settings.pcap_interface} mtu {self._mtu}",
215+
executor=self._executor,
216+
sudo=self._sudo,
217+
).run()
218+
Tool(
219+
f"ethtool -K {self._settings.pcap_interface} gro off gso off tso off",
220+
executor=self._executor,
221+
sudo=self._sudo,
222+
).run()
223+
Tool(
224+
f"ethtool --set-channels {self._settings.pcap_interface} combined {self._rss_queues_pcap}",
225+
executor=self._executor,
226+
sudo=self._sudo,
227+
).run()
228+
229+
def start(self):
230+
"""
231+
Start the probe.
232+
"""
233+
logging.getLogger().info(
234+
f"Starting {self._binary} exporter on {self._settings.pcap_interface}."
235+
)
236+
237+
# check and stop running pmacctd instance
238+
check_running_cmd = f"pidof '{self._binary}'"
239+
running_processes = Tool(
240+
check_running_cmd, executor=self._executor, failure_verbosity="silent"
241+
).run()[0]
242+
if len(running_processes) > 0:
243+
pids = running_processes.split()
244+
for pid in pids:
245+
if not pid:
246+
continue
247+
running_pid = int(pid)
248+
self._stop_process(running_pid)
249+
time.sleep(2)
250+
self._before_start()
251+
252+
self._process = Daemon(self._cmd, executor=self._executor, sudo=self._sudo)
253+
# stderr is implicitly redirected to stdout
254+
self._process.set_outputs(self._log_file)
255+
self._process.start()
256+
time.sleep(1)
257+
258+
if not self._process.is_running():
259+
res = self._process.stop()
260+
return_code = self._process.returncode()
261+
self._process = None
262+
263+
# stderr is redirected to stdout
264+
err = res[0]
265+
logging.getLogger().error(
266+
"Unable to start probe on %s. %s return code: %d, error: %s",
267+
self._settings.pcap_interface,
268+
self._binary,
269+
return_code,
270+
err,
271+
)
272+
raise ProbeException(f"{self._binary} startup error")
273+
274+
self.host_statistics.start()
275+
276+
def _after_stop(self):
277+
pass
278+
279+
def _stop_process(self, pid):
280+
"""
281+
Stop exporter process.
282+
"""
283+
284+
Tool(
285+
f"kill -2 {pid}",
286+
executor=self._executor,
287+
failure_verbosity="silent",
288+
sudo=True,
289+
).run()
290+
ps_ec = Tool(
291+
f"ps -p {pid}", executor=self._executor, failure_verbosity="silent"
292+
)
293+
for _ in range(5):
294+
ps_ec.run()
295+
if ps_ec.returncode() == 1:
296+
return
297+
time.sleep(1)
298+
logging.getLogger().warning(
299+
"Unable to stop exporter process with SIGINT, using SIGKILL."
300+
)
301+
Tool(
302+
f"kill -9 {pid}",
303+
executor=self._executor,
304+
failure_verbosity="silent",
305+
sudo=True,
306+
).run()
307+
308+
def supported_fields(self):
309+
"""
310+
Get list of IPFIX fields the probe may export in its current configuration.
311+
"""
312+
# TODO: Implement
313+
raise NotImplementedError
314+
315+
def get_special_fields(self):
316+
"""
317+
Return dictionary of exported fields that need special evaluation.
318+
"""
319+
# TODO: Implement
320+
raise NotImplementedError
321+
322+
def stop(self):
323+
"""
324+
Stop the probe.
325+
"""
326+
# if process not running, method has no effect
327+
if self._process is None:
328+
return
329+
330+
logging.getLogger().info(f"Stopping {self._binary} exporter.")
331+
332+
stdout = []
333+
try:
334+
stdout, _ = self._process.stop()
335+
except ExecutableProcessError:
336+
pass
337+
338+
while self._process.is_running():
339+
time.sleep(0.1)
340+
341+
if self._process.returncode() > 0:
342+
# stderr is redirected to stdout
343+
# Since stdout could be filled with normal output, print only last 1 line#
344+
err = stdout[-1] if stdout else ""
345+
logging.getLogger().error(
346+
f"{self._binary} runtime error: %s, error: %s",
347+
self._process.returncode(),
348+
err,
349+
)
350+
351+
self._process = None
352+
self.host_statistics.stop()
353+
self._after_stop()
354+
355+
def cleanup(self):
356+
"""
357+
Clean any artifacts created by the connector or the active probe itself.
358+
"""
359+
Tool(f"rm -rf {self._local_workdir}").run()
360+
self._rsync.wipe_data_directory()
361+
Tool(
362+
f"rmdir {self._rsync.get_data_directory()}",
363+
executor=self._executor,
364+
sudo=self._sudo,
365+
)
366+
self.host_statistics.cleanup()
367+
368+
def download_logs(self, directory: str):
369+
"""
370+
Download logs to the given directory.
371+
372+
Args:
373+
directory (str): Path to a local directory where logs should be stored.
374+
"""
375+
try:
376+
shutil.move(self._log_file, directory)
377+
shutil.move(self._config_file, directory)
378+
self.host_statistics.get_csv(directory)
379+
except PermissionError as err:
380+
logging.getLogger().warning("Cannot download ipfixprobe log, %s", err)
381+
382+
def get_timeouts(self) -> tuple[int, int]:
383+
"""
384+
Get active and inactive timeouts of the probe (in seconds).
385+
386+
Returns:
387+
tuple: active_timeout, inactive_timeout
388+
"""
389+
return self._timeouts
390+
391+
def set_prefilter(self, ip_ranges: list[str]) -> None:
392+
"""
393+
Set probe input filter. Probe will drop all traffic except specified IP ranges.
394+
"""
395+
raise NotImplementedError

0 commit comments

Comments
 (0)