Initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
data/
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
16
.vscode/settings.json
vendored
Normal file
16
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"workbench.colorCustomizations": {
|
||||
"tree.indentGuidesStroke": "#3d92ec",
|
||||
"activityBar.background": "#1E1E5C",
|
||||
"titleBar.activeBackground": "#2A2A81",
|
||||
"titleBar.activeForeground": "#FCFCFE",
|
||||
"titleBar.inactiveBackground": "#1E1E5C",
|
||||
"titleBar.inactiveForeground": "#FCFCFE",
|
||||
"statusBar.background": "#1E1E5C",
|
||||
"statusBar.foreground": "#FCFCFE",
|
||||
"statusBar.debuggingBackground": "#1E1E5C",
|
||||
"statusBar.debuggingForeground": "#FCFCFE",
|
||||
"statusBar.noFolderBackground": "#1E1E5C",
|
||||
"statusBar.noFolderForeground": "#FCFCFE"
|
||||
}
|
||||
}
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nmap \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# NetTrak
|
||||
|
||||
NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs:
|
||||
- Devices discovered on the network
|
||||
- Open ports per device
|
||||
- Service fingerprint details from `nmap`
|
||||
- HTTP headers and lightweight banners when available
|
||||
|
||||
Results are persisted in SQLite for change tracking (new/updated/missing devices and ports).
|
||||
|
||||
## Features
|
||||
|
||||
- Dark mode UI by default
|
||||
- 3-pane layout:
|
||||
- Left: discovered machines
|
||||
- Right-top: selected machine details
|
||||
- Right-bottom: collapsible port records with headers/banners
|
||||
- Background scan execution
|
||||
- SQLite persistence for historical tracking
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Backend: FastAPI + SQLite
|
||||
- Scanner: `nmap` + lightweight Python probes
|
||||
- Frontend: Static HTML/CSS/JS
|
||||
- Deployment: Docker / Docker Compose
|
||||
|
||||
## Run With Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open: `http://localhost:8080`
|
||||
|
||||
Database file is stored at `./data/nettrak.db` via a bind mount.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
- `NETTRAK_DB_PATH` (default: `/data/nettrak.db`)
|
||||
- `NETTRAK_SUBNET` (default: `192.168.2.0/24`)
|
||||
|
||||
In Compose, these are already set.
|
||||
|
||||
## LAN Scanning Notes
|
||||
|
||||
- LAN host discovery can be limited in bridged container networking.
|
||||
- For best results on Linux hosts, enable host networking in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
- Some `nmap` OS detection capabilities may require elevated privileges. The app automatically falls back if OS detection fails.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/devices`
|
||||
- `GET /api/devices/{id}`
|
||||
- `GET /api/scans?limit=20`
|
||||
- `POST /api/scans/run?subnet=192.168.2.0/24`
|
||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
app/config.py
Normal file
5
app/config.py
Normal file
@@ -0,0 +1,5 @@
|
||||
import os
|
||||
|
||||
DB_PATH = os.getenv("NETTRAK_DB_PATH", "/data/nettrak.db")
|
||||
DEFAULT_SUBNET = os.getenv("NETTRAK_SUBNET", "192.168.2.0/24")
|
||||
SCAN_TIMEOUT_SECONDS = int(os.getenv("NETTRAK_SCAN_TIMEOUT", "1800"))
|
||||
74
app/db.py
Normal file
74
app/db.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subnet TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
host_count INTEGER DEFAULT 0,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
hostname TEXT,
|
||||
mac TEXT,
|
||||
vendor TEXT,
|
||||
os_name TEXT,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
last_scan_id INTEGER,
|
||||
FOREIGN KEY(last_scan_id) REFERENCES scans(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id INTEGER NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
service TEXT,
|
||||
product TEXT,
|
||||
version TEXT,
|
||||
extra_info TEXT,
|
||||
banner TEXT,
|
||||
headers_json TEXT,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
is_open INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(device_id, port, protocol),
|
||||
FOREIGN KEY(device_id) REFERENCES devices(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_ports_device ON ports(device_id);
|
||||
"""
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db_file = Path(DB_PATH)
|
||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(DB_PATH) as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
99
app/main.py
Normal file
99
app/main.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import DEFAULT_SUBNET
|
||||
from .db import init_db
|
||||
from .scanner import discover_hosts, scan_host
|
||||
from .service import (
|
||||
complete_scan,
|
||||
create_scan,
|
||||
fetch_device,
|
||||
fetch_devices,
|
||||
fetch_scans,
|
||||
mark_missing_devices,
|
||||
scan_state,
|
||||
upsert_host,
|
||||
)
|
||||
|
||||
app = FastAPI(title="NetTrak")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def home() -> FileResponse:
|
||||
return FileResponse("app/static/index.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"scan_running": scan_state.running,
|
||||
"current_scan_id": scan_state.current_scan_id,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/devices")
|
||||
def api_devices() -> list[dict]:
|
||||
return fetch_devices()
|
||||
|
||||
|
||||
@app.get("/api/devices/{device_id}")
|
||||
def api_device(device_id: int) -> dict:
|
||||
device = fetch_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return device
|
||||
|
||||
|
||||
@app.get("/api/scans")
|
||||
def api_scans(limit: int = 20) -> list[dict]:
|
||||
return fetch_scans(limit=limit)
|
||||
|
||||
|
||||
@app.post("/api/scans/run")
|
||||
def run_scan(subnet: str | None = None) -> dict[str, Any]:
|
||||
subnet = subnet or DEFAULT_SUBNET
|
||||
scan_id = create_scan(subnet)
|
||||
if not scan_state.start(scan_id):
|
||||
complete_scan(scan_id, "cancelled", 0, notes="Another scan was already running")
|
||||
raise HTTPException(status_code=409, detail="Scan already running")
|
||||
|
||||
def worker() -> None:
|
||||
host_count = 0
|
||||
try:
|
||||
discovered = discover_hosts(subnet)
|
||||
for host in discovered:
|
||||
detailed = scan_host(host["ip"])
|
||||
if not detailed:
|
||||
continue
|
||||
host_count += 1
|
||||
if not detailed.hostname and host.get("hostname"):
|
||||
detailed.hostname = host["hostname"]
|
||||
if not detailed.mac and host.get("mac"):
|
||||
detailed.mac = host["mac"]
|
||||
if not detailed.vendor and host.get("vendor"):
|
||||
detailed.vendor = host["vendor"]
|
||||
|
||||
upsert_host(scan_id, detailed)
|
||||
|
||||
mark_missing_devices(scan_id)
|
||||
complete_scan(scan_id, "completed", host_count)
|
||||
except Exception as exc:
|
||||
complete_scan(scan_id, "failed", host_count, notes=str(exc))
|
||||
finally:
|
||||
scan_state.finish()
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
return {"status": "started", "scan_id": scan_id, "subnet": subnet}
|
||||
210
app/scanner.py
Normal file
210
app/scanner.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
HTTP_PORTS = {80, 81, 443, 8000, 8080, 8081, 8443, 8888}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortResult:
|
||||
port: int
|
||||
protocol: str
|
||||
state: str
|
||||
service: str | None = None
|
||||
product: str | None = None
|
||||
version: str | None = None
|
||||
extra_info: str | None = None
|
||||
banner: str | None = None
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostResult:
|
||||
ip: str
|
||||
hostname: str | None = None
|
||||
mac: str | None = None
|
||||
vendor: str | None = None
|
||||
os_name: str | None = None
|
||||
ports: list[PortResult] = field(default_factory=list)
|
||||
|
||||
|
||||
def run_nmap(args: list[str]) -> ET.Element:
|
||||
proc = subprocess.run(
|
||||
["nmap", *args, "-oX", "-"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"nmap failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
||||
return ET.fromstring(proc.stdout)
|
||||
|
||||
|
||||
def discover_hosts(subnet: str) -> list[dict[str, Any]]:
|
||||
root = run_nmap(["-sn", subnet])
|
||||
hosts: list[dict[str, Any]] = []
|
||||
for host in root.findall("host"):
|
||||
status = host.find("status")
|
||||
if status is None or status.attrib.get("state") != "up":
|
||||
continue
|
||||
ip = None
|
||||
mac = None
|
||||
vendor = None
|
||||
for addr in host.findall("address"):
|
||||
addr_type = addr.attrib.get("addrtype")
|
||||
if addr_type == "ipv4":
|
||||
ip = addr.attrib.get("addr")
|
||||
elif addr_type == "mac":
|
||||
mac = addr.attrib.get("addr")
|
||||
vendor = addr.attrib.get("vendor")
|
||||
hostname = None
|
||||
names = host.find("hostnames")
|
||||
if names is not None:
|
||||
first = names.find("hostname")
|
||||
if first is not None:
|
||||
hostname = first.attrib.get("name")
|
||||
if ip:
|
||||
hosts.append({"ip": ip, "hostname": hostname, "mac": mac, "vendor": vendor})
|
||||
return hosts
|
||||
|
||||
|
||||
def parse_detailed_host(xml_root: ET.Element) -> HostResult | None:
|
||||
host = xml_root.find("host")
|
||||
if host is None:
|
||||
return None
|
||||
|
||||
status = host.find("status")
|
||||
if status is None or status.attrib.get("state") != "up":
|
||||
return None
|
||||
|
||||
ip = None
|
||||
mac = None
|
||||
vendor = None
|
||||
|
||||
for addr in host.findall("address"):
|
||||
addr_type = addr.attrib.get("addrtype")
|
||||
if addr_type == "ipv4":
|
||||
ip = addr.attrib.get("addr")
|
||||
elif addr_type == "mac":
|
||||
mac = addr.attrib.get("addr")
|
||||
vendor = addr.attrib.get("vendor")
|
||||
|
||||
if not ip:
|
||||
return None
|
||||
|
||||
hostname = None
|
||||
names = host.find("hostnames")
|
||||
if names is not None:
|
||||
first = names.find("hostname")
|
||||
if first is not None:
|
||||
hostname = first.attrib.get("name")
|
||||
|
||||
os_name = None
|
||||
os_match = host.find("os/osmatch")
|
||||
if os_match is not None:
|
||||
os_name = os_match.attrib.get("name")
|
||||
|
||||
result = HostResult(ip=ip, hostname=hostname, mac=mac, vendor=vendor, os_name=os_name)
|
||||
|
||||
for port in host.findall("ports/port"):
|
||||
protocol = port.attrib.get("protocol", "tcp")
|
||||
portid = int(port.attrib.get("portid", "0"))
|
||||
state_node = port.find("state")
|
||||
state = state_node.attrib.get("state", "unknown") if state_node is not None else "unknown"
|
||||
if state != "open":
|
||||
continue
|
||||
|
||||
service_node = port.find("service")
|
||||
service = service_node.attrib.get("name") if service_node is not None else None
|
||||
product = service_node.attrib.get("product") if service_node is not None else None
|
||||
version = service_node.attrib.get("version") if service_node is not None else None
|
||||
extrainfo = service_node.attrib.get("extrainfo") if service_node is not None else None
|
||||
|
||||
p = PortResult(
|
||||
port=portid,
|
||||
protocol=protocol,
|
||||
state=state,
|
||||
service=service,
|
||||
product=product,
|
||||
version=version,
|
||||
extra_info=extrainfo,
|
||||
)
|
||||
|
||||
if protocol == "tcp":
|
||||
banner, headers = probe_port(ip, portid)
|
||||
p.banner = banner
|
||||
p.headers = headers
|
||||
|
||||
result.ports.append(p)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def probe_port(ip: str, port: int, timeout: float = 1.5) -> tuple[str | None, dict[str, str]]:
|
||||
if port not in HTTP_PORTS:
|
||||
return grab_banner(ip, port, timeout), {}
|
||||
|
||||
try:
|
||||
if port in {443, 8443}:
|
||||
context = ssl.create_default_context()
|
||||
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||
with context.wrap_socket(sock, server_hostname=ip) as tls_sock:
|
||||
return http_probe(tls_sock, ip)
|
||||
|
||||
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||
return http_probe(sock, ip)
|
||||
except Exception:
|
||||
return grab_banner(ip, port, timeout), {}
|
||||
|
||||
|
||||
def http_probe(sock: socket.socket, host: str) -> tuple[str | None, dict[str, str]]:
|
||||
request = (
|
||||
f"HEAD / HTTP/1.1\r\n"
|
||||
f"Host: {host}\r\n"
|
||||
"User-Agent: NetTrak/1.0\r\n"
|
||||
"Connection: close\r\n\r\n"
|
||||
)
|
||||
sock.sendall(request.encode("ascii", errors="ignore"))
|
||||
data = sock.recv(4096)
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||
|
||||
headers: dict[str, str] = {}
|
||||
status = lines[0] if lines else None
|
||||
for line in lines[1:]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
headers[key.strip()] = value.strip()
|
||||
|
||||
return status, headers
|
||||
|
||||
|
||||
def grab_banner(ip: str, port: int, timeout: float) -> str | None:
|
||||
try:
|
||||
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||
sock.settimeout(timeout)
|
||||
data = sock.recv(1024)
|
||||
if not data:
|
||||
return None
|
||||
return data.decode("utf-8", errors="ignore").strip()[:300]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def scan_host(ip: str) -> HostResult | None:
|
||||
# `-O` may fail without extra privileges, so we gracefully retry without it.
|
||||
base_args = ["-Pn", "-sV", "--top-ports", "200", ip]
|
||||
try:
|
||||
root = run_nmap([*base_args, "-O", "--osscan-guess"])
|
||||
return parse_detailed_host(root)
|
||||
except Exception:
|
||||
root = run_nmap(base_args)
|
||||
return parse_detailed_host(root)
|
||||
|
||||
241
app/service.py
Normal file
241
app/service.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .db import get_conn
|
||||
from .scanner import HostResult
|
||||
|
||||
|
||||
class ScanState:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self.running = False
|
||||
self.current_scan_id: int | None = None
|
||||
|
||||
def start(self, scan_id: int) -> bool:
|
||||
with self._lock:
|
||||
if self.running:
|
||||
return False
|
||||
self.running = True
|
||||
self.current_scan_id = scan_id
|
||||
return True
|
||||
|
||||
def finish(self) -> None:
|
||||
with self._lock:
|
||||
self.running = False
|
||||
self.current_scan_id = None
|
||||
|
||||
|
||||
scan_state = ScanState()
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def create_scan(subnet: str) -> int:
|
||||
started = now_iso()
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO scans(subnet, started_at, status) VALUES (?, ?, ?)",
|
||||
(subnet, started, "running"),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def complete_scan(scan_id: int, status: str, host_count: int, notes: str | None = None) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE scans
|
||||
SET completed_at = ?, status = ?, host_count = ?, notes = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), status, host_count, notes, scan_id),
|
||||
)
|
||||
|
||||
|
||||
def upsert_host(scan_id: int, host: HostResult) -> int:
|
||||
timestamp = now_iso()
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM devices WHERE ip = ?", (host.ip,)).fetchone()
|
||||
if row:
|
||||
device_id = int(row["id"])
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET hostname = ?, mac = ?, vendor = ?, os_name = ?,
|
||||
last_seen = ?, is_active = 1, last_scan_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
host.hostname,
|
||||
host.mac,
|
||||
host.vendor,
|
||||
host.os_name,
|
||||
timestamp,
|
||||
scan_id,
|
||||
device_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO devices(ip, hostname, mac, vendor, os_name, first_seen, last_seen, is_active, last_scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||
""",
|
||||
(
|
||||
host.ip,
|
||||
host.hostname,
|
||||
host.mac,
|
||||
host.vendor,
|
||||
host.os_name,
|
||||
timestamp,
|
||||
timestamp,
|
||||
scan_id,
|
||||
),
|
||||
)
|
||||
device_id = int(cur.lastrowid)
|
||||
|
||||
seen_ports = {(p.port, p.protocol) for p in host.ports}
|
||||
|
||||
existing_ports = conn.execute(
|
||||
"SELECT id, port, protocol FROM ports WHERE device_id = ?",
|
||||
(device_id,),
|
||||
).fetchall()
|
||||
|
||||
for p in host.ports:
|
||||
headers_json = json.dumps(p.headers)
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM ports WHERE device_id = ? AND port = ? AND protocol = ?",
|
||||
(device_id, p.port, p.protocol),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE ports
|
||||
SET state = ?, service = ?, product = ?, version = ?, extra_info = ?, banner = ?,
|
||||
headers_json = ?, last_seen = ?, is_open = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
p.state,
|
||||
p.service,
|
||||
p.product,
|
||||
p.version,
|
||||
p.extra_info,
|
||||
p.banner,
|
||||
headers_json,
|
||||
timestamp,
|
||||
int(existing["id"]),
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ports(device_id, port, protocol, state, service, product, version, extra_info, banner,
|
||||
headers_json, first_seen, last_seen, is_open)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
(
|
||||
device_id,
|
||||
p.port,
|
||||
p.protocol,
|
||||
p.state,
|
||||
p.service,
|
||||
p.product,
|
||||
p.version,
|
||||
p.extra_info,
|
||||
p.banner,
|
||||
headers_json,
|
||||
timestamp,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
|
||||
for rowp in existing_ports:
|
||||
pair = (int(rowp["port"]), rowp["protocol"])
|
||||
if pair not in seen_ports:
|
||||
conn.execute(
|
||||
"UPDATE ports SET is_open = 0, state = 'closed', last_seen = ? WHERE id = ?",
|
||||
(timestamp, int(rowp["id"])),
|
||||
)
|
||||
|
||||
return device_id
|
||||
|
||||
|
||||
def mark_missing_devices(scan_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE devices
|
||||
SET is_active = 0
|
||||
WHERE last_scan_id IS NOT NULL
|
||||
AND last_scan_id <> ?
|
||||
""",
|
||||
(scan_id,),
|
||||
)
|
||||
|
||||
|
||||
def fetch_devices() -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
|
||||
FROM devices
|
||||
ORDER BY is_active DESC, ip ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def fetch_device(device_id: int) -> dict | None:
|
||||
with get_conn() as conn:
|
||||
device = conn.execute(
|
||||
"""
|
||||
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
|
||||
FROM devices
|
||||
WHERE id = ?
|
||||
""",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
if not device:
|
||||
return None
|
||||
|
||||
ports = conn.execute(
|
||||
"""
|
||||
SELECT id, port, protocol, state, service, product, version, extra_info, banner, headers_json,
|
||||
first_seen, last_seen, is_open
|
||||
FROM ports
|
||||
WHERE device_id = ?
|
||||
ORDER BY is_open DESC, port ASC
|
||||
""",
|
||||
(device_id,),
|
||||
).fetchall()
|
||||
|
||||
result = dict(device)
|
||||
parsed_ports = []
|
||||
for row in ports:
|
||||
p = dict(row)
|
||||
p["headers"] = json.loads(p["headers_json"] or "{}")
|
||||
p.pop("headers_json", None)
|
||||
parsed_ports.append(p)
|
||||
|
||||
result["ports"] = parsed_ports
|
||||
return result
|
||||
|
||||
|
||||
def fetch_scans(limit: int = 20) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, subnet, started_at, completed_at, status, host_count, notes
|
||||
FROM scans
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
177
app/static/app.js
Normal file
177
app/static/app.js
Normal file
@@ -0,0 +1,177 @@
|
||||
const deviceListEl = document.getElementById('deviceList');
|
||||
const machineInfoEl = document.getElementById('machineInfo');
|
||||
const portsListEl = document.getElementById('portsList');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const scanBtn = document.getElementById('scanBtn');
|
||||
const subnetInput = document.getElementById('subnetInput');
|
||||
|
||||
let devices = [];
|
||||
let selectedDeviceId = null;
|
||||
|
||||
function setStatus(msg) {
|
||||
statusText.textContent = msg;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(path, options);
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data.detail) detail = data.detail;
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function deviceTitle(d) {
|
||||
return d.hostname ? `${d.hostname} (${d.ip})` : d.ip;
|
||||
}
|
||||
|
||||
function renderDeviceList() {
|
||||
deviceListEl.innerHTML = '';
|
||||
|
||||
if (!devices.length) {
|
||||
deviceListEl.innerHTML = '<li class="device-item">No devices discovered yet.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
devices.forEach((d) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
|
||||
li.innerHTML = `
|
||||
<div>${deviceTitle(d)}</div>
|
||||
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div>
|
||||
`;
|
||||
li.addEventListener('click', () => {
|
||||
selectedDeviceId = d.id;
|
||||
renderDeviceList();
|
||||
loadDevice(d.id);
|
||||
});
|
||||
deviceListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMachineInfo(d) {
|
||||
machineInfoEl.classList.remove('empty');
|
||||
machineInfoEl.innerHTML = `
|
||||
<div class="info-grid">
|
||||
<div class="info-card"><div class="label">Hostname</div><div class="value">${d.hostname || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">IP Address</div><div class="value">${d.ip}</div></div>
|
||||
<div class="info-card"><div class="label">MAC Address</div><div class="value">${d.mac || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Vendor</div><div class="value">${d.vendor || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Operating System</div><div class="value">${d.os_name || '-'}</div></div>
|
||||
<div class="info-card"><div class="label">Status</div><div class="value">${d.is_active ? 'Active' : 'Not Seen in Last Scan'}</div></div>
|
||||
<div class="info-card"><div class="label">First Seen</div><div class="value">${formatDate(d.first_seen)}</div></div>
|
||||
<div class="info-card"><div class="label">Last Seen</div><div class="value">${formatDate(d.last_seen)}</div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPorts(ports) {
|
||||
portsListEl.innerHTML = '';
|
||||
portsListEl.classList.remove('empty');
|
||||
|
||||
if (!ports.length) {
|
||||
portsListEl.classList.add('empty');
|
||||
portsListEl.textContent = 'No ports recorded for this machine.';
|
||||
return;
|
||||
}
|
||||
|
||||
ports.forEach((p) => {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'port';
|
||||
const svc = [p.service, p.product, p.version].filter(Boolean).join(' ');
|
||||
const headers = Object.entries(p.headers || {})
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n') || 'No headers captured';
|
||||
|
||||
details.innerHTML = `
|
||||
<summary>${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''}</summary>
|
||||
<div class="port-body">
|
||||
Service: ${svc || 'Unknown'}
|
||||
Extra: ${p.extra_info || '-'}
|
||||
Banner: ${p.banner || '-'}
|
||||
First Seen: ${formatDate(p.first_seen)}
|
||||
Last Seen: ${formatDate(p.last_seen)}
|
||||
Headers:\n${headers}
|
||||
</div>
|
||||
`;
|
||||
portsListEl.appendChild(details);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
devices = await api('/api/devices');
|
||||
renderDeviceList();
|
||||
|
||||
if (!selectedDeviceId && devices.length) {
|
||||
selectedDeviceId = devices[0].id;
|
||||
renderDeviceList();
|
||||
}
|
||||
|
||||
if (selectedDeviceId) {
|
||||
await loadDevice(selectedDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevice(deviceId) {
|
||||
const d = await api(`/api/devices/${deviceId}`);
|
||||
renderMachineInfo(d);
|
||||
renderPorts(d.ports || []);
|
||||
}
|
||||
|
||||
async function runScan() {
|
||||
const subnet = subnetInput.value.trim();
|
||||
if (!subnet) return;
|
||||
|
||||
scanBtn.disabled = true;
|
||||
setStatus(`Starting scan on ${subnet}...`);
|
||||
|
||||
try {
|
||||
const result = await api(`/api/scans/run?subnet=${encodeURIComponent(subnet)}`, { method: 'POST' });
|
||||
setStatus(`Scan #${result.scan_id} running on ${result.subnet}. Refreshing automatically...`);
|
||||
await pollUntilComplete(result.scan_id);
|
||||
} catch (err) {
|
||||
setStatus(`Scan failed to start: ${err.message}`);
|
||||
} finally {
|
||||
scanBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollUntilComplete(scanId) {
|
||||
for (let i = 0; i < 240; i += 1) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const scans = await api('/api/scans?limit=1');
|
||||
const latest = scans[0];
|
||||
if (!latest || latest.id !== scanId) continue;
|
||||
|
||||
if (latest.status === 'running') {
|
||||
setStatus(`Scan #${scanId} is running...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
setStatus(`Scan #${scanId} ${latest.status} (${latest.host_count} hosts).`);
|
||||
await loadDevices();
|
||||
return;
|
||||
}
|
||||
setStatus(`Scan #${scanId} is taking longer than expected.`);
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', runScan);
|
||||
|
||||
(async function init() {
|
||||
setStatus('Loading inventory...');
|
||||
try {
|
||||
await loadDevices();
|
||||
setStatus('Ready.');
|
||||
} catch (err) {
|
||||
setStatus(`Failed loading data: ${err.message}`);
|
||||
}
|
||||
})();
|
||||
48
app/static/index.html
Normal file
48
app/static/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NetTrak</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="title-wrap">
|
||||
<h1>NetTrak</h1>
|
||||
<p>Network inventory and port intelligence</p>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="subnetInput" type="text" value="192.168.2.0/24" aria-label="Subnet" />
|
||||
<button id="scanBtn">Run Scan</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="pane left-pane">
|
||||
<div class="pane-head">
|
||||
<h2>Discovered Machines</h2>
|
||||
</div>
|
||||
<ul id="deviceList" class="device-list"></ul>
|
||||
</aside>
|
||||
|
||||
<section class="right-stack">
|
||||
<section class="pane detail-pane">
|
||||
<div class="pane-head"><h2>Machine Info</h2></div>
|
||||
<div id="machineInfo" class="machine-info empty">Select a machine from the left pane.</div>
|
||||
</section>
|
||||
|
||||
<section class="pane ports-pane">
|
||||
<div class="pane-head"><h2>Open Ports</h2></div>
|
||||
<div id="portsList" class="ports-list empty">No machine selected.</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="statusbar">
|
||||
<span id="statusText">Ready.</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
214
app/static/styles.css
Normal file
214
app/static/styles.css
Normal file
@@ -0,0 +1,214 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a1118;
|
||||
--panel: #111b24;
|
||||
--panel-2: #182531;
|
||||
--text: #d9e5ef;
|
||||
--muted: #8fa4b6;
|
||||
--accent: #5dc4ff;
|
||||
--accent-2: #2f9bd5;
|
||||
--ok: #6fdd8b;
|
||||
--warn: #f2b84d;
|
||||
--danger: #f67a7a;
|
||||
--border: #253648;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 90% 0%, #193149 0%, transparent 40%),
|
||||
radial-gradient(circle at 10% 100%, #0f2a3a 0%, transparent 38%),
|
||||
var(--bg);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(7, 13, 19, 0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.title-wrap h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.title-wrap p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input, button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||
border: none;
|
||||
color: #03101a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 128px);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 300px 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(160deg, var(--panel), var(--panel-2));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane-head {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-head h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.left-pane { overflow: hidden; }
|
||||
|
||||
.device-list {
|
||||
list-style: none;
|
||||
padding: 6px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-item:hover { border-color: #33516b; }
|
||||
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
|
||||
|
||||
.device-item .meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.right-stack {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.machine-info, .ports-list {
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.info-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 5px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
details.port {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.port-body {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty { color: var(--muted); }
|
||||
|
||||
.statusbar {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 250px 1fr;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 128px);
|
||||
}
|
||||
|
||||
.right-stack {
|
||||
grid-template-rows: 1fr 1fr;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.controls { flex-direction: column; align-items: stretch; }
|
||||
input { min-width: 0; }
|
||||
}
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
nettrak:
|
||||
build: .
|
||||
container_name: nettrak
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- NETTRAK_DB_PATH=/data/nettrak.db
|
||||
- NETTRAK_SUBNET=192.168.2.0/24
|
||||
# For best host discovery on Linux, uncomment host networking:
|
||||
# network_mode: host
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
Reference in New Issue
Block a user