Initial commit

This commit is contained in:
Keith Solomon
2026-03-08 15:06:50 -05:00
commit 9fb58a9677
16 changed files with 1194 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
*.pyd
.env
.git
.gitignore
data/

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__/
*.pyc
.env
data/

16
.vscode/settings.json vendored Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

5
app/config.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0