Initial commit: Werkzeuge-Sammlung

Enthält:
- rdp_client.py: RDP Client mit GUI und Monitor-Auswahl
- rdp.sh: Bash-basierter RDP Client
- teamleader_test/: Network Scanner Fullstack-App
- teamleader_test2/: Network Mapper CLI

Subdirectories mit eigenem Repo wurden ausgeschlossen.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-28 09:39:24 +01:00
commit cb073786b3
112 changed files with 23543 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# LAN Graph Explorer - AI Coding Agent Instructions
## Project Overview
Python-based LAN discovery tool that scans local networks via ping/SSH, builds a topology graph, and visualizes it with D3.js. The architecture separates scanning logic ([network_mapper/scanner.py](../network_mapper/scanner.py)), FastAPI backend ([network_mapper/main.py](../network_mapper/main.py)), and D3 frontend ([frontend/](../frontend/)).
## Architecture & Data Flow
1. **Scanner** (`NetworkScanner` class): Auto-discovers primary IPv4 CIDR via `ip -4 addr`, pings all hosts concurrently (default 64), performs reverse DNS lookups, and optionally SSH-probes reachable hosts with `ip neigh show` to discover neighbor relationships
2. **Data Model**: `HostNode` (IP, DNS, reachability, SSH status), `ConnectionEdge` (source/target IPs + relation type: `gateway`/`neighbor`/`scan`), `ScanResult` (aggregates nodes/edges/metadata)
3. **API**: FastAPI serves `/api/scan` endpoint accepting `cidr`, `concurrency`, `ssh_timeout` query params; returns JSON graph
4. **Frontend**: D3 force-directed graph (`d3.forceSimulation`) renders nodes colored by role (gateway=orange, scanner=blue, SSH-enabled=green)
## Critical Developer Workflows
### Installation & Setup
```bash
# Install in virtualenv (project uses Python >=3.11)
pip install --upgrade pip
pip install -e . # installs 'lan-graph' CLI from pyproject.toml entry point
# For testing
pip install -e ".[testing]"
pytest
```
### Running the Tool
```bash
# CLI scan (outputs JSON to stdout or --output file)
lan-graph scan --concurrency 64 --ssh-timeout 5
# Start web server (FastAPI + D3 frontend)
lan-graph serve --host 0.0.0.0 --port 8000
# Access at http://localhost:8000
```
### SSH Key Authentication
Set environment variables to enable neighbor discovery via passwordless SSH:
```bash
export SSH_USER=team
export SSH_KEY_PATH=~/.ssh/id_rsa
```
Scanner uses `ssh -o BatchMode=yes` to avoid password prompts. CLI flags `--ssh-user` and `--ssh-key` override env vars.
## Project-Specific Conventions
### Async/Await Patterns
- **Concurrent scanning**: Uses `asyncio.Semaphore` for rate-limiting ping operations (`_ping_concurrency=64`) and SSH probes (`_ssh_concurrency=10`)
- **Host probing**: `_probe_host()` acquires semaphore for ping, then conditionally probes SSH; results gathered via `asyncio.gather()`
- Blocking operations (DNS lookups, subprocess calls) wrapped in `asyncio.to_thread()` or use `asyncio.create_subprocess_exec()`
### Edge Deduplication
In `scanner.py`, edges are deduplicated via `seen_edges` set tracking `(source, target, relation)` tuples before creating `ConnectionEdge` objects. This prevents duplicate links in the visualization.
### Frontend-Backend Contract
- `/api/scan` returns JSON matching `ScanResult.to_dict()` structure
- Nodes must have `ip` field (used as D3 force layout ID)
- Frontend expects `edges` array with `source`/`target` as IP strings (D3 converts to node references)
### Error Handling
- Scanner methods (`_discover_network`, `_default_gateway`) raise `RuntimeError` with descriptive messages if system commands fail
- SSH failures return empty neighbor set + `via_ssh=False`; network discovery continues
- Frontend displays error messages in `#status` element on API fetch failures
## Testing Approach
Uses pytest with basic unit tests in [tests/test_scanner.py](../tests/test_scanner.py):
- `parse_neighbor_ips()` regex extraction tested with sample `ip neigh` output
- `ScanResult.to_dict()` serialization validated
- No integration tests currently; future tests should mock subprocess calls
## Key Files & Extension Points
- **[network_mapper/scanner.py](../network_mapper/scanner.py)**: `_collect_neighbors()` can be extended for additional probes (e.g., `probe_tcp_port()` helpers for HTTP/SMB)
- **[frontend/script.js](../frontend/script.js)**: `colorForNode()` and `render()` functions define visualization styling; swap `d3.forceSimulation()` for hierarchical layouts if needed
- **[pyproject.toml](../pyproject.toml)**: Entry point `lan-graph = "network_mapper.cli:app"` uses Typer for CLI generation
## Dependencies & Environment
- **System requirements**: Linux with `ip`, `ping`, `ssh` commands (no Windows support)
- **Python deps**: FastAPI, uvicorn[standard], typer (see [pyproject.toml](../pyproject.toml))
- **Frontend**: Vanilla D3.js v7 (loaded from CDN in [frontend/index.html](../frontend/index.html))
## Common Issues
- **"Unable to determine local IPv4 network"**: Scanner requires non-loopback global-scope IPv4 interface
- **No neighbor edges**: Verify `SSH_USER`/`SSH_KEY_PATH` are set and target hosts allow key-based auth
- **FastAPI 404 on static files**: Ensure `FRONTEND_DIR` path resolution in `main.py` points to workspace-relative [frontend/](../frontend/) directory

