whoson 0.1.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.
whoson-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: whoson
3
+ Version: 0.1.0
4
+ Summary: Lightweight subnet audit tool -- see what is alive on your network
5
+ Author-email: Dani Issac <daniissac@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/daniissac/whoson
8
+ Project-URL: Repository, https://github.com/daniissac/whoson
9
+ Project-URL: Issues, https://github.com/daniissac/whoson/issues
10
+ Keywords: network,scanner,nmap,subnet,topology
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: System :: Networking
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: python-nmap
20
+ Requires-Dist: Pillow
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest; extra == "dev"
23
+
24
+ # whoson
25
+
26
+ A lightweight subnet audit tool. Scan a network, see what's alive, and export a topology image -- all from the terminal.
27
+
28
+ [![PyPI](https://img.shields.io/pypi/v/whoson)](https://pypi.org/project/whoson/)
29
+ [![CI](https://github.com/daniissac/whoson/actions/workflows/ci.yml/badge.svg)](https://github.com/daniissac/whoson/actions/workflows/ci.yml)
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ # Prerequisites: nmap must be installed
35
+ brew install nmap # macOS
36
+ sudo apt install nmap # Debian/Ubuntu
37
+ sudo dnf install nmap # Fedora/RHEL
38
+
39
+ # Install whoson
40
+ pip install whoson
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ ```bash
46
+ # Scan a subnet and print a host table
47
+ whoson 192.168.1.0/24
48
+
49
+ # Save a topology diagram as PNG
50
+ whoson 192.168.1.0/24 -i topology.png
51
+
52
+ # Or as JPEG or SVG
53
+ whoson 192.168.1.0/24 -i topology.jpg
54
+ whoson 192.168.1.0/24 -i topology.svg
55
+
56
+ # Export as JSON or CSV
57
+ whoson 192.168.1.0/24 --json results.json
58
+ whoson 192.168.1.0/24 --csv hosts.csv
59
+
60
+ # Use TCP connect scan instead of ping
61
+ whoson 192.168.1.0/24 -t tcp
62
+
63
+ # SYN stealth scan (requires root)
64
+ sudo whoson 10.0.0.0/24 -t syn
65
+
66
+ # Quiet mode -- only write files, no terminal output
67
+ whoson 192.168.1.0/24 -i out.png --json out.json -q
68
+
69
+ # Combine everything
70
+ whoson 192.168.1.0/24 -t tcp -i topology.png --json data.json --csv hosts.csv
71
+ ```
72
+
73
+ ### Example output
74
+
75
+ ```
76
+ Scanning 192.168.1.0/24 (254 usable addresses, ping scan)
77
+ Found 5 hosts in 4.2s
78
+
79
+ IP Hostname Type MAC Vendor Ports
80
+ --------------------------------------------------------------------------------
81
+ 192.168.1.1 router.local gateway AA:BB:CC:DD:EE:01 Cisco -
82
+ 192.168.1.10 web-srv server AA:BB:CC:DD:EE:10 Dell 80,443
83
+ 192.168.1.15 db-srv server AA:BB:CC:DD:EE:15 Dell 3306
84
+ 192.168.1.50 - workstation AA:BB:CC:DD:EE:50 Apple -
85
+ 192.168.1.99 hp-printer printer AA:BB:CC:DD:EE:99 HP 9100
86
+ ```
87
+
88
+ ## Host Classification
89
+
90
+ | Type | Criteria | Color |
91
+ |------|----------|-------|
92
+ | Gateway | IP ends in `.1` or `.254` | Red |
93
+ | Server | Open ports: 22, 80, 443, 25, 53, etc. | Teal |
94
+ | Printer | Open ports: 515, 631, 9100 | Green |
95
+ | Workstation | Default | Blue |
96
+
97
+ ## Export Formats
98
+
99
+ | Format | Flag | Description |
100
+ |--------|------|-------------|
101
+ | PNG | `-i out.png` | Raster topology image |
102
+ | JPEG | `-i out.jpg` | Raster topology image (compressed) |
103
+ | SVG | `-i out.svg` | Vector topology image (scalable) |
104
+ | JSON | `--json FILE` | Full topology data with metadata |
105
+ | CSV | `--csv FILE` | Host list with IP, hostname, type, MAC, vendor, ports |
106
+
107
+ The `-i` / `--image` flag detects the format from the file extension (`.png`, `.jpg`, `.jpeg`, `.svg`).
108
+
109
+ ## Scope and Limitations
110
+
111
+ whoson shows what hosts are alive and classifies them by type. It does **not** discover actual Layer 2/3 topology -- it cannot determine switch port connections or router adjacencies. The image shows a star topology (all hosts connected to the gateway) because that is all that can be honestly inferred from a scan.
112
+
113
+ For real topology discovery using CDP/LLDP/SNMP, see [LibreNMS](https://www.librenms.org/), [Secure Cartography](https://github.com/scottpeterman/secure_cartography), or [NetDisco](https://netdisco.org/).
114
+
115
+ ## Dependencies
116
+
117
+ **Runtime:** `python-nmap` + `Pillow` (2 packages).
118
+
119
+ **System:** `nmap` must be available on `PATH`.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ git clone https://github.com/daniissac/whoson.git
125
+ cd whoson
126
+ pip install -e ".[dev]"
127
+ pytest
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
whoson-0.1.0/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # whoson
2
+
3
+ A lightweight subnet audit tool. Scan a network, see what's alive, and export a topology image -- all from the terminal.
4
+
5
+ [![PyPI](https://img.shields.io/pypi/v/whoson)](https://pypi.org/project/whoson/)
6
+ [![CI](https://github.com/daniissac/whoson/actions/workflows/ci.yml/badge.svg)](https://github.com/daniissac/whoson/actions/workflows/ci.yml)
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ # Prerequisites: nmap must be installed
12
+ brew install nmap # macOS
13
+ sudo apt install nmap # Debian/Ubuntu
14
+ sudo dnf install nmap # Fedora/RHEL
15
+
16
+ # Install whoson
17
+ pip install whoson
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # Scan a subnet and print a host table
24
+ whoson 192.168.1.0/24
25
+
26
+ # Save a topology diagram as PNG
27
+ whoson 192.168.1.0/24 -i topology.png
28
+
29
+ # Or as JPEG or SVG
30
+ whoson 192.168.1.0/24 -i topology.jpg
31
+ whoson 192.168.1.0/24 -i topology.svg
32
+
33
+ # Export as JSON or CSV
34
+ whoson 192.168.1.0/24 --json results.json
35
+ whoson 192.168.1.0/24 --csv hosts.csv
36
+
37
+ # Use TCP connect scan instead of ping
38
+ whoson 192.168.1.0/24 -t tcp
39
+
40
+ # SYN stealth scan (requires root)
41
+ sudo whoson 10.0.0.0/24 -t syn
42
+
43
+ # Quiet mode -- only write files, no terminal output
44
+ whoson 192.168.1.0/24 -i out.png --json out.json -q
45
+
46
+ # Combine everything
47
+ whoson 192.168.1.0/24 -t tcp -i topology.png --json data.json --csv hosts.csv
48
+ ```
49
+
50
+ ### Example output
51
+
52
+ ```
53
+ Scanning 192.168.1.0/24 (254 usable addresses, ping scan)
54
+ Found 5 hosts in 4.2s
55
+
56
+ IP Hostname Type MAC Vendor Ports
57
+ --------------------------------------------------------------------------------
58
+ 192.168.1.1 router.local gateway AA:BB:CC:DD:EE:01 Cisco -
59
+ 192.168.1.10 web-srv server AA:BB:CC:DD:EE:10 Dell 80,443
60
+ 192.168.1.15 db-srv server AA:BB:CC:DD:EE:15 Dell 3306
61
+ 192.168.1.50 - workstation AA:BB:CC:DD:EE:50 Apple -
62
+ 192.168.1.99 hp-printer printer AA:BB:CC:DD:EE:99 HP 9100
63
+ ```
64
+
65
+ ## Host Classification
66
+
67
+ | Type | Criteria | Color |
68
+ |------|----------|-------|
69
+ | Gateway | IP ends in `.1` or `.254` | Red |
70
+ | Server | Open ports: 22, 80, 443, 25, 53, etc. | Teal |
71
+ | Printer | Open ports: 515, 631, 9100 | Green |
72
+ | Workstation | Default | Blue |
73
+
74
+ ## Export Formats
75
+
76
+ | Format | Flag | Description |
77
+ |--------|------|-------------|
78
+ | PNG | `-i out.png` | Raster topology image |
79
+ | JPEG | `-i out.jpg` | Raster topology image (compressed) |
80
+ | SVG | `-i out.svg` | Vector topology image (scalable) |
81
+ | JSON | `--json FILE` | Full topology data with metadata |
82
+ | CSV | `--csv FILE` | Host list with IP, hostname, type, MAC, vendor, ports |
83
+
84
+ The `-i` / `--image` flag detects the format from the file extension (`.png`, `.jpg`, `.jpeg`, `.svg`).
85
+
86
+ ## Scope and Limitations
87
+
88
+ whoson shows what hosts are alive and classifies them by type. It does **not** discover actual Layer 2/3 topology -- it cannot determine switch port connections or router adjacencies. The image shows a star topology (all hosts connected to the gateway) because that is all that can be honestly inferred from a scan.
89
+
90
+ For real topology discovery using CDP/LLDP/SNMP, see [LibreNMS](https://www.librenms.org/), [Secure Cartography](https://github.com/scottpeterman/secure_cartography), or [NetDisco](https://netdisco.org/).
91
+
92
+ ## Dependencies
93
+
94
+ **Runtime:** `python-nmap` + `Pillow` (2 packages).
95
+
96
+ **System:** `nmap` must be available on `PATH`.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ git clone https://github.com/daniissac/whoson.git
102
+ cd whoson
103
+ pip install -e ".[dev]"
104
+ pytest
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
whoson-0.1.0/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """whoson -- lightweight subnet audit tool."""
2
+
3
+ import argparse
4
+ import sys
5
+ import time
6
+
7
+ from discovery.nmap_scan import NetworkScanner
8
+ from graph.topology_builder import TopologyBuilder
9
+ from utils.exporters import TopologyExporter
10
+
11
+
12
+ def _print_table(scan_results):
13
+ """Print a host table to stdout."""
14
+ hosts = scan_results.get('hosts', [])
15
+ if not hosts:
16
+ print("No hosts found.")
17
+ return
18
+
19
+ builder = TopologyBuilder()
20
+
21
+ rows = []
22
+ for h in hosts:
23
+ host_type = builder._classify_host(h)
24
+ ports = ','.join(str(p['port']) for p in h.get('open_ports', []))
25
+ rows.append({
26
+ 'ip': h.get('ip', ''),
27
+ 'hostname': h.get('hostname', '') or '-',
28
+ 'type': host_type,
29
+ 'mac': h.get('mac_address', '') or '-',
30
+ 'vendor': h.get('vendor', '') or '-',
31
+ 'ports': ports or '-',
32
+ })
33
+
34
+ col_widths = {
35
+ 'ip': max(len('IP'), max(len(r['ip']) for r in rows)),
36
+ 'hostname': max(len('Hostname'), max(len(r['hostname']) for r in rows)),
37
+ 'type': max(len('Type'), max(len(r['type']) for r in rows)),
38
+ 'mac': max(len('MAC'), max(len(r['mac']) for r in rows)),
39
+ 'vendor': max(len('Vendor'), max(len(r['vendor']) for r in rows)),
40
+ 'ports': max(len('Ports'), max(len(r['ports']) for r in rows)),
41
+ }
42
+
43
+ header = (
44
+ f"{'IP':<{col_widths['ip']}} "
45
+ f"{'Hostname':<{col_widths['hostname']}} "
46
+ f"{'Type':<{col_widths['type']}} "
47
+ f"{'MAC':<{col_widths['mac']}} "
48
+ f"{'Vendor':<{col_widths['vendor']}} "
49
+ f"{'Ports':<{col_widths['ports']}}"
50
+ )
51
+ sep = '-' * len(header)
52
+
53
+ print(header)
54
+ print(sep)
55
+ for r in rows:
56
+ print(
57
+ f"{r['ip']:<{col_widths['ip']}} "
58
+ f"{r['hostname']:<{col_widths['hostname']}} "
59
+ f"{r['type']:<{col_widths['type']}} "
60
+ f"{r['mac']:<{col_widths['mac']}} "
61
+ f"{r['vendor']:<{col_widths['vendor']}} "
62
+ f"{r['ports']:<{col_widths['ports']}}"
63
+ )
64
+
65
+
66
+ def main(argv=None):
67
+ parser = argparse.ArgumentParser(
68
+ prog='whoson',
69
+ description='Lightweight subnet audit tool -- see what is alive on your network.',
70
+ )
71
+ parser.add_argument(
72
+ 'subnet',
73
+ help='Network in CIDR notation (e.g. 192.168.1.0/24)',
74
+ )
75
+ parser.add_argument(
76
+ '-t', '--type',
77
+ choices=['ping', 'tcp', 'syn'],
78
+ default='ping',
79
+ help='Scan type (default: ping)',
80
+ )
81
+ parser.add_argument(
82
+ '-i', '--image',
83
+ metavar='FILE',
84
+ help='Save topology image (format from extension: .png, .jpg, .svg)',
85
+ )
86
+ parser.add_argument(
87
+ '--json',
88
+ metavar='FILE',
89
+ dest='json_file',
90
+ help='Save topology data as JSON',
91
+ )
92
+ parser.add_argument(
93
+ '--csv',
94
+ metavar='FILE',
95
+ dest='csv_file',
96
+ help='Save host list as CSV',
97
+ )
98
+ parser.add_argument(
99
+ '-q', '--quiet',
100
+ action='store_true',
101
+ help='Suppress table output (only write files)',
102
+ )
103
+
104
+ args = parser.parse_args(argv)
105
+
106
+ scanner = NetworkScanner()
107
+
108
+ if not scanner.validate_subnet(args.subnet):
109
+ print(f"Error: invalid subnet '{args.subnet}'", file=sys.stderr)
110
+ sys.exit(1)
111
+
112
+ info = scanner.get_network_info(args.subnet)
113
+ if not args.quiet:
114
+ print(f"Scanning {args.subnet} ({info['usable_addresses']} usable addresses, {args.type} scan)")
115
+
116
+ t0 = time.time()
117
+ scan_results = scanner.scan_subnet(args.subnet, args.type)
118
+ elapsed = time.time() - t0
119
+
120
+ total = scan_results['total_hosts']
121
+ if not args.quiet:
122
+ print(f"Found {total} host{'s' if total != 1 else ''} in {elapsed:.1f}s\n")
123
+
124
+ if total == 0:
125
+ return
126
+
127
+ if not args.quiet:
128
+ _print_table(scan_results)
129
+
130
+ builder = TopologyBuilder()
131
+ topology_data = builder.build_topology(scan_results)
132
+ exporter = TopologyExporter()
133
+
134
+ if args.image:
135
+ exporter.export_image(topology_data, args.image)
136
+ if not args.quiet:
137
+ print(f"\nImage saved to {args.image}")
138
+
139
+ if args.json_file:
140
+ json_str = exporter.export_json(topology_data)
141
+ with open(args.json_file, 'w', encoding='utf-8') as f:
142
+ f.write(json_str)
143
+ if not args.quiet:
144
+ print(f"JSON saved to {args.json_file}")
145
+
146
+ if args.csv_file:
147
+ csv_str = exporter.export_csv_nodes(topology_data)
148
+ with open(args.csv_file, 'w', encoding='utf-8') as f:
149
+ f.write(csv_str)
150
+ if not args.quiet:
151
+ print(f"CSV saved to {args.csv_file}")
152
+
153
+
154
+ if __name__ == '__main__':
155
+ main()
whoson-0.1.0/config.py ADDED
@@ -0,0 +1,13 @@
1
+ """Shared configuration constants for n2g."""
2
+
3
+ NODE_COLORS = {
4
+ 'gateway': '#ff6b6b',
5
+ 'server': '#4ecdc4',
6
+ 'workstation': '#45b7d1',
7
+ 'printer': '#96ceb4',
8
+ 'unknown': '#999999',
9
+ }
10
+
11
+ SERVER_PORTS = {22, 23, 25, 53, 80, 135, 139, 443, 445, 993, 995}
12
+
13
+ PRINTER_PORTS = {515, 631, 9100}
@@ -0,0 +1 @@
1
+ # Discovery module for network scanning
@@ -0,0 +1,114 @@
1
+ import nmap
2
+ import ipaddress
3
+ import logging
4
+ from typing import List, Dict, Any
5
+ import time
6
+
7
+ class NetworkScanner:
8
+ def __init__(self):
9
+ self.nm = nmap.PortScanner()
10
+ self.logger = logging.getLogger(__name__)
11
+
12
+ def validate_subnet(self, subnet: str) -> bool:
13
+ """Validate if the provided subnet is valid."""
14
+ try:
15
+ ipaddress.ip_network(subnet, strict=False)
16
+ return True
17
+ except ValueError:
18
+ return False
19
+
20
+ def scan_subnet(self, subnet: str, scan_type: str = 'ping') -> Dict[str, Any]:
21
+ """
22
+ Scan a subnet for live hosts using Nmap.
23
+
24
+ Args:
25
+ subnet: Network subnet in CIDR notation (e.g., '192.168.1.0/24')
26
+ scan_type: Type of scan ('ping', 'tcp', 'syn')
27
+
28
+ Returns:
29
+ Dictionary containing scan results
30
+ """
31
+ if not self.validate_subnet(subnet):
32
+ raise ValueError(f"Invalid subnet format: {subnet}")
33
+
34
+ scan_results = {
35
+ 'subnet': subnet,
36
+ 'hosts': [],
37
+ 'scan_time': time.time(),
38
+ 'total_hosts': 0
39
+ }
40
+
41
+ try:
42
+ # Configure scan arguments based on type
43
+ if scan_type == 'ping':
44
+ arguments = '-sn' # Ping scan only
45
+ elif scan_type == 'tcp':
46
+ arguments = '-sT -F' # TCP connect scan with fast mode
47
+ elif scan_type == 'syn':
48
+ arguments = '-sS -F' # SYN stealth scan with fast mode
49
+ else:
50
+ arguments = '-sn'
51
+
52
+ self.logger.info(f"Starting {scan_type} scan of {subnet}")
53
+ scan_result = self.nm.scan(hosts=subnet, arguments=arguments)
54
+
55
+ for host in self.nm.all_hosts():
56
+ host_info = {
57
+ 'ip': host,
58
+ 'hostname': '',
59
+ 'state': self.nm[host].state(),
60
+ 'protocols': [],
61
+ 'open_ports': [],
62
+ 'mac_address': '',
63
+ 'vendor': ''
64
+ }
65
+
66
+ # Get hostname
67
+ if 'hostnames' in self.nm[host] and self.nm[host]['hostnames']:
68
+ host_info['hostname'] = self.nm[host]['hostnames'][0]['name']
69
+
70
+ # Get MAC address and vendor info
71
+ if 'addresses' in self.nm[host]:
72
+ if 'mac' in self.nm[host]['addresses']:
73
+ host_info['mac_address'] = self.nm[host]['addresses']['mac']
74
+ if 'vendor' in self.nm[host]:
75
+ host_info['vendor'] = list(self.nm[host]['vendor'].values())[0] if self.nm[host]['vendor'] else ''
76
+
77
+ # Get port information if available
78
+ for protocol in self.nm[host].all_protocols():
79
+ host_info['protocols'].append(protocol)
80
+ ports = self.nm[host][protocol].keys()
81
+ for port in ports:
82
+ port_state = self.nm[host][protocol][port]['state']
83
+ if port_state == 'open':
84
+ host_info['open_ports'].append({
85
+ 'port': port,
86
+ 'protocol': protocol,
87
+ 'service': self.nm[host][protocol][port].get('name', 'unknown')
88
+ })
89
+
90
+ scan_results['hosts'].append(host_info)
91
+
92
+ scan_results['total_hosts'] = len(scan_results['hosts'])
93
+ self.logger.info(f"Scan completed. Found {scan_results['total_hosts']} hosts")
94
+
95
+ except Exception as e:
96
+ self.logger.error(f"Scan failed: {str(e)}")
97
+ raise Exception(f"Network scan failed: {str(e)}")
98
+
99
+ return scan_results
100
+
101
+ def get_network_info(self, subnet: str) -> Dict[str, Any]:
102
+ """Get basic network information."""
103
+ try:
104
+ network = ipaddress.ip_network(subnet, strict=False)
105
+ return {
106
+ 'network_address': str(network.network_address),
107
+ 'broadcast_address': str(network.broadcast_address),
108
+ 'netmask': str(network.netmask),
109
+ 'total_addresses': network.num_addresses,
110
+ 'usable_addresses': network.num_addresses - 2 if network.num_addresses > 2 else 0,
111
+ 'prefix_length': network.prefixlen
112
+ }
113
+ except ValueError as e:
114
+ raise ValueError(f"Invalid network: {str(e)}")
@@ -0,0 +1 @@
1
+ # Graph module for topology building
@@ -0,0 +1,131 @@
1
+ import ipaddress
2
+ import logging
3
+ from typing import List, Dict, Any
4
+
5
+ from config import SERVER_PORTS, PRINTER_PORTS
6
+
7
+
8
+ class TopologyBuilder:
9
+ def __init__(self):
10
+ self.nodes: Dict[str, Dict[str, Any]] = {}
11
+ self.edges: List[Dict[str, Any]] = []
12
+ self.logger = logging.getLogger(__name__)
13
+
14
+ def build_topology(self, scan_results: Dict[str, Any]) -> Dict[str, Any]:
15
+ """
16
+ Build network topology from scan results.
17
+
18
+ Args:
19
+ scan_results: Results from network scanner
20
+
21
+ Returns:
22
+ Dict with nodes and edges representing the topology
23
+ """
24
+ self.nodes.clear()
25
+ self.edges.clear()
26
+ hosts = scan_results.get('hosts', [])
27
+ subnet = scan_results.get('subnet', '')
28
+
29
+ if not hosts:
30
+ return self.get_graph_data()
31
+
32
+ for host in hosts:
33
+ ip = host['ip']
34
+ self.nodes[ip] = {
35
+ 'ip': ip,
36
+ 'hostname': host.get('hostname', ''),
37
+ 'state': host.get('state', 'unknown'),
38
+ 'mac_address': host.get('mac_address', ''),
39
+ 'vendor': host.get('vendor', ''),
40
+ 'open_ports': host.get('open_ports', []),
41
+ 'protocols': host.get('protocols', []),
42
+ 'node_type': self._classify_host(host),
43
+ }
44
+
45
+ self._infer_connections(hosts, subnet)
46
+
47
+ return self.get_graph_data()
48
+
49
+ def _classify_host(self, host: Dict[str, Any]) -> str:
50
+ """Classify host type based on IP address and open ports."""
51
+ ip = host['ip']
52
+ if ip.endswith('.1') or ip.endswith('.254'):
53
+ return 'gateway'
54
+
55
+ host_ports = {port['port'] for port in host.get('open_ports', [])}
56
+
57
+ if host_ports & SERVER_PORTS:
58
+ return 'server'
59
+
60
+ if host_ports & PRINTER_PORTS:
61
+ return 'printer'
62
+
63
+ return 'workstation'
64
+
65
+ def _infer_connections(self, hosts: List[Dict[str, Any]], subnet: str):
66
+ """
67
+ Build a star topology: every host connects to the gateway.
68
+
69
+ This is the only topology that can be honestly inferred from a
70
+ scan without SNMP/CDP/LLDP neighbor data.
71
+ """
72
+ try:
73
+ network = ipaddress.ip_network(subnet, strict=False)
74
+ gateway_ip = str(network.network_address + 1)
75
+
76
+ actual_gateway = None
77
+ for ip, attrs in self.nodes.items():
78
+ if attrs.get('node_type') == 'gateway':
79
+ actual_gateway = ip
80
+ break
81
+
82
+ if actual_gateway:
83
+ gateway_ip = actual_gateway
84
+
85
+ if gateway_ip not in self.nodes:
86
+ return
87
+
88
+ host_ips = [h['ip'] for h in hosts]
89
+ for ip in host_ips:
90
+ if ip != gateway_ip:
91
+ self.edges.append({
92
+ 'source': ip,
93
+ 'target': gateway_ip,
94
+ 'weight': 1.0,
95
+ 'type': 'gateway',
96
+ })
97
+
98
+ except (ValueError, ipaddress.AddressValueError) as exc:
99
+ self.logger.error("Error inferring connections: %s", exc)
100
+
101
+ def get_graph_data(self) -> Dict[str, Any]:
102
+ """Get graph data in a format suitable for D3.js visualization."""
103
+ nodes = []
104
+ for ip, attrs in self.nodes.items():
105
+ nodes.append({
106
+ 'id': ip,
107
+ 'label': attrs.get('hostname') or ip,
108
+ 'ip': ip,
109
+ 'type': attrs.get('node_type', 'workstation'),
110
+ 'hostname': attrs.get('hostname', ''),
111
+ 'state': attrs.get('state', 'unknown'),
112
+ 'mac_address': attrs.get('mac_address', ''),
113
+ 'vendor': attrs.get('vendor', ''),
114
+ 'open_ports': len(attrs.get('open_ports', [])),
115
+ 'protocols': attrs.get('protocols', []),
116
+ })
117
+
118
+ type_counts: Dict[str, int] = {}
119
+ for attrs in self.nodes.values():
120
+ nt = attrs.get('node_type', 'unknown')
121
+ type_counts[nt] = type_counts.get(nt, 0) + 1
122
+
123
+ return {
124
+ 'nodes': nodes,
125
+ 'links': list(self.edges),
126
+ 'stats': {
127
+ 'total_nodes': len(nodes),
128
+ 'total_links': len(self.edges),
129
+ 'node_types': type_counts,
130
+ },
131
+ }