Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a424b929 | |||
| b0aa6ff9a8 | |||
| d246267c9d |
@@ -1,6 +1,7 @@
|
|||||||
# NetTrak
|
# NetTrak
|
||||||
|
|
||||||
NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs:
|
NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs:
|
||||||
|
|
||||||
- Devices discovered on the network
|
- Devices discovered on the network
|
||||||
- Open ports per device
|
- Open ports per device
|
||||||
- Service fingerprint details from `nmap`
|
- Service fingerprint details from `nmap`
|
||||||
@@ -29,17 +30,28 @@ Results are persisted in SQLite for change tracking (new/updated/missing devices
|
|||||||
|
|
||||||
## Run With Docker Compose
|
## Run With Docker Compose
|
||||||
|
|
||||||
|
Use the provided `docker-compose.yml` to run the app:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open: `http://localhost:1337`
|
Then open: `http://localhost:1337`
|
||||||
|
|
||||||
Database file is stored at `./data/nettrak.db` via a bind mount.
|
Database file is stored at `./data/nettrak.db` via a bind mount.
|
||||||
|
|
||||||
|
## For a fresh build (for example to change the internal port)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.keithsolomon.net/keith/NetTrak
|
||||||
|
cd NetTrak
|
||||||
|
docker compose -f docker-compose-build.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
|
|
||||||
- `NETTRAK_DB_PATH` (default: `/data/nettrak.db`)
|
- `NETTRAK_DB_PATH` (default: `/data/nettrak.db`)
|
||||||
- `NETTRAK_SUBNET` (default: `192.168.2.0/24`)
|
- `NETTRAK_SUBNET` (default: `192.168.2.0/24`)
|
||||||
- `NETTRAK_TOP_PORTS` (default: `100`)
|
- `NETTRAK_TOP_PORTS` (default: `100`)
|
||||||
@@ -69,6 +81,7 @@ network_mode: host
|
|||||||
NetTrak can optionally annotate host ports that are published by Docker containers on the scan host.
|
NetTrak can optionally annotate host ports that are published by Docker containers on the scan host.
|
||||||
|
|
||||||
To enable:
|
To enable:
|
||||||
|
|
||||||
- set `NETTRAK_ENABLE_DOCKER_INSIGHTS=1`
|
- set `NETTRAK_ENABLE_DOCKER_INSIGHTS=1`
|
||||||
- mount the Docker socket:
|
- mount the Docker socket:
|
||||||
|
|
||||||
|
|||||||
+79
-19
@@ -14,6 +14,8 @@ let devices = [];
|
|||||||
let selectedDeviceId = null;
|
let selectedDeviceId = null;
|
||||||
let selectedDevice = null;
|
let selectedDevice = null;
|
||||||
let saveInFlight = false;
|
let saveInFlight = false;
|
||||||
|
let activeSectionOpen = true;
|
||||||
|
let inactiveSectionOpen = false;
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
statusText.textContent = msg;
|
statusText.textContent = msg;
|
||||||
@@ -105,6 +107,21 @@ function compareIpNumeric(a, b) {
|
|||||||
return String(a.ip || '').localeCompare(String(b.ip || ''));
|
return String(a.ip || '').localeCompare(String(b.ip || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareDevices(a, b) {
|
||||||
|
const aName = String(a.hostname || '').trim().toLocaleLowerCase();
|
||||||
|
const bName = String(b.hostname || '').trim().toLocaleLowerCase();
|
||||||
|
|
||||||
|
if (aName && bName) {
|
||||||
|
const byName = aName.localeCompare(bName);
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return compareIpNumeric(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aName) return -1;
|
||||||
|
if (bName) return 1;
|
||||||
|
return compareIpNumeric(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
function renderDeviceList() {
|
function renderDeviceList() {
|
||||||
deviceListEl.innerHTML = '';
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -113,20 +130,69 @@ function renderDeviceList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
devices.forEach((d) => {
|
const activeDevices = devices.filter((device) => device.is_active);
|
||||||
const li = document.createElement('li');
|
const inactiveDevices = devices.filter((device) => !device.is_active);
|
||||||
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
|
|
||||||
li.innerHTML = `
|
deviceListEl.appendChild(createSection('Active Machines', activeDevices, activeSectionOpen, true));
|
||||||
<div>${deviceTitle(d)}</div>
|
deviceListEl.appendChild(createSection('Inactive Machines', inactiveDevices, inactiveSectionOpen, false));
|
||||||
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div>
|
attachDeviceListHandlers();
|
||||||
`;
|
}
|
||||||
li.addEventListener('click', () => {
|
|
||||||
selectedDeviceId = d.id;
|
function createSection(title, sectionDevices, isOpen, isActiveSection) {
|
||||||
|
const sectionItem = document.createElement('li');
|
||||||
|
sectionItem.className = 'device-section-item';
|
||||||
|
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'device-section';
|
||||||
|
details.dataset.section = isActiveSection ? 'active' : 'inactive';
|
||||||
|
details.open = isOpen;
|
||||||
|
|
||||||
|
const itemsHtml = sectionDevices.length
|
||||||
|
? sectionDevices.map((device) => `
|
||||||
|
<li class="device-item ${selectedDeviceId === device.id ? 'active' : ''}" data-device-id="${device.id}">
|
||||||
|
<div>${escapeHtml(deviceTitle(device))}</div>
|
||||||
|
<div class="meta">${escapeHtml(device.os_name || 'OS unknown')} | ${device.is_active ? 'Active' : 'Missing'}</div>
|
||||||
|
</li>
|
||||||
|
`).join('')
|
||||||
|
: '<li class="device-empty">No machines in this section.</li>';
|
||||||
|
|
||||||
|
details.innerHTML = `
|
||||||
|
<summary class="device-section-summary">
|
||||||
|
<span>${escapeHtml(title)}</span>
|
||||||
|
<span class="device-section-count">${sectionDevices.length}</span>
|
||||||
|
</summary>
|
||||||
|
<ul class="device-section-list">${itemsHtml}</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
sectionItem.appendChild(details);
|
||||||
|
return sectionItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachDeviceListHandlers() {
|
||||||
|
deviceListEl.querySelectorAll('.device-item[data-device-id]').forEach((item) => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const deviceId = Number(item.dataset.deviceId);
|
||||||
|
if (!deviceId) return;
|
||||||
|
selectedDeviceId = deviceId;
|
||||||
renderDeviceList();
|
renderDeviceList();
|
||||||
loadDevice(d.id);
|
loadDevice(deviceId);
|
||||||
});
|
});
|
||||||
deviceListEl.appendChild(li);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeSection = deviceListEl.querySelector('.device-section[data-section="active"]');
|
||||||
|
const inactiveSection = deviceListEl.querySelector('.device-section[data-section="inactive"]');
|
||||||
|
|
||||||
|
if (activeSection) {
|
||||||
|
activeSection.addEventListener('toggle', () => {
|
||||||
|
activeSectionOpen = activeSection.open;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactiveSection) {
|
||||||
|
inactiveSection.addEventListener('toggle', () => {
|
||||||
|
inactiveSectionOpen = inactiveSection.open;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMachineInfo(d) {
|
function renderMachineInfo(d) {
|
||||||
@@ -201,10 +267,7 @@ Headers:\n${headers}
|
|||||||
|
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
devices = await api('/api/devices');
|
devices = await api('/api/devices');
|
||||||
devices.sort((a, b) => {
|
devices.sort(compareDevices);
|
||||||
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
|
|
||||||
return compareIpNumeric(a, b);
|
|
||||||
});
|
|
||||||
renderDeviceList();
|
renderDeviceList();
|
||||||
|
|
||||||
if (!selectedDeviceId && devices.length) {
|
if (!selectedDeviceId && devices.length) {
|
||||||
@@ -277,10 +340,7 @@ function resetDeviceEdits() {
|
|||||||
|
|
||||||
function updateDeviceInList(updatedDevice) {
|
function updateDeviceInList(updatedDevice) {
|
||||||
devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device));
|
devices = devices.map((device) => (device.id === updatedDevice.id ? { ...device, ...updatedDevice } : device));
|
||||||
devices.sort((a, b) => {
|
devices.sort(compareDevices);
|
||||||
if (a.is_active !== b.is_active) return b.is_active - a.is_active;
|
|
||||||
return compareIpNumeric(a, b);
|
|
||||||
});
|
|
||||||
renderDeviceList();
|
renderDeviceList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,51 @@ button:disabled {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-section-item {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,0.015);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-count {
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 6px 6px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.device-item {
|
.device-item {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -173,6 +218,16 @@ button:disabled {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-section-list .device-item:first-child {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-empty {
|
||||||
|
padding: 12px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
.device-item:hover { border-color: #33516b; }
|
.device-item:hover { border-color: #33516b; }
|
||||||
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
|
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
codex resume 019ccf04-af34-7883-a705-2802dd142306
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
nettrak:
|
||||||
|
container_name: NetTrak
|
||||||
|
restart: unless-stopped
|
||||||
|
build: .
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- NETTRAK_DB_PATH=/data/nettrak.db
|
||||||
|
# Set this to your local subnet. Example for common home network:
|
||||||
|
- NETTRAK_SUBNET=192.168.2.0/24
|
||||||
|
- NETTRAK_TOP_PORTS=100
|
||||||
|
# Optional explicit port set/range. Example catches most app ports:
|
||||||
|
- NETTRAK_PORT_SPEC=1-10000
|
||||||
|
- NETTRAK_SCAN_WORKERS=12
|
||||||
|
- NETTRAK_PORT_PROBE_TIMEOUT=0.4
|
||||||
|
- NETTRAK_ENABLE_OS_DETECTION=0
|
||||||
|
- NETTRAK_ENABLE_DOCKER_INSIGHTS=1
|
||||||
|
# Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred.
|
||||||
|
- NETTRAK_DOCKER_HOST_IP=192.168.2.23
|
||||||
|
|
||||||
|
# For best host discovery on Linux, use host mode.
|
||||||
|
# If you do that, remove the `ports` section and ensure port 1337 is free.
|
||||||
|
# NOTE: If you want/need to change the port, you have to rebuild the image to update the EXPOSE instruction (see docker-compose-build.yml).
|
||||||
|
# network_mode: host
|
||||||
|
|
||||||
|
# Helps nmap discover MAC addresses/OS details in containerized runs.
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "1337:1337"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
# Optional: mount Docker socket to include container-published host ports in results.
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
+13
-10
@@ -1,35 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
nettrak:
|
nettrak:
|
||||||
container_name: NetTrak
|
container_name: NetTrak
|
||||||
build: .
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
image: git.keithsolomon.net/keith/nettrak:latest
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- NETTRAK_DB_PATH=/data/nettrak.db
|
- NETTRAK_DB_PATH=/data/nettrak.db
|
||||||
|
# Set this to your local subnet. Example for common home network:
|
||||||
- NETTRAK_SUBNET=192.168.2.0/24
|
- NETTRAK_SUBNET=192.168.2.0/24
|
||||||
- NETTRAK_TOP_PORTS=100
|
- NETTRAK_TOP_PORTS=100
|
||||||
# Optional explicit port set/range. Example catches 8989 and many app ports:
|
# Optional explicit port set/range. Example catches most app ports:
|
||||||
# - NETTRAK_PORT_SPEC=1-10000
|
- NETTRAK_PORT_SPEC=1-10000
|
||||||
- NETTRAK_SCAN_WORKERS=12
|
- NETTRAK_SCAN_WORKERS=12
|
||||||
- NETTRAK_PORT_PROBE_TIMEOUT=0.4
|
- NETTRAK_PORT_PROBE_TIMEOUT=0.4
|
||||||
- NETTRAK_ENABLE_OS_DETECTION=0
|
- NETTRAK_ENABLE_OS_DETECTION=0
|
||||||
- NETTRAK_ENABLE_DOCKER_INSIGHTS=0
|
- NETTRAK_ENABLE_DOCKER_INSIGHTS=1
|
||||||
# Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred.
|
# Set this if Docker published ports are bound to 0.0.0.0 and host IP cannot be inferred.
|
||||||
# - NETTRAK_DOCKER_HOST_IP=192.168.2.10
|
- NETTRAK_DOCKER_HOST_IP=192.168.2.23
|
||||||
|
|
||||||
# For best host discovery on Linux, you can switch to host mode.
|
# For best host discovery on Linux, use host mode.
|
||||||
# If you do that, remove the `ports` section and ensure APP_PORT is free.
|
# If you do that, remove the `ports` section and ensure port 1337 is free.
|
||||||
# network_mode: host
|
# NOTE: If you want/need to change the port, you have to rebuild the image to update the EXPOSE instruction (see docker-compose-build.yml).
|
||||||
|
#network_mode: host
|
||||||
|
|
||||||
# Helps nmap discover MAC addresses/OS details in containerized runs.
|
# Helps nmap discover MAC addresses/OS details in containerized runs.
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
|
||||||
ports:
|
# ports:
|
||||||
- "1337:1337"
|
- "1337:1337"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Store the database on the host for persistence across container restarts and easy access.
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
# Optional: mount Docker socket to include container-published host ports in results.
|
# Optional: mount Docker socket to include container-published host ports in results.
|
||||||
# - /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|||||||
Reference in New Issue
Block a user