View File

@@ -0,0 +1,15 @@
# LAN Graph Architecture
## Overview
- **Scanner**: Discovers live IPv4 hosts in the local subnet by pinging each address in the primary CIDR range discovered from `ip -4 addr`. Reverse DNS lookups enrich each host node. When SSH is reachable using key-based authentication, the scanner also runs `ip neigh show` remotely to learn neighbor MAC/IP and infers host-to-host connections.
- **Data Model**: `HostNode` stores IP, DNS name, reachability, last-seen timestamp, and optionally gathered SSH services; `ConnectionEdge` links nodes with a typed relation (e.g., `gateway`, `neighbor`, `ssh`) to drive the visualization. `ScanResult` aggregates nodes, edges, CIDR, gateway, and timestamp.
- **Web Visualization**: FastAPI serves the API plus a D3.js front-end. `/api/scan` triggers a fresh scan (or accepts `cidr` override) and returns the JSON graph. Static assets live under `frontend/` and render the nodes/edges with `d3-force` to deliver a Visio-like topology.
- **CLI/Server**: Typing `lan-graph scan` runs a JSON scan, and `lan-graph serve` launches FastAPI (uvicorn) to host the visual overview and API.
## Security & SSH
- Scanner defaults to `ssh -o BatchMode=yes -o ConnectTimeout=5` to honor the user's request for key-based auth and to avoid prompting for passwords.
- SSH user/key can come from the calling environment via `SSH_USER` and `SSH_KEY_PATH` or CLI flags so credentials are not hard-coded.
## Extensibility Points
- Additional probing (SMB, HTTP) can be added via `Scanner.probe_tcp_port` helpers.
- Visualization can be enhanced by swapping the D3 force layout for hierarchical or layered diagrams.

View File

@@ -0,0 +1,43 @@
# LAN Graph Explorer
A local LAN discovery service that pings every host on your primary subnet, optionally uses SSH key-based auth to query remote neighbors, and presents a Visio-style map via D3.
## Features
- Detects your default IPv4 network and gateway automatically.
- Pings each active host and performs reverse DNS lookup.
- Attempts passwordless SSH (honoring `SSH_USER`/`SSH_KEY_PATH`) to collect `ip neigh` data so we can draw edges between hosts.
- Serves a FastAPI backend and a D3-powered front-end that renders nodes/edges interactively.
## Getting started
1. Install dependencies (ideally inside a virtualenv):
```bash
pip install --upgrade pip
pip install fastapi uvicorn[standard] typer
```
2. Run a scan from the command line:
```bash
lan-graph scan --concurrency 64 --ssh-timeout 5
```
3. Start the UI server:
```bash
lan-graph serve --host 0.0.0.0 --port 8000
```
Open `http://localhost:8000` in a browser to see the topology.
## SSH key authentication
Set environment variables so the scanner uses your preferred SSH identity:
```bash
export SSH_USER=team
export SSH_KEY_PATH=~/.ssh/id_rsa
```
The tool runs `ssh -o BatchMode=yes` and will skip hosts where it cannot connect quickly.
## Testing
```bash
pip install pytest
pytest
```
## Notes
- The graph draws gateways, the scanning host, and any discovered neighbor relationships for hosts that allow `ssh`.
- It's designed to run on Linux machines with `ip`, `ping`, and `ssh` in place.

