From 02e8483c87c43d16ad89c0a9f19664f2e94d9940 Mon Sep 17 00:00:00 2001 From: Adam Nagy Date: Sun, 1 Mar 2026 23:16:03 +0100 Subject: [PATCH] feat: add per-process network port discovery with listening ports column --- src/MainWindow.vala | 2 +- src/Managers/NetworkConnections.vala | 225 ++++++++++++++++ src/Managers/Process.vala | 77 ++++++ src/Managers/ProcessManager.vala | 3 + src/Managers/ProcessStructs.vala | 11 + src/Models/TreeViewModel.vala | 6 +- .../ProcessInfoView/ProcessInfoIOStats.vala | 51 +++- .../ProcessTreeView/CPUProcessTreeView.vala | 23 ++ src/Views/ProcessView/ProcessView.vala | 5 +- src/meson.build | 1 + tests/meson.build | 6 +- tests/runner.vala | 1 + tests/test_network_connections.vala | 248 ++++++++++++++++++ 13 files changed, 653 insertions(+), 6 deletions(-) create mode 100644 src/Managers/NetworkConnections.vala create mode 100644 tests/test_network_connections.vala diff --git a/src/MainWindow.vala b/src/MainWindow.vala index f87d7cf52..afd9afeb5 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -47,7 +47,7 @@ public class Monitor.MainWindow : Gtk.ApplicationWindow { preferences_button.add_css_class (Granite.STYLE_CLASS_LARGE_ICONS); var search_entry = new Gtk.SearchEntry () { - placeholder_text = _("Search process name or PID"), + placeholder_text = _("Search process name, PID, or port"), valign = CENTER }; search_entry.set_key_capture_widget (this); diff --git a/src/Managers/NetworkConnections.vala b/src/Managers/NetworkConnections.vala new file mode 100644 index 000000000..f22cb64ec --- /dev/null +++ b/src/Managers/NetworkConnections.vala @@ -0,0 +1,225 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +public class Monitor.NetworkConnections { + + public static Gee.HashMap build_inode_port_map () { + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + parse_proc_net_file ("/proc/net/tcp", "tcp", true, false, map); + parse_proc_net_file ("/proc/net/tcp6", "tcp6", true, true, map); + parse_proc_net_file ("/proc/net/udp", "udp", false, false, map); + parse_proc_net_file ("/proc/net/udp6", "udp6", false, true, map); + return map; + } + + // Public for unit testing + public static void parse_proc_net_file (string path, string protocol, bool is_tcp, bool is_ipv6, Gee.HashMap map) { + var file = File.new_for_path (path); + if (!file.query_exists ()) { + return; + } + + try { + var stream = file.read (); + var dis = new DataInputStream (stream); + string? line; + + line = dis.read_line (); + + while ((line = dis.read_line ()) != null) { + line = line.strip (); + if (line.length == 0) { + continue; + } + + var parts = line.split_set (" \t"); + var fields = new Gee.ArrayList (); + foreach (var part in parts) { + if (part.length > 0) { + fields.add (part); + } + } + + if (fields.size < 10) { + continue; + } + + var local_address = fields.get (1); + var rem_address = fields.get (2); + var state = fields.get (3); + var inode_str = fields.get (9); + + if (is_tcp && state != "0A") { + continue; + } + + if (!is_tcp) { + if (is_ipv6) { + if (rem_address != "00000000000000000000000000000000:0000") { + continue; + } + } else { + if (rem_address != "00000000:0000") { + continue; + } + } + } + + var addr_parts = local_address.split (":"); + if (addr_parts.length < 2) { + continue; + } + + var addr_hex = addr_parts[0]; + var port_hex = addr_parts[1]; + uint16 port = parse_hex_port (port_hex); + + string addr; + if (is_ipv6) { + addr = format_ipv6_address (addr_hex); + } else { + addr = format_ipv4_address (addr_hex); + } + + uint64 inode = uint64.parse (inode_str); + if (inode == 0) { + continue; + } + + map.set (inode, ListeningPort () { + protocol = protocol, + port = port, + local_address = addr + }); + } + + } catch (Error e) { + warning ("Error reading %s: %s", path, e.message); + } + } + + public static string format_ipv4_address (string hex) { + if (hex.length < 8) { + return "?.?.?.?"; + } + + var b0 = (uint8) ulong.parse (hex.substring (0, 2), 16); + var b1 = (uint8) ulong.parse (hex.substring (2, 2), 16); + var b2 = (uint8) ulong.parse (hex.substring (4, 2), 16); + var b3 = (uint8) ulong.parse (hex.substring (6, 2), 16); + return "%u.%u.%u.%u".printf (b3, b2, b1, b0); + } + + // NOTE: /proc/net stores IPv6 addresses as 4 x 32-bit words in host byte order. + // This implementation assumes little-endian (x86/x64/ARM64), which covers all + // architectures supported by elementary OS. + public static string format_ipv6_address (string hex) { + if (hex.length < 32) { + return hex; + } + + var result = new StringBuilder (); + for (int i = 0; i < 4; i++) { + var group = hex.substring (i * 8, 8); + var b0 = (uint8) ulong.parse (group.substring (0, 2), 16); + var b1 = (uint8) ulong.parse (group.substring (2, 2), 16); + var b2 = (uint8) ulong.parse (group.substring (4, 2), 16); + var b3 = (uint8) ulong.parse (group.substring (6, 2), 16); + if (i > 0) { + result.append (":"); + } + result.append ("%02X%02X".printf (b3, b2)); + result.append (":"); + result.append ("%02X%02X".printf (b1, b0)); + } + return result.str; + } + + /** Parses a hex port string from /proc/net. Input is assumed to be valid kernel-generated hex. */ + public static uint16 parse_hex_port (string hex) { + return (uint16) ulong.parse (hex, 16); + } + + public static string simplify_address (string addr) { + // IPv4: keep as-is + if (!addr.contains (":") || addr.length < 16) { + return addr; + } + + // IPv6: compress to standard short form (RFC 5952) + var groups = addr.split (":"); + if (groups.length != 8) { + return addr; + } + + // Convert each group to minimal hex (strip leading zeros) + var trimmed = new string[8]; + for (int i = 0; i < 8; i++) { + if (groups[i].length == 0) { + return addr; + } + + // Validate hex before parsing + bool valid_hex = true; + for (int c = 0; c < groups[i].length; c++) { + unichar ch = groups[i].get_char (groups[i].index_of_nth_char (c)); + if (!ch.isxdigit ()) { + valid_hex = false; + break; + } + } + if (!valid_hex) { + return addr; + } + + uint64 val = ulong.parse (groups[i], 16); + trimmed[i] = "%x".printf ((uint) val); + } + + // Find longest run of consecutive "0" groups + int best_start = -1; + int best_len = 0; + int cur_start = -1; + int cur_len = 0; + for (int i = 0; i < 8; i++) { + if (trimmed[i] == "0") { + if (cur_start == -1) { + cur_start = i; + cur_len = 1; + } else { + cur_len++; + } + if (cur_len > best_len) { + best_start = cur_start; + best_len = cur_len; + } + } else { + cur_start = -1; + cur_len = 0; + } + } + + // Build compressed string + if (best_len >= 2) { + var sb = new StringBuilder (); + for (int i = 0; i < best_start; i++) { + if (i > 0) sb.append (":"); + sb.append (trimmed[i]); + } + sb.append ("::"); + for (int i = best_start + best_len; i < 8; i++) { + if (i > best_start + best_len) sb.append (":"); + sb.append (trimmed[i]); + } + return sb.str; + } + + // No compressible run, just join trimmed groups + return string.joinv (":", trimmed); + } +} diff --git a/src/Managers/Process.vala b/src/Managers/Process.vala index db527f839..2d01e0630 100644 --- a/src/Managers/Process.vala +++ b/src/Managers/Process.vala @@ -50,6 +50,11 @@ public class Monitor.Process : GLib.Object { public Gee.HashSet open_files_paths; public Gee.HashSet children = new Gee.HashSet (); + public Gee.ArrayList listening_ports = new Gee.ArrayList (); + public Gee.HashSet socket_inodes = new Gee.HashSet ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); /** * CPU usage of this process from the last time that it was updated, measured in percent @@ -58,6 +63,29 @@ public class Monitor.Process : GLib.Object { */ public double cpu_percentage { get; private set; } + public string ports_display_string { + owned get { + if (listening_ports.size == 0) { + return Utils.NO_DATA; + } + + var sb = new StringBuilder (); + int show_count = int.min (3, listening_ports.size); + for (int i = 0; i < show_count; i++) { + if (i > 0) { + sb.append (", "); + } + sb.append ("%s:%u".printf (listening_ports[i].protocol, listening_ports[i].port)); + } + + if (listening_ports.size > 3) { + sb.append (" " + _("+ %d more").printf (listening_ports.size - 3)); + } + + return sb.str; + } + } + private uint64 cpu_last_used; // Memory usage of the process, measured in KiB. @@ -256,6 +284,8 @@ public class Monitor.Process : GLib.Object { } private bool get_open_files () { + socket_inodes.clear (); + try { string directory = "/proc/%d/fd".printf (stat.pid); Dir dir = Dir.open (directory, 0); @@ -267,6 +297,15 @@ public class Monitor.Process : GLib.Object { string real_path = FileUtils.read_link (path); // debug(content); open_files_paths.add (real_path); + + // Extract socket inode if this is a socket fd + if (real_path.has_prefix ("socket:[") && real_path.has_suffix ("]") && real_path.length > 9) { + string inode_str = real_path.substring (8, real_path.length - 9); + uint64 inode = uint64.parse (inode_str); + if (inode > 0) { + socket_inodes.add (inode); + } + } } } } catch (FileError err) { @@ -279,6 +318,44 @@ public class Monitor.Process : GLib.Object { return true; } + public void update_listening_ports (Gee.HashMap inode_port_map) { + listening_ports.clear (); + + // Build a set to deduplicate by (protocol_base, port) for column display + var seen = new Gee.HashSet (); + + foreach (var inode in socket_inodes) { + if (!inode_port_map.has_key (inode)) { + continue; + } + + var lp = inode_port_map.get (inode); + if (lp == null || lp.protocol == null) { + continue; + } + + // Deduplicate: treat tcp/tcp6 as same base, udp/udp6 as same base + var base_proto = lp.protocol.has_prefix ("tcp") ? "tcp" : "udp"; + var key = "%s:%u".printf (base_proto, lp.port); + if (!seen.contains (key)) { + seen.add (key); + // Store with base protocol for display + listening_ports.add (ListeningPort () { + protocol = base_proto, + port = lp.port, + local_address = lp.local_address + }); + } + } + + // Sort by port number ascending + listening_ports.sort ((a, b) => { + if (a.port < b.port) return -1; + if (a.port > b.port) return 1; + return 0; + }); + } + /** * Reads the /proc/%pid%/cmdline file and updates from the information contained therein. */ diff --git a/src/Managers/ProcessManager.vala b/src/Managers/ProcessManager.vala index 69033027c..844ab8eb1 100644 --- a/src/Managers/ProcessManager.vala +++ b/src/Managers/ProcessManager.vala @@ -121,12 +121,15 @@ namespace Monitor { } var remove_me = new Gee.HashSet (); + var inode_port_map = NetworkConnections.build_inode_port_map (); /* go through each process and update it, removing the old ones */ foreach (var process in process_list.values) { if (!process.update (cpu_data.total, cpu_last_total)) { /* process doesn't exist any more, flag it for removal! */ remove_me.add (process.stat.pid); + } else { + process.update_listening_ports (inode_port_map); } } diff --git a/src/Managers/ProcessStructs.vala b/src/Managers/ProcessStructs.vala index 6b210b902..bd09d4ab0 100644 --- a/src/Managers/ProcessStructs.vala +++ b/src/Managers/ProcessStructs.vala @@ -107,3 +107,14 @@ public struct Monitor.ProcessStatus { // The time the process started after system boot. public uint64 starttime; } + +public struct Monitor.ListeningPort { + // Network protocol (tcp or udp) + public string protocol; + + // Listening port number + public uint16 port; + + // Local bind address + public string local_address; +} diff --git a/src/Models/TreeViewModel.vala b/src/Models/TreeViewModel.vala index aecb1c760..f7300be01 100644 --- a/src/Models/TreeViewModel.vala +++ b/src/Models/TreeViewModel.vala @@ -9,7 +9,8 @@ public enum Monitor.Column { CPU, MEMORY, PID, - CMD + CMD, + PORTS } public class Monitor.TreeViewModel : Gtk.TreeStore { @@ -27,6 +28,7 @@ public class Monitor.TreeViewModel : Gtk.TreeStore { typeof (int64), typeof (int), typeof (string), + typeof (string), }); process_manager = ProcessManager.get_default (); @@ -61,6 +63,7 @@ public class Monitor.TreeViewModel : Gtk.TreeStore { Column.ICON, process.icon.to_string (), Column.PID, process.stat.pid, Column.CMD, process.command, + Column.PORTS, process.ports_display_string, -1); if (process_rows.size < 1) { added_first_row (); @@ -79,6 +82,7 @@ public class Monitor.TreeViewModel : Gtk.TreeStore { set (iter, Column.CPU, process.cpu_percentage, Column.MEMORY, process.mem_usage, + Column.PORTS, process.ports_display_string, -1); } } diff --git a/src/Views/ProcessView/ProcessInfoView/ProcessInfoIOStats.vala b/src/Views/ProcessView/ProcessInfoView/ProcessInfoIOStats.vala index ef592ba5f..f5c4fe39b 100644 --- a/src/Views/ProcessView/ProcessInfoView/ProcessInfoIOStats.vala +++ b/src/Views/ProcessView/ProcessInfoView/ProcessInfoIOStats.vala @@ -10,6 +10,9 @@ public class Monitor.ProcessInfoIOStats : Gtk.Grid { private Gtk.Label read_bytes_label; private Gtk.Label cancelled_write_bytes_label; + private Gtk.Box ports_list_box; + private string last_ports_key = ""; + construct { var io_label = new Granite.HeaderLabel (_("Read/Written")); @@ -26,6 +29,10 @@ public class Monitor.ProcessInfoIOStats : Gtk.Grid { child = open_files_tree_view }; + var ports_header_label = new Granite.HeaderLabel (_("Listening Ports")); + + ports_list_box = new Gtk.Box (VERTICAL, 3); + column_spacing = 6; row_spacing = 6; column_homogeneous = true; @@ -35,13 +42,55 @@ public class Monitor.ProcessInfoIOStats : Gtk.Grid { attach (create_label_with_icon (write_bytes_label, "go-down-symbolic"), 0, 3); attach (cancelled_write_label, 1, 1); attach (cancelled_write_bytes_label, 1, 2); - attach (open_files_tree_view_scrolled, 0, 4, 2); + attach (ports_header_label, 0, 4, 2); + attach (ports_list_box, 0, 5, 2); + attach (open_files_tree_view_scrolled, 0, 6, 2); } public void update (Process process) { write_bytes_label.label = format_size ((uint64) process.io.write_bytes, IEC_UNITS); read_bytes_label.label = format_size ((uint64) process.io.read_bytes, IEC_UNITS); cancelled_write_bytes_label.label = format_size ((uint64) process.io.cancelled_write_bytes, IEC_UNITS); + update_ports (process); + } + + public void update_ports (Process process) { + // Build a full key from all port data to avoid stale display + var key_builder = new StringBuilder (); + foreach (var lp in process.listening_ports) { + key_builder.append ("%s:%u:%s,".printf (lp.protocol, lp.port, lp.local_address)); + } + var ports_key = key_builder.str; + + if (ports_key == last_ports_key) { + return; + } + last_ports_key = ports_key; + + // Remove old dynamic children from ports_list_box + var child = ports_list_box.get_first_child (); + while (child != null) { + var next = child.get_next_sibling (); + ports_list_box.remove (child); + child = next; + } + + if (process.listening_ports.size > 0) { + // Show each port as a label + foreach (var lp in process.listening_ports) { + var addr_display = NetworkConnections.simplify_address (lp.local_address); + var port_label = new Gtk.Label ("%s:%u (%s)".printf (lp.protocol, lp.port, addr_display)) { + halign = START + }; + ports_list_box.append (port_label); + } + } else { + // Own process with no ports + var no_data_label = new Gtk.Label (Utils.NO_DATA) { + halign = START + }; + ports_list_box.append (no_data_label); + } } private Gtk.Label create_label (string text) { diff --git a/src/Views/ProcessView/ProcessTreeView/CPUProcessTreeView.vala b/src/Views/ProcessView/ProcessTreeView/CPUProcessTreeView.vala index 51d3c2ef2..05a0cfdde 100644 --- a/src/Views/ProcessView/ProcessTreeView/CPUProcessTreeView.vala +++ b/src/Views/ProcessView/ProcessTreeView/CPUProcessTreeView.vala @@ -9,6 +9,7 @@ public class Monitor.CPUProcessTreeView : Gtk.TreeView { private Gtk.TreeViewColumn pid_column; private Gtk.TreeViewColumn cpu_column; private Gtk.TreeViewColumn memory_column; + private Gtk.TreeViewColumn ports_column; public signal void process_selected (Process process); @@ -58,6 +59,17 @@ public class Monitor.CPUProcessTreeView : Gtk.TreeView { memory_column.set_sort_column_id (Column.MEMORY); insert_column (memory_column, -1); + // setup ports column + var ports_cell = new Gtk.CellRendererText (); + ports_cell.ellipsize = Pango.EllipsizeMode.END; + ports_column = new Gtk.TreeViewColumn.with_attributes (_("Ports"), ports_cell); + ports_column.expand = false; + ports_column.min_width = 120; + ports_column.set_cell_data_func (ports_cell, ports_cell_layout); + ports_column.alignment = 0.0f; + ports_column.set_sort_column_id (Column.PORTS); + insert_column (ports_column, -1); + // setup PID column var pid_cell = new Gtk.CellRendererText (); pid_cell.xalign = 0.5f; @@ -152,6 +164,17 @@ public class Monitor.CPUProcessTreeView : Gtk.TreeView { } } + private void ports_cell_layout (Gtk.CellLayout cell_layout, Gtk.CellRenderer cell, Gtk.TreeModel model, Gtk.TreeIter iter) { + Value ports_value; + model.get_value (iter, Column.PORTS, out ports_value); + string ports = (string) ports_value; + if (ports == null || ports == Utils.NO_DATA) { + ((Gtk.CellRendererText)cell).text = Utils.NO_DATA; + } else { + ((Gtk.CellRendererText)cell).text = ports; + } + } + public void focus_on_first_row () { Gtk.TreePath tree_path = new Gtk.TreePath.from_indices (0); this.set_cursor (tree_path, null, false); diff --git a/src/Views/ProcessView/ProcessView.vala b/src/Views/ProcessView/ProcessView.vala index 0b7abda9d..501467125 100644 --- a/src/Views/ProcessView/ProcessView.vala +++ b/src/Views/ProcessView/ProcessView.vala @@ -161,6 +161,7 @@ public class Monitor.ProcessView : Granite.Bin { string name_haystack; int pid_haystack; string cmd_haystack; + string ports_haystack; bool found = false; if (needle.length == 0) { @@ -170,13 +171,15 @@ public class Monitor.ProcessView : Granite.Bin { model.get (iter, Column.NAME, out name_haystack, -1); model.get (iter, Column.PID, out pid_haystack, -1); model.get (iter, Column.CMD, out cmd_haystack, -1); + model.get (iter, Column.PORTS, out ports_haystack, -1); // sometimes name_haystack is null if (name_haystack != null) { bool name_found = name_haystack.casefold ().contains (needle.casefold ()) || false; bool pid_found = pid_haystack.to_string ().casefold ().contains (needle.casefold ()) || false; bool cmd_found = cmd_haystack.casefold ().contains (needle.casefold ()) || false; - found = name_found || pid_found || cmd_found; + bool ports_found = ports_haystack != null && ports_haystack.casefold ().contains (needle.casefold ()) || false; + found = name_found || pid_found || cmd_found || ports_found; } Gtk.TreeIter child_iter; diff --git a/src/meson.build b/src/meson.build index b6464f8d7..d283d00c3 100644 --- a/src/meson.build +++ b/src/meson.build @@ -41,6 +41,7 @@ source_app_files = [ 'Managers/Process.vala', 'Managers/ProcessStructs.vala', 'Managers/ProcessUtils.vala', + 'Managers/NetworkConnections.vala', # Services 'Services/DBusServer.vala', diff --git a/tests/meson.build b/tests/meson.build index 0005473c8..1f82114f1 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -6,9 +6,11 @@ test( executable('monitor-statusbar-test', test_sources, [ meson.project_source_root() / 'src/Widgets/Statusbar/Statusbar.vala', meson.project_source_root() / 'src/Resources/ResourcesSerialized.vala', - meson.project_source_root() / 'src/Utils.vala' + meson.project_source_root() / 'src/Utils.vala', + meson.project_source_root() / 'src/Managers/NetworkConnections.vala', + meson.project_source_root() / 'src/Managers/ProcessStructs.vala' ], project_config, dependencies: app_dependencies), suite: 'monitor-gui-headless' -) \ No newline at end of file +) diff --git a/tests/runner.vala b/tests/runner.vala index 10b6c528f..e819f3624 100644 --- a/tests/runner.vala +++ b/tests/runner.vala @@ -4,6 +4,7 @@ void main (string[] args) { Gtk.init (); test_statusbar (); + test_network_connections (); Test.run (); } diff --git a/tests/test_network_connections.vala b/tests/test_network_connections.vala new file mode 100644 index 000000000..229a3eebc --- /dev/null +++ b/tests/test_network_connections.vala @@ -0,0 +1,248 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. (https://elementary.io) + */ + +using Monitor; + +private string write_tmp_proc_file (string contents, out string tmp_dir) { + tmp_dir = DirUtils.make_tmp ("monitor-proc-XXXXXX"); + string tmp_path = Path.build_filename (tmp_dir, "proc_net"); + FileUtils.set_contents (tmp_path, contents); + return tmp_path; +} + +private void test_network_connections () { + Test.add_func ("/Monitor/NetworkConnections#format_ipv4_loopback", () => { + assert (NetworkConnections.format_ipv4_address ("0100007F") == "127.0.0.1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#format_ipv4_zeros", () => { + assert (NetworkConnections.format_ipv4_address ("00000000") == "0.0.0.0"); + }); + + Test.add_func ("/Monitor/NetworkConnections#format_ipv4_private", () => { + assert (NetworkConnections.format_ipv4_address ("0101A8C0") == "192.168.1.1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#parse_port_80", () => { + assert (NetworkConnections.parse_hex_port ("0050") == 80); + }); + + Test.add_func ("/Monitor/NetworkConnections#parse_port_443", () => { + assert (NetworkConnections.parse_hex_port ("01BB") == 443); + }); + + Test.add_func ("/Monitor/NetworkConnections#parse_port_8080", () => { + assert (NetworkConnections.parse_hex_port ("1F90") == 8080); + }); + + Test.add_func ("/Monitor/NetworkConnections#format_ipv6_loopback", () => { + string result = NetworkConnections.format_ipv6_address ("00000000000000000000000001000000"); + assert (result == "0000:0000:0000:0000:0000:0000:0000:0001"); + }); + + Test.add_func ("/Monitor/NetworkConnections#build_map_no_crash", () => { + var map = NetworkConnections.build_inode_port_map (); + assert (map != null); + }); + + Test.add_func ("/Monitor/NetworkConnections#tcp_listen_state_filter", () => { + string header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + string contents = header + + " 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0\n" + + " 1: 0100007F:01BB 0101A8C0:9C40 01 00000000:00000000 00:00000000 00000000 1000 0 67890 1 0000000000000000 100 0 0 10 0\n" + + " 2: 0100007F:1770 00000000:0000 06 00000000:00000000 00:00000000 00000000 1000 0 24680 1 0000000000000000 100 0 0 10 0\n"; + string tmp_dir; + string tmp_path = write_tmp_proc_file (contents, out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "tcp", true, false, map); + + assert (map.size == 1); + assert (map.has_key ((uint64) 12345)); + assert (!map.has_key ((uint64) 67890)); + assert (!map.has_key ((uint64) 24680)); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + Test.add_func ("/Monitor/NetworkConnections#udp_remote_filter", () => { + string header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + string contents = header + + " 0: 00000000:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 11111 1 0000000000000000 100 0 0 10 0\n" + + " 1: 00000000:0035 0101A8C0:1234 07 00000000:00000000 00:00000000 00000000 0 0 22222 1 0000000000000000 100 0 0 10 0\n"; + string tmp_dir; + string tmp_path = write_tmp_proc_file (contents, out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "udp", false, false, map); + + assert (map.size == 1); + assert (map.has_key ((uint64) 11111)); + assert (!map.has_key ((uint64) 22222)); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + Test.add_func ("/Monitor/NetworkConnections#header_skipped", () => { + string header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + string contents = header + + " 0: 0100007F:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33333 1 0000000000000000 100 0 0 10 0\n"; + string tmp_dir; + string tmp_path = write_tmp_proc_file (contents, out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "tcp", true, false, map); + + assert (map.size == 1); + assert (map.has_key ((uint64) 33333)); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + Test.add_func ("/Monitor/NetworkConnections#empty_file", () => { + string tmp_dir; + string tmp_path = write_tmp_proc_file ("", out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "tcp", true, false, map); + + assert (map.size == 0); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + Test.add_func ("/Monitor/NetworkConnections#format_ipv6_known_tcp6", () => { + // Real tcp6 entry for ::ffff:127.0.0.1 (IPv4-mapped IPv6) + // The kernel stores IPv6 as 4 x 32-bit words in host byte order (little-endian on x86/ARM64). + // Network-order value 0x0000FFFF becomes LE bytes FF FF 00 00, printed as FFFF0000. + // So ::ffff:127.0.0.1 in /proc/net/tcp6 on LE is: 0000000000000000FFFF00000100007F + string result = NetworkConnections.format_ipv6_address ("0000000000000000FFFF00000100007F"); + assert (result == "0000:0000:0000:0000:0000:FFFF:7F00:0001"); + }); + + Test.add_func ("/Monitor/NetworkConnections#full_tcp_line_parse", () => { + // Full realistic /proc/net/tcp line: nginx listening on 0.0.0.0:80 + string header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + string contents = header + + " 0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 99999 1 0000000000000000 100 0 0 10 0\n"; + string tmp_dir; + string tmp_path = write_tmp_proc_file (contents, out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "tcp", true, false, map); + + assert (map.size == 1); + assert (map.has_key ((uint64) 99999)); + + var lp = map.get ((uint64) 99999); + assert (lp.protocol == "tcp"); + assert (lp.port == 80); + assert (lp.local_address == "0.0.0.0"); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + // simplify_address tests + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv4_passthrough", () => { + assert (NetworkConnections.simplify_address ("192.168.1.1") == "192.168.1.1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv4_loopback", () => { + assert (NetworkConnections.simplify_address ("127.0.0.1") == "127.0.0.1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_loopback", () => { + // ::1 + assert (NetworkConnections.simplify_address ("0000:0000:0000:0000:0000:0000:0000:0001") == "::1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_all_zeros", () => { + // :: + assert (NetworkConnections.simplify_address ("0000:0000:0000:0000:0000:0000:0000:0000") == "::"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_no_compression", () => { + // No consecutive zero groups to compress + assert (NetworkConnections.simplify_address ("2001:0db8:0001:0002:0003:0004:0005:0006") == "2001:db8:1:2:3:4:5:6"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_single_zero_no_compress", () => { + // Single zero group should NOT compress (RFC 5952 requires run >= 2) + assert (NetworkConnections.simplify_address ("2001:0db8:0000:0001:0002:0003:0004:0005") == "2001:db8:0:1:2:3:4:5"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_mapped_v4", () => { + // IPv4-mapped IPv6: ::ffff:0:7f00:1 + assert (NetworkConnections.simplify_address ("0000:0000:0000:0000:FFFF:0000:7F00:0001") == "::ffff:0:7f00:1"); + }); + + Test.add_func ("/Monitor/NetworkConnections#simplify_ipv6_longest_run", () => { + // Should compress the longest run of zeros + assert (NetworkConnections.simplify_address ("2001:0000:0000:0000:0000:0db8:0000:0001") == "2001::db8:0:1"); + }); + + // Edge case tests: invalid/short input + + Test.add_func ("/Monitor/NetworkConnections#format_ipv4_short_input", () => { + assert (NetworkConnections.format_ipv4_address ("0100") == "?.?.?.?"); + }); + + Test.add_func ("/Monitor/NetworkConnections#format_ipv6_short_input", () => { + // Short hex should be returned as-is + assert (NetworkConnections.format_ipv6_address ("0000FFFF") == "0000FFFF"); + }); + + // IPv6 UDP6 remote address filter + + Test.add_func ("/Monitor/NetworkConnections#udp6_remote_filter", () => { + string header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + string contents = header + + " 0: 00000000000000000000000000000000:0035 00000000000000000000000000000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 44444 1 0000000000000000 100 0 0 10 0\n" + + " 1: 00000000000000000000000000000000:0035 00000000000000000000000001000000:1234 07 00000000:00000000 00:00000000 00000000 0 0 55555 1 0000000000000000 100 0 0 10 0\n"; + string tmp_dir; + string tmp_path = write_tmp_proc_file (contents, out tmp_dir); + + var map = new Gee.HashMap ( + (a) => { return (uint) (a ^ (a >> 32)); }, + (a, b) => { return a == b; } + ); + NetworkConnections.parse_proc_net_file (tmp_path, "udp6", false, true, map); + + assert (map.size == 1); + assert (map.has_key ((uint64) 44444)); + assert (!map.has_key ((uint64) 55555)); + + FileUtils.remove (tmp_path); + FileUtils.remove (tmp_dir); + }); + + // Full end-to-end with corrected IPv4-mapped IPv6 + + Test.add_func ("/Monitor/NetworkConnections#simplify_corrected_mapped_v4", () => { + // The real kernel output for ::ffff:127.0.0.1 on LE produces 0000:0000:0000:0000:0000:FFFF:7F00:0001 + assert (NetworkConnections.simplify_address ("0000:0000:0000:0000:0000:FFFF:7F00:0001") == "::ffff:7f00:1"); + }); +}