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:
82
teamleader_test2/.github/copilot-instructions.md
vendored
Normal file
82
teamleader_test2/.github/copilot-instructions.md
vendored
Normal 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
|
||||
15
teamleader_test2/ARCHITECTURE.md
Normal file
15
teamleader_test2/ARCHITECTURE.md
Normal 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.
|
||||
43
teamleader_test2/README.md
Normal file
43
teamleader_test2/README.md
Normal 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.
|
||||
22
teamleader_test2/frontend/index.html
Normal file
22
teamleader_test2/frontend/index.html
Normal 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>
|
||||
103
teamleader_test2/frontend/script.js
Normal file
103
teamleader_test2/frontend/script.js
Normal 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();
|
||||
71
teamleader_test2/frontend/style.css
Normal file
71
teamleader_test2/frontend/style.css
Normal 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);
|
||||
}
|
||||
6
teamleader_test2/network_mapper/__init__.py
Normal file
6
teamleader_test2/network_mapper/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""LAN topology scanning package."""
|
||||
__all__ = [
|
||||
"scanner",
|
||||
"main",
|
||||
"cli",
|
||||
]
|
||||
61
teamleader_test2/network_mapper/cli.py
Normal file
61
teamleader_test2/network_mapper/cli.py
Normal 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)
|
||||
35
teamleader_test2/network_mapper/main.py
Normal file
35
teamleader_test2/network_mapper/main.py
Normal 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")
|
||||
291
teamleader_test2/network_mapper/scanner.py
Normal file
291
teamleader_test2/network_mapper/scanner.py
Normal 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")
|
||||
29
teamleader_test2/pyproject.toml
Normal file
29
teamleader_test2/pyproject.toml
Normal 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"
|
||||
24
teamleader_test2/tests/test_scanner.py
Normal file
24
teamleader_test2/tests/test_scanner.py
Normal 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"
|
||||
Reference in New Issue
Block a user