View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LAN Graph Explorer</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>LAN Graph Explorer</h1>
<p>Discover your local hosts and their relationships in a Visio-style topology.</p>
<button id="refresh">Refresh scan</button>
</header>
<main>
<div id="status">Idle</div>
<svg id="topology" viewBox="0 0 1000 600"></svg>
</main>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="/static/script.js"></script>
</body>
</html>

View File

@@ -0,0 +1,103 @@
const statusEl = document.querySelector('#status');
const svg = d3.select('#topology');
const width = 1000;
const height = 600;
const linkGroup = svg.append('g').attr('class', 'links');
const nodeGroup = svg.append('g').attr('class', 'nodes');
const simulation = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.ip).distance(140))
.force('charge', d3.forceManyBody().strength(-200))
.force('center', d3.forceCenter(width / 2, height / 2));
function colorForNode(node) {
if (node.comment && node.comment.includes('gateway')) return '#ffb347';
if (node.comment && node.comment.includes('scanner')) return '#4db8ff';
return node.via_ssh ? '#7fbea6' : '#d4d4d4';
}
function render(data) {
const edges = data.edges.map(edge => ({
...edge,
source: edge.source,
target: edge.target,
}));
const link = linkGroup.selectAll('line').data(edges, d => `${d.source}|${d.target}|${d.relation}`);
link.join(
enter => enter.append('line').attr('stroke-width', 2),
update => update,
exit => exit.remove()
).attr('stroke', '#999');
const node = nodeGroup.selectAll('g').data(data.nodes, d => d.ip);
const nodeEnter = node.enter().append('g').call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
nodeEnter.append('circle').attr('r', 26);
nodeEnter.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '0.35em')
.text(d => d.ip);
nodeEnter.append('title');
const nodeMerged = nodeEnter.merge(node);
nodeMerged.select('circle').attr('fill', colorForNode);
nodeMerged.select('title').text(d => `${d.ip}\n${d.dns_name || 'no reverse host'}\nvia SSH: ${d.via_ssh}`);
node.exit().remove();
simulation.nodes(data.nodes).on('tick', ticked);
simulation.force('link').links(edges);
simulation.alpha(1).restart();
}
function ticked() {
linkGroup.selectAll('line')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
nodeGroup.selectAll('g')
.attr('transform', d => `translate(${d.x},${d.y})`);
}
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
async function refresh() {
try {
statusEl.textContent = 'Scanning local LAN…';
const response = await fetch('/api/scan');
if (!response.ok) {
throw new Error(`scan failed: ${response.status}`);
}
const payload = await response.json();
render(payload);
statusEl.textContent = `Last scanned ${new Date().toLocaleTimeString()}`;
} catch (error) {
statusEl.textContent = `Error: ${error.message}`;
}
}
document.querySelector('#refresh').addEventListener('click', refresh);
refresh();

View File

@@ -0,0 +1,71 @@
* {
box-sizing: border-box;
font-family: Inter, system-ui, sans-serif;
}
body {
margin: 0;
background: #0f141a;
color: #f4f5f7;
}
header {
padding: 1rem 2rem;
background: linear-gradient(90deg, #1d2734, #0d121b);
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
header p {
margin: 0;
color: #9da8b7;
}
main {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
}
#status {
margin-bottom: 0.5rem;
font-size: 0.95rem;
color: #a5c9ff;
}
#topology {
width: min(100%, 1100px);
background: #111827;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 45px rgba(5, 5, 5, 0.4);
}
circle {
stroke: #fff;
stroke-width: 1;
}
button {
border: none;
border-radius: 999px;
padding: 0.5rem 1rem;
background: #2563eb;
color: white;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.35);
}

