commit 9fb58a9677dd702fb896f035426db22fad14811c Author: Keith Solomon Date: Sun Mar 8 15:06:50 2026 -0500 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5785c00 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.git +.gitignore +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d51f033 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.env +data/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0867865 --- /dev/null +++ b/.vscode/settings.json @@ -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" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7252eb9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9c8e6e --- /dev/null +++ b/README.md @@ -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` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5f59b4e --- /dev/null +++ b/app/config.py @@ -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")) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..533a9df --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..68da1bc --- /dev/null +++ b/app/main.py @@ -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} diff --git a/app/scanner.py b/app/scanner.py new file mode 100644 index 0000000..d274957 --- /dev/null +++ b/app/scanner.py @@ -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) + diff --git a/app/service.py b/app/service.py new file mode 100644 index 0000000..2c7d6a6 --- /dev/null +++ b/app/service.py @@ -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] diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..4ce9b70 --- /dev/null +++ b/app/static/app.js @@ -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 = '
  • No devices discovered yet.
  • '; + return; + } + + devices.forEach((d) => { + const li = document.createElement('li'); + li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`; + li.innerHTML = ` +
    ${deviceTitle(d)}
    +
    ${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}
    + `; + li.addEventListener('click', () => { + selectedDeviceId = d.id; + renderDeviceList(); + loadDevice(d.id); + }); + deviceListEl.appendChild(li); + }); +} + +function renderMachineInfo(d) { + machineInfoEl.classList.remove('empty'); + machineInfoEl.innerHTML = ` +
    +
    Hostname
    ${d.hostname || '-'}
    +
    IP Address
    ${d.ip}
    +
    MAC Address
    ${d.mac || '-'}
    +
    Vendor
    ${d.vendor || '-'}
    +
    Operating System
    ${d.os_name || '-'}
    +
    Status
    ${d.is_active ? 'Active' : 'Not Seen in Last Scan'}
    +
    First Seen
    ${formatDate(d.first_seen)}
    +
    Last Seen
    ${formatDate(d.last_seen)}
    +
    + `; +} + +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 = ` + ${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''} +
    +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} +
    + `; + 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}`); + } +})(); diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..0fb7725 --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,48 @@ + + + + + + NetTrak + + + +
    +
    +

    NetTrak

    +

    Network inventory and port intelligence

    +
    +
    + + +
    +
    + +
    + + +
    +
    +

    Machine Info

    +
    Select a machine from the left pane.
    +
    + +
    +

    Open Ports

    +
    No machine selected.
    +
    +
    +
    + + + + + + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..b07325d --- /dev/null +++ b/app/static/styles.css @@ -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; } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fcf566b --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..babedd0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0