diff --git a/.gitignore b/.gitignore index 66fc7dc..b97cf5d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,10 +27,6 @@ share/python-wheels/ *.egg MANIFEST -# PyInstaller -# Usually these files are written by a CI server in a temp folder. -# Then everything is copied to shipping folder during release. -*.spec # Installer logs pip-log.txt @@ -148,3 +144,9 @@ dmypy.json # Temporary files *.swp *~ + +_dist/* +_build/* +*.pcapng + + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0a00ef0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "name": "Python Debugger: Module", + "type": "debugpy", + "request": "launch", + "module": "netanalyzer" + } + ] +} \ No newline at end of file diff --git a/SXXXXXXX_NetAnalyzer.ico b/NetAnalyzer.ico similarity index 100% rename from SXXXXXXX_NetAnalyzer.ico rename to NetAnalyzer.ico diff --git a/netanalyzer.spec b/netanalyzer.spec new file mode 100644 index 0000000..ebcaa9a --- /dev/null +++ b/netanalyzer.spec @@ -0,0 +1,39 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +a = Analysis( + ['netanalyzer/__main__.py'], + pathex=['.'], + binaries=[], + # Usa project_icon_filename nella sezione datas + datas=[('NetAnalyzer.ico', '.')], + hiddenimports=[], + hookspath=[], + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='NetAnalyzer', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + # Usa project_icon_filename per l'opzione icon + icon='NetAnalyzer.ico' +) diff --git a/sxxxxxxx_netanalyzer/__init__.py b/netanalyzer/__init__.py similarity index 100% rename from sxxxxxxx_netanalyzer/__init__.py rename to netanalyzer/__init__.py diff --git a/netanalyzer/__main__.py b/netanalyzer/__main__.py new file mode 100644 index 0000000..c807d3f --- /dev/null +++ b/netanalyzer/__main__.py @@ -0,0 +1,11 @@ +from .gui.main_view import MainView + +def main(): + """ + Punto di ingresso principale per l'applicazione NetPulseAnalyzer. + """ + app = MainView() + app.mainloop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/sxxxxxxx_netanalyzer/core/__init__.py b/netanalyzer/core/__init__.py similarity index 100% rename from sxxxxxxx_netanalyzer/core/__init__.py rename to netanalyzer/core/__init__.py diff --git a/netanalyzer/core/analyzer.py b/netanalyzer/core/analyzer.py new file mode 100644 index 0000000..fef8ba0 --- /dev/null +++ b/netanalyzer/core/analyzer.py @@ -0,0 +1,80 @@ +import scapy.all as scapy +import pandas as pd +from typing import List, Dict, Any, Optional + +class PacketAnalyzer: + """ + Main class for analyzing network packets from capture files. + Uses Scapy for reading and Pandas for statistical processing. + """ + def __init__(self): + self.packets: Optional[scapy.PacketList] = None + self.filepath: Optional[str] = None + + def load_pcap(self, filepath: str) -> int: + """ + Loads packets from a .pcapng or .pcap file. + WARNING: This can be slow for very large files as it loads the entire file into memory. + + Returns the total number of packets loaded. + """ + self.filepath = filepath + print(f"Loading packets from {filepath}...") + self.packets = scapy.rdpcap(filepath) + print(f"Loaded {len(self.packets)} packets.") + return len(self.packets) + + def calculate_ab_processing_stats(self, port_a: int, port_b: int) -> Dict[str, Any]: + """ + Calculates the processing time between packets on port A and port B. + """ + if not self.packets: + raise ValueError("No packets loaded. Call load_pcap() first.") + + # Extract timestamps for packets on port A and port B (destination ports) + times_a = [p.time for p in self.packets if p.haslayer(scapy.UDP) and p[scapy.UDP].dport == port_a] + times_b = [p.time for p in self.packets if p.haslayer(scapy.UDP) and p[scapy.UDP].dport == port_b] + + if not times_a or not times_b: + return {"error": "Packets for one or both ports (A/B) were not found."} + + min_len = min(len(times_a), len(times_b)) + if min_len == 0: + return {"error": "No matching A/B packet pairs found."} + + # Calculate the time deltas in milliseconds + processing_times_ms = (pd.Series(times_b[:min_len]) - pd.Series(times_a[:min_len])) * 1000 + + # Use pandas to get a comprehensive set of statistics + stats = processing_times_ms.describe(percentiles=[.25, .5, .75, .95, .99]).to_dict() + + # Add raw data points for plotting + stats['data_points'] = processing_times_ms.tolist() + + return stats + + def calculate_inter_packet_gap_stats(self, port: int) -> Dict[str, Any]: + """ + Calculates the interval (and jitter) between consecutive packets on a single port. + """ + if not self.packets: + raise ValueError("No packets loaded.") + + # Filter packets for the specified port (either source or destination) + times = [p.time for p in self.packets if p.haslayer(scapy.UDP) and (p[scapy.UDP].sport == port or p[scapy.UDP].dport == port)] + + if len(times) < 2: + return {"error": f"Fewer than 2 packets found on port {port}. Cannot calculate interval."} + + # Calculate the difference between each timestamp and the previous one + gaps_ms = pd.Series(times).diff().dropna() * 1000 + + # Calculate statistics + stats = gaps_ms.describe(percentiles=[.25, .5, .75, .95, .99]).to_dict() + + # Jitter is often defined as the standard deviation of the packet delay variation + stats['jitter'] = stats.get('std', 0.0) + + stats['data_points'] = gaps_ms.tolist() + + return stats \ No newline at end of file diff --git a/sxxxxxxx_netanalyzer/gui/__init__.py b/netanalyzer/gui/__init__.py similarity index 100% rename from sxxxxxxx_netanalyzer/gui/__init__.py rename to netanalyzer/gui/__init__.py diff --git a/netanalyzer/gui/main_view.py b/netanalyzer/gui/main_view.py new file mode 100644 index 0000000..5d7110e --- /dev/null +++ b/netanalyzer/gui/main_view.py @@ -0,0 +1,262 @@ +# NetPulseAnalyzer/gui/main_view.py + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import threading +import os +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import pandas as pd +from typing import Dict, Any + + +from ..core.analyzer import PacketAnalyzer + +class MainView(tk.Tk): + """ + Main window for the NetPulseAnalyzer application. + """ + def __init__(self): + super().__init__() + self.title("NetPulseAnalyzer") + self.geometry("1200x800") + + self.analyzer = PacketAnalyzer() + + self._create_widgets() + + def _create_widgets(self): + main_frame = ttk.Frame(self, padding=10) + main_frame.pack(fill=tk.BOTH, expand=True) + + # --- Top Controls Section --- + controls_frame = ttk.LabelFrame(main_frame, text="File Controls", padding=10) + controls_frame.pack(fill=tk.X, pady=(0, 10)) + + self.filepath_label = ttk.Label(controls_frame, text="No file loaded.") + self.filepath_label.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + self.load_button = ttk.Button(controls_frame, text="Open .pcapng File...", command=self._open_file) + self.load_button.pack(side=tk.RIGHT, padx=5) + + # --- Notebook for different analysis types --- + self.results_notebook = ttk.Notebook(main_frame) + self.results_notebook.pack(fill=tk.BOTH, expand=True) + + # Create the two tabs + self.tab_processing_time = ttk.Frame(self.results_notebook) + self.tab_jitter = ttk.Frame(self.results_notebook) + + self.results_notebook.add(self.tab_processing_time, text=" Processing Time (A -> B) ") + self.results_notebook.add(self.tab_jitter, text=" Interval / Jitter Analysis ") + + # Populate each tab with its specific widgets + self._create_processing_time_tab(self.tab_processing_time) + self._create_jitter_tab(self.tab_jitter) + + def _create_processing_time_tab(self, parent_tab): + controls = ttk.LabelFrame(parent_tab, text="Analysis Parameters", padding=10) + controls.pack(fill=tk.X, padx=10, pady=10) + + ttk.Label(controls, text="Port A (Start):").grid(row=0, column=0, padx=5, sticky='w') + self.proc_port_a_var = tk.StringVar(value="55001") + ttk.Entry(controls, textvariable=self.proc_port_a_var, width=10).grid(row=0, column=1) + + ttk.Label(controls, text="Port B (End):").grid(row=0, column=2, padx=(20, 5), sticky='w') + self.proc_port_b_var = tk.StringVar(value="55002") + ttk.Entry(controls, textvariable=self.proc_port_b_var, width=10).grid(row=0, column=3) + + self.proc_analyze_button = ttk.Button(controls, text="Analyze", command=self._run_processing_time_analysis, state=tk.DISABLED) + self.proc_analyze_button.grid(row=0, column=4, padx=20) + + pane = ttk.PanedWindow(parent_tab, orient=tk.HORIZONTAL) + pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) + + stats_frame = ttk.LabelFrame(pane, text="Statistics (ms)", padding=10) + pane.add(stats_frame, weight=1) + + self.proc_stats_tree = ttk.Treeview(stats_frame, columns=("Metric", "Value"), show="headings") + self.proc_stats_tree.heading("Metric", text="Metric") + self.proc_stats_tree.heading("Value", text="Value (ms)") + self.proc_stats_tree.column("Metric", width=120) + self.proc_stats_tree.column("Value", width=100, anchor='e') + self.proc_stats_tree.pack(fill=tk.BOTH, expand=True) + + plot_frame = ttk.LabelFrame(pane, text="Plot", padding=10) + pane.add(plot_frame, weight=3) + + self.proc_fig = plt.Figure(figsize=(5, 4), dpi=100) + self.proc_ax = self.proc_fig.add_subplot(111) + self.proc_canvas = FigureCanvasTkAgg(self.proc_fig, master=plot_frame) + self.proc_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + + def _create_jitter_tab(self, parent_tab): + controls = ttk.LabelFrame(parent_tab, text="Analysis Parameters", padding=10) + controls.pack(fill=tk.X, padx=10, pady=10) + + ttk.Label(controls, text="Port to Analyze:").grid(row=0, column=0, padx=5, sticky='w') + self.jitter_port_var = tk.StringVar(value="60012") # Default to server command port + ttk.Entry(controls, textvariable=self.jitter_port_var, width=10).grid(row=0, column=1) + + self.jitter_analyze_button = ttk.Button(controls, text="Analyze Jitter", command=self._run_jitter_analysis, state=tk.DISABLED) + self.jitter_analyze_button.grid(row=0, column=2, padx=20) + + pane = ttk.PanedWindow(parent_tab, orient=tk.HORIZONTAL) + pane.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) + + stats_frame = ttk.LabelFrame(pane, text="Interval Statistics (ms)", padding=10) + pane.add(stats_frame, weight=1) + + self.jitter_stats_tree = ttk.Treeview(stats_frame, columns=("Metric", "Value"), show="headings") + self.jitter_stats_tree.heading("Metric", text="Metric") + self.jitter_stats_tree.heading("Value", text="Value (ms)") + self.jitter_stats_tree.column("Metric", width=120) + self.jitter_stats_tree.column("Value", width=100, anchor='e') + self.jitter_stats_tree.pack(fill=tk.BOTH, expand=True) + + plot_frame = ttk.LabelFrame(pane, text="Packet Interval (Jitter) Plot", padding=10) + pane.add(plot_frame, weight=3) + + self.jitter_fig = plt.Figure(figsize=(5, 4), dpi=100) + self.jitter_ax = self.jitter_fig.add_subplot(111) + self.jitter_canvas = FigureCanvasTkAgg(self.jitter_fig, master=plot_frame) + self.jitter_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) + + def _open_file(self): + filepath = filedialog.askopenfilename( + title="Select a capture file", + filetypes=[("Wireshark/Scapy Captures", "*.pcapng *.pcap"), ("All files", "*.*")] + ) + if not filepath: + return + + self.load_button.config(state=tk.DISABLED) + self.proc_analyze_button.config(state=tk.DISABLED) + self.jitter_analyze_button.config(state=tk.DISABLED) + + progress_dialog = self._create_progress_dialog(filepath) + + threading.Thread( + target=self._load_file_thread, + args=(filepath, progress_dialog), + daemon=True + ).start() + + def _create_progress_dialog(self, filepath: str) -> tk.Toplevel: + """Creates a modal dialog with an indeterminate progress bar.""" + dialog = tk.Toplevel(self) + dialog.title("Loading...") + dialog.geometry("400x150") + dialog.transient(self) + dialog.grab_set() + dialog.resizable(False, False) + + filename = os.path.basename(filepath) + try: + file_size_mb = os.path.getsize(filepath) / (1024 * 1024) + size_str = f"({file_size_mb:.2f} MB)" + except OSError: + size_str = "" + + ttk.Label(dialog, text="Loading capture file:", padding=10).pack() + ttk.Label(dialog, text=f"{filename} {size_str}", font=('Helvetica', 10, 'bold')).pack() + + ttk.Label(dialog, text="This may take a moment...", padding=5).pack() + + progress_bar = ttk.Progressbar(dialog, mode='indeterminate', length=350) + progress_bar.pack(pady=20) + progress_bar.start(10) + + self.update_idletasks() + x = self.winfo_x() + (self.winfo_width() // 2) - (dialog.winfo_width() // 2) + y = self.winfo_y() + (self.winfo_height() // 2) - (dialog.winfo_height() // 2) + dialog.geometry(f"+{x}+{y}") + + return dialog + + def _load_file_thread(self, filepath: str, progress_dialog: tk.Toplevel): + """ + Background thread function to load the file. Closes the dialog when done. + """ + try: + total_packets = self.analyzer.load_pcap(filepath) + self.after(0, self._on_load_complete, filepath, total_packets) + except Exception as e: + self.after(0, messagebox.showerror, "Load Error", f"Failed to read file:\n{e}") + self.after(0, self.filepath_label.config, {"text": "File load failed."}) + finally: + self.after(0, progress_dialog.destroy) + self.after(0, self.load_button.config, {"state": tk.NORMAL}) + + def _on_load_complete(self, filepath, total_packets): + """GUI thread callback for when file loading is complete.""" + self.filepath_label.config(text=f"File: {os.path.basename(filepath)} ({total_packets} packets)") + self.proc_analyze_button.config(state=tk.NORMAL) + self.jitter_analyze_button.config(state=tk.NORMAL) + + def _run_processing_time_analysis(self): + try: + port_a = int(self.proc_port_a_var.get()) + port_b = int(self.proc_port_b_var.get()) + stats = self.analyzer.calculate_ab_processing_stats(port_a, port_b) + if "error" in stats: + messagebox.showerror("Analysis Error", stats["error"]) + return + self._update_stats_table(self.proc_stats_tree, stats) + self._update_plot(self.proc_ax, self.proc_fig, self.proc_canvas, stats['data_points'], "Processing Time", "Time (ms)") + except Exception as e: + messagebox.showerror("Error", f"Analysis failed: {e}") + + def _run_jitter_analysis(self): + try: + port = int(self.jitter_port_var.get()) + stats = self.analyzer.calculate_inter_packet_gap_stats(port) + if "error" in stats: + messagebox.showerror("Analysis Error", stats["error"]) + return + + # CORREZIONE: Usiamo la chiave 'std' per 'Jitter' + stats['Jitter'] = stats.get('std', 0.0) + + self._update_stats_table(self.jitter_stats_tree, stats) + self._update_plot(self.jitter_ax, self.jitter_fig, self.jitter_canvas, stats['data_points'], f"Packet Interval (Port {port})", "Interval (ms)") + except Exception as e: + messagebox.showerror("Error", f"Analysis failed: {e}") + + def _update_stats_table(self, tree: ttk.Treeview, stats: Dict[str, Any]): + for item in tree.get_children(): + tree.delete(item) + + # CORREZIONE: Usiamo 'std' ma lo etichettiamo come Jitter per chiarezza + metric_map = { + 'count': 'Sample Count', 'mean': 'Mean', 'std': 'Std Dev (Jitter)', + 'min': 'Min', '25%': '25th Percentile', '50%': 'Median (50%)', + '75%': '75th Percentile', '95%': '95th Percentile', '99%': '99th Percentile', + 'max': 'Max' + } + + for key, name in metric_map.items(): + if key in stats: + value = stats[key] + if key == 'count': + tree.insert('', 'end', values=(name, f"{int(value)}")) + else: + tree.insert('', 'end', values=(name, f"{value:.4f}")) + + def _update_plot(self, ax, fig, canvas, data_points, title, ylabel): + ax.clear() + ax.plot(data_points, marker='.', linestyle='-', markersize=4) + + if data_points: + s = pd.Series(data_points) + mean_val = s.mean() + p99_val = s.quantile(0.99) + ax.axhline(y=mean_val, color='g', linestyle='--', label=f'Mean ({mean_val:.2f} ms)') + ax.axhline(y=p99_val, color='r', linestyle='--', label=f'99th Percentile ({p99_val:.2f} ms)') + + ax.set_title(title) + ax.set_xlabel("Packet Sequence") + ax.set_ylabel(ylabel) + ax.grid(True) + ax.legend() + fig.tight_layout() + canvas.draw() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c01e0ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sxxxxxxx-netanalyzer" +version = "0.1.0" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c312cdb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Requirements generated by DependencyAnalyzer for SXXXXXXX_NetAnalyzer +# Python Version (analysis env): 3.13.9 + +# --- Standard Library Modules Used (part of Python 3.13.9) --- +# threading (Used in: netanalyzer\gui\main_view.py) +# tkinter (Used in: netanalyzer\gui\main_view.py) +# typing (Used in: netanalyzer\core\analyzer.py, netanalyzer\gui\main_view.py) + +# --- External Dependencies (for pip install) --- +# Found in: netanalyzer\gui\main_view.py +matplotlib==3.10.7 + +# Found in: netanalyzer\core\analyzer.py, netanalyzer\gui\main_view.py +pandas==2.3.3 + +# Found in: netanalyzer\core\analyzer.py +scapy==2.6.1 + diff --git a/sxxxxxxx_netanalyzer/__main__.py b/sxxxxxxx_netanalyzer/__main__.py deleted file mode 100644 index d9c503a..0000000 --- a/sxxxxxxx_netanalyzer/__main__.py +++ /dev/null @@ -1,17 +0,0 @@ -# sxxxxxxx_netanalyzer/__main__.py - -# Example import assuming your main logic is in a 'main' function -# within a 'app' module in your 'sxxxxxxx_netanalyzer.core' package. -# from sxxxxxxx_netanalyzer.core.app import main as start_application -# -# Or, if you have a function in sxxxxxxx_netanalyzer.core.core: -# from sxxxxxxx_netanalyzer.core.core import main_function - -def main(): - print(f"Running SXXXXXXX_NetAnalyzer...") - # Placeholder: Replace with your application's entry point - # Example: start_application() - print("To customize, edit 'sxxxxxxx_netanalyzer/__main__.py' and your core modules.") - -if __name__ == "__main__": - main() diff --git a/sxxxxxxx_netanalyzer/core/core.py b/sxxxxxxx_netanalyzer/core/core.py deleted file mode 100644 index e69de29..0000000 diff --git a/sxxxxxxx_netanalyzer/gui/gui.py b/sxxxxxxx_netanalyzer/gui/gui.py deleted file mode 100644 index e69de29..0000000