View File

@@ -0,0 +1,6 @@
"""LAN topology scanning package."""
__all__ = [
"scanner",
"main",
"cli",
]

View File

@@ -0,0 +1,61 @@
"""Command line entrypoints for the LAN visualizer."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import typer
from network_mapper.scanner import NetworkScanner
app = typer.Typer(help="LAN discovery + visualization tooling")
@app.command()
def scan(
cidr: str | None = typer.Option(
None,
help="CIDR range to probe (defaults to the primary IPv4 network).",
),
concurrency: int = typer.Option(32, help="Limit the number of simultaneous pings."),
ssh_timeout: float = typer.Option(5.0, help="Seconds to wait for SSH probes."),
ssh_user: str | None = typer.Option(
None,
envvar="SSH_USER",
help="Username for SSH probes when key auth is available.",
),
ssh_key: Path | None = typer.Option(
None,
envvar="SSH_KEY_PATH",
exists=True,
file_okay=True,
dir_okay=False,
help="Private key path for passwordless SSH execution.",
),
output: Path | None = typer.Option(
None,
exists=False,
file_okay=True,
dir_okay=False,
help="Write the scan result JSON to this file instead of stdout.",
),
):
"""Run a single scan and emit JSON results."""
scanner = NetworkScanner(ssh_user=ssh_user, ssh_key_path=str(ssh_key) if ssh_key else None)
result = asyncio.run(scanner.scan(cidr=cidr, concurrency=concurrency, ssh_timeout=ssh_timeout))
payload = result.to_dict()
serialized = json.dumps(payload, indent=2)
if output:
output.write_text(serialized)
typer.echo(f"Scan saved to {output}")
else:
typer.echo(serialized)
@app.command()
def serve(host: str = "0.0.0.0", port: int = 8000, reload: bool = False):
"""Start the FastAPI server and front-end."""
import uvicorn
uvicorn.run("network_mapper.main:app", host=host, port=port, reload=reload)

View File

@@ -0,0 +1,35 @@
"""FastAPI application that exposes the scan API and serves the front-end."""
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from network_mapper.scanner import NetworkScanner
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"
app = FastAPI(title="LAN Graph Explorer")
scanner = NetworkScanner(
ssh_user=os.environ.get("SSH_USER"),
ssh_key_path=os.environ.get("SSH_KEY_PATH"),
)
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@app.get("/api/scan")
async def api_scan(cidr: str | None = None, concurrency: int = 64, ssh_timeout: float = 5.0):
"""Run a fresh scan and return the topology graph."""
result = await scanner.scan(cidr=cidr, concurrency=concurrency, ssh_timeout=ssh_timeout)
return result.to_dict()
@app.get("/")
async def serve_frontend():
return FileResponse(FRONTEND_DIR / "index.html")

View File

@@ -0,0 +1,291 @@
"""Network scanning helpers for the LAN graph tool."""
from __future__ import annotations
import asyncio
import dataclasses
import ipaddress
import json
import re
import socket
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, List, Optional, Set, Tuple
NEIGHBOR_PATTERN = re.compile(r"^(?P<ip>\d+\.\d+\.\d+\.\d+)")
@dataclass
class HostNode:
ip: str
dns_name: Optional[str]
reachable: bool
last_seen: float
via_ssh: bool = False
comment: Optional[str] = None
def to_dict(self) -> dict:
return {
"ip": self.ip,
"dns_name": self.dns_name,
"reachable": self.reachable,
"last_seen": self.last_seen,
"via_ssh": self.via_ssh,
"comment": self.comment,
}
@dataclass
class ConnectionEdge:
source: str
target: str
relation: str
def to_dict(self) -> dict:
return {
"source": self.source,
"target": self.target,
"relation": self.relation,
}
@dataclass
class ScanResult:
cidr: str
gateway: str
nodes: List[HostNode]
edges: List[ConnectionEdge]
generated_at: float
def to_dict(self) -> dict:
return {
"cidr": self.cidr,
"gateway": self.gateway,
"generated_at": self.generated_at,
"nodes": [node.to_dict() for node in self.nodes],
"edges": [edge.to_dict() for edge in self.edges],
}
@dataclass
class _HostProbe:
ip: str
reachable: bool
dns_name: Optional[str]
neighbors: Set[str]
via_ssh: bool
def parse_neighbor_ips(raw_output: str) -> Set[str]:
ips: Set[str] = set()
for line in raw_output.splitlines():
match = NEIGHBOR_PATTERN.match(line.strip())
if match:
ips.add(match.group("ip"))
return ips
class NetworkScanner:
def __init__(self, ssh_user: Optional[str] = None, ssh_key_path: Optional[str] = None):
self.ssh_user = ssh_user
self.ssh_key_path = ssh_key_path
self._ping_concurrency = 64
self._ssh_concurrency = 10
self._ssh_semaphore: Optional[asyncio.Semaphore] = None
async def scan(
self,
cidr: Optional[str] = None,
concurrency: Optional[int] = None,
ssh_timeout: float = 5.0,
) -> ScanResult:
if concurrency:
self._ping_concurrency = concurrency
network, gateway, local_ip = self._discover_network(cidr)
host_ips = list(network.hosts())
semaphore = asyncio.Semaphore(self._ping_concurrency)
self._ssh_semaphore = asyncio.Semaphore(self._ssh_concurrency)
probes = []
for host in host_ips:
probes.append(self._probe_host(str(host), semaphore, ssh_timeout))
probe_results = await asyncio.gather(*probes)
nodes: List[HostNode] = []
node_map: dict[str, HostNode] = {}
edges: List[ConnectionEdge] = []
timestamp = time.time()
# add gateway and scanner host nodes first
gateway_node = HostNode(
ip=gateway,
dns_name=None,
reachable=True,
last_seen=timestamp,
comment="default gateway",
)
node_map[gateway] = gateway_node
local_node = HostNode(
ip=local_ip,
dns_name=None,
reachable=True,
last_seen=timestamp,
comment="this scanner",
)
node_map[local_ip] = local_node
final_edges: List[ConnectionEdge] = []
seen_edges: Set[Tuple[str, str, str]] = set()
def append_edge(source: str, target: str, relation: str) -> None:
key = (source, target, relation)
if key in seen_edges:
return
seen_edges.add(key)
final_edges.append(ConnectionEdge(source=source, target=target, relation=relation))
for probe in probe_results:
if not probe.reachable:
continue
node = HostNode(
ip=probe.ip,
dns_name=probe.dns_name,
reachable=True,
last_seen=timestamp,
via_ssh=probe.via_ssh,
)
node_map[probe.ip] = node
if probe.ip != local_ip:
append_edge(local_ip, probe.ip, "scan")
if probe.ip != gateway:
append_edge(gateway, probe.ip, "gateway")
for probe in probe_results:
if not probe.reachable or not probe.neighbors:
continue
source = probe.ip
for neighbor in probe.neighbors:
if neighbor not in node_map:
continue
append_edge(source, neighbor, "neighbor")
result = ScanResult(
cidr=str(network),
gateway=gateway,
nodes=list(node_map.values()),
edges=final_edges,
generated_at=timestamp,
)
return result
async def _probe_host(self, ip: str, semaphore: asyncio.Semaphore, ssh_timeout: float) -> _HostProbe:
async with semaphore:
alive = await self._ping(ip)
name = await asyncio.to_thread(self._resolve_dns, ip) if alive else None
neighbors: Set[str] = set()
via_ssh = False
if alive:
neighbors, via_ssh = await self._collect_neighbors(ip, ssh_timeout)
return _HostProbe(ip=ip, reachable=alive, dns_name=name, neighbors=neighbors, via_ssh=via_ssh)
async def _ping(self, ip: str) -> bool:
process = await asyncio.create_subprocess_exec(
"ping",
"-c",
"1",
"-W",
"1",
ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await process.communicate()
return process.returncode == 0
def _resolve_dns(self, ip: str) -> Optional[str]:
try:
return socket.gethostbyaddr(ip)[0]
except OSError:
return None
async def _collect_neighbors(self, ip: str, ssh_timeout: float) -> Tuple[Set[str], bool]:
ssh_command = ["ssh", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={ssh_timeout}"]
if self.ssh_key_path:
ssh_command.extend(["-i", str(self.ssh_key_path)])
target = f"{self.ssh_user}@{ip}" if self.ssh_user else ip
ssh_command.append(target)
ssh_command.extend(["ip", "-4", "neigh", "show"])
if self._ssh_semaphore is None:
self._ssh_semaphore = asyncio.Semaphore(self._ssh_concurrency)
async with self._ssh_semaphore:
process = await asyncio.create_subprocess_exec(
*ssh_command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _ = await process.communicate()
if process.returncode != 0:
return set(), False
neighbors = parse_neighbor_ips(stdout.decode("utf-8", errors="ignore"))
return neighbors, True
def _discover_network(self, cidr_override: Optional[str]) -> Tuple[ipaddress.IPv4Network, str, str]:
if cidr_override is not None:
network = ipaddress.IPv4Network(cidr_override, strict=False)
else:
network = self._without_override()
gateway = self._default_gateway()
local_ip = self._local_ip_from_network(network)
return network, gateway, local_ip
def _without_override(self) -> ipaddress.IPv4Network:
output = subprocess.run(
["ip", "-o", "-4", "addr", "show", "scope", "global"],
capture_output=True,
text=True,
check=False,
)
for line in output.stdout.splitlines():
parts = line.split()
if len(parts) < 4:
continue
iface = parts[1]
if iface == "lo":
continue
inet = parts[3]
return ipaddress.IPv4Network(inet, strict=False)
raise RuntimeError("Unable to determine local IPv4 network")
def _default_gateway(self) -> str:
output = subprocess.run(
["ip", "route", "show", "default"],
capture_output=True,
text=True,
check=False,
)
for line in output.stdout.splitlines():
parts = line.split()
if "via" in parts:
via_index = parts.index("via")
return parts[via_index + 1]
raise RuntimeError("Unable to determine default gateway")
def _local_ip_from_network(self, network: ipaddress.IPv4Network) -> str:
output = subprocess.run(
["ip", "-o", "-4", "addr", "show", "scope", "global"],
capture_output=True,
text=True,
check=False,
)
for line in output.stdout.splitlines():
parts = line.split()
if len(parts) < 4:
continue
iface = parts[1]
if iface == "lo":
continue
inet = parts[3]
if ipaddress.IPv4Address(inet.split("/")[0]) in network:
return inet.split("/")[0]
raise RuntimeError("Unable to determine scanner IP within network")

View File

@@ -0,0 +1,29 @@
[project]
name = "lan-graph"
version = "0.1.0"
description = "Local LAN discovery with a Visio-style web visualization."
readme = "README.md"
authors = [
{ name = "TeamLeader", email = "teamleader@example.com" },
]
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.113",
"uvicorn[standard]>=0.24",
"typer>=0.9",
]
[project.optional-dependencies]
testing = [
"pytest>=8.3",
]
[project.scripts]
lan-graph = "network_mapper.cli:app"
[tool.setuptools]
packages = ["network_mapper"]
[build-system]
requires = ["setuptools>=61.0","wheel"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,24 @@
"""Basic unit tests for the LAN graph scanner helpers."""
from network_mapper.scanner import ConnectionEdge, HostNode, ScanResult, parse_neighbor_ips
def test_parse_neighbor_ips_filters_ips():
sample = """
192.168.5.10 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
invalid line
192.168.5.11 dev eth0 lladdr 11:22:33:44:55:66 STALE
"""
parsed = parse_neighbor_ips(sample)
assert parsed == {"192.168.5.10", "192.168.5.11"}
def test_scan_result_dict_includes_nodes_and_edges():
nodes = [HostNode(ip="10.0.0.5", dns_name="node", reachable=True, last_seen=0.0)]
edges = [ConnectionEdge(source="10.0.0.1", target="10.0.0.5", relation="gateway")]
scan_result = ScanResult(cidr="10.0.0.0/24", gateway="10.0.0.1", nodes=nodes, edges=edges, generated_at=0.0)
payload = scan_result.to_dict()
assert payload["cidr"] == "10.0.0.0/24"
assert payload["nodes"][0]["ip"] == "10.0.0.5"
assert payload["edges"][0]["relation"] == "gateway"