v1.1: Add CIDR subnet filtering and Agent filter in Devices page
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# local databases & temporary files
|
||||||
|
temp.json
|
||||||
|
/scratch
|
||||||
|
backend/*.db
|
||||||
|
backend/*.db-shm
|
||||||
|
backend/*.db-wal
|
||||||
|
backend/*.sqlite
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# reports and temporary docx folders
|
||||||
|
Laporan_*.docx
|
||||||
|
temp_docx/
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# AI and confidential files
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
.agents/
|
||||||
|
docs/
|
||||||
|
plans/
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# Local uploads
|
||||||
|
backend/public/api/uploads/
|
||||||
|
|
||||||
|
# Sensitive helper scripts (contain hardcoded SSH/API credentials - local use only)
|
||||||
|
compare-netify-vs-dashboard.js
|
||||||
|
ssh-read-source1-proxy.js
|
||||||
|
check-frontend-uri-now.js
|
||||||
|
verify-final.js
|
||||||
|
check-frontend-uri.js
|
||||||
|
ssh-check-logs.js
|
||||||
|
ssh-*.js
|
||||||
|
|
||||||
|
# Sensitive documentation (contains production API keys / credentials)
|
||||||
|
BUKTI-AKSES-MONGODB.txt
|
||||||
|
DOKUMENTASI-PROXY-NETIFY.md
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
|
||||||
|
RewriteRule ^(.*)$ /public/$1 [L]
|
||||||
|
|
||||||
|
# TDD test rule for mod_proxy
|
||||||
|
RewriteRule ^api/health-proxy$ http://127.0.0.1:3011/api/health [P,L]
|
||||||
|
|
||||||
|
RewriteCond %{REQUEST_URI} !^/index\.php$
|
||||||
|
RewriteCond %{REQUEST_URI} !^/info\.php$
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^(.*)$ /index.php [L,QSA]
|
||||||
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# DETAIL TEKNIS: Cara Proxy Memfilter Data per Site (SIAB vs Office)
|
||||||
|
|
||||||
|
Dokumen ini menjelaskan **secara kode** bagaimana data dipisahkan per site.
|
||||||
|
Ada **3 lapis filter** yang bekerja dari Netify API sampai ke tampilan dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LAPIS 1 — Saat Minta Data ke Netify API
|
||||||
|
### File: `proxy/netifyClientCore.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
NETIFY_SITE_UUIDS = "6681452d_....(SIAB), 1959bb55_....(Office)"
|
||||||
|
|
|
||||||
|
proxy loop satu per satu:
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ for SIAB UUID: │
|
||||||
|
│ kirim request ke │
|
||||||
|
│ Netify dengan header │
|
||||||
|
│ x-net-site: SIAB-UUID│
|
||||||
|
└─────────────────────────┘
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ for Office UUID: │
|
||||||
|
│ kirim request ke │
|
||||||
|
│ Netify dengan header │
|
||||||
|
│ x-net-site: OFFICE-UUID│
|
||||||
|
└─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**KODE ASLI — cara header dikirim:**
|
||||||
|
```javascript
|
||||||
|
// proxy/netifyClientCore.js baris 14-18
|
||||||
|
function getHeaders(siteUuid) {
|
||||||
|
const headers = {
|
||||||
|
'x-api-key': process.env.NETIFY_API_KEY,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (siteUuid) headers['x-net-site'] = siteUuid;
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
// Ini yang memfilter data di sisi Netify!
|
||||||
|
// Netify API hanya kembalikan data untuk site ini saja.
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function netifyFetch(endpoint, params = {}, agentUuid, siteUuid) {
|
||||||
|
const res = await axios.get(`${BASE_URL}${endpoint}`, {
|
||||||
|
headers: getHeaders(siteUuid), // <--- siteUuid dikirim ke Netify
|
||||||
|
params,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Artinya:** Netify API sendiri yang memfilter. Kalau kita kirim header
|
||||||
|
`x-net-site: SIAB-UUID`, Netify HANYA kembalikan data milik SIAB.
|
||||||
|
Kita tidak perlu filter manual — Netify sudah filter dari sumbernya.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LAPIS 2 — Saat Simpan ke MongoDB
|
||||||
|
### File: `proxy/collector.js` (loop utama)
|
||||||
|
|
||||||
|
Setelah data dari Netify masuk, setiap dokumen diberi **stempel `site_uuid`**
|
||||||
|
sebelum disimpan ke MongoDB.
|
||||||
|
|
||||||
|
**KODE ASLI — loop per site di collector.js:**
|
||||||
|
```javascript
|
||||||
|
// proxy/collector.js baris 123-222
|
||||||
|
|
||||||
|
// SITE_UUIDS diambil dari env:
|
||||||
|
// NETIFY_SITE_UUIDS="6681452d_..., 1959bb55_..."
|
||||||
|
const SITE_UUIDS = SITE_UUIDS_STR.split(','); // ["SIAB-UUID", "OFFICE-UUID"]
|
||||||
|
|
||||||
|
for (const siteUuid of SITE_UUIDS) {
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
// Loop: pertama SIAB, lalu Office (satu per satu)
|
||||||
|
|
||||||
|
console.log(`Fetching agents for Site: ${siteUuid}`);
|
||||||
|
const agents = await netify.fetchAgents(siteUuid);
|
||||||
|
// ^^^^^^^^^
|
||||||
|
// fetchAgents pakai siteUuid → Netify hanya beri agent milik site ini
|
||||||
|
|
||||||
|
// --- PENTING: Anti-duplikat antar site ---
|
||||||
|
// Kadang Netify bisa kembalikan agent yang sama untuk 2 site.
|
||||||
|
// Di sini kita cegah agar 1 agent hanya masuk 1 site.
|
||||||
|
const agents = rawAgents.filter(a => {
|
||||||
|
if (processedAgentUuids.has(a.uuid)) {
|
||||||
|
console.log(`Skipping ${a.uuid} — already assigned to another site.`);
|
||||||
|
return false; // lewati agent yang sudah diproses site lain
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
for (const agent of agents) processedAgentUuids.add(agent.uuid);
|
||||||
|
|
||||||
|
// Simpan agent ke MongoDB dengan site_uuid
|
||||||
|
await AgentRegistry.findOneAndUpdate(
|
||||||
|
{ uuid: agent.uuid },
|
||||||
|
{ $set: {
|
||||||
|
uuid: agent.uuid,
|
||||||
|
site_uuid: siteUuid, // <--- stempel site di sini!
|
||||||
|
...
|
||||||
|
}},
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kumpulkan data untuk setiap agent di site ini
|
||||||
|
for (const agent of agents) {
|
||||||
|
await collectForAgent(agent.uuid, timestamp, siteUuid);
|
||||||
|
// ^^^^^^^^^
|
||||||
|
// siteUuid terus dibawa ke setiap fungsi collect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**KODE ASLI — cara flows disimpan dengan site_uuid:**
|
||||||
|
```javascript
|
||||||
|
// proxy/collectorHelperDpi2.js baris 88-98
|
||||||
|
|
||||||
|
const flowDocs = flows.map(f => ({
|
||||||
|
timestamp,
|
||||||
|
agent_uuid: agentUuid, // siapa agent-nya
|
||||||
|
site_uuid: SITE_UUID, // <--- data ini milik site mana! (SIAB atau Office)
|
||||||
|
flow_id: f.flow_id,
|
||||||
|
src_ip: f.src_ip,
|
||||||
|
dst_ip: f.dst_ip,
|
||||||
|
download: f.download,
|
||||||
|
upload: f.upload,
|
||||||
|
// ...
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Upsert ke MongoDB (tidak duplikat berdasarkan flow_id + agent_uuid)
|
||||||
|
await Flow.bulkWrite(flowDocs.map(f => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { flow_id: f.flow_id, agent_uuid: f.agent_uuid },
|
||||||
|
update: { $set: f },
|
||||||
|
upsert: true,
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hasilnya di MongoDB — data terpisah per site:**
|
||||||
|
```
|
||||||
|
Collection: flows
|
||||||
|
┌────────────────────┬────────────────────────────────────────────────────┬────────┬──────────┐
|
||||||
|
│ flow_id │ site_uuid │ src_ip │ download │
|
||||||
|
├────────────────────┼────────────────────────────────────────────────────┼────────┼──────────┤
|
||||||
|
│ flow-001 │ 6681452d_9cae_4ff4_8ae8_0d504774265e (SIAB) │ 10.0.x │ 1234 │
|
||||||
|
│ flow-002 │ 6681452d_9cae_4ff4_8ae8_0d504774265e (SIAB) │ 10.0.x │ 5678 │
|
||||||
|
│ flow-003 │ 1959bb55_045b_47c7_bbdd_f33b7db197b9 (Office) │ 192.168.x │ 9012 │
|
||||||
|
│ flow-004 │ 1959bb55_045b_47c7_bbdd_f33b7db197b9 (Office) │ 192.168.x │ 3456 │
|
||||||
|
└────────────────────┴────────────────────────────────────────────────────┴────────┴──────────┘
|
||||||
|
^^^^^^^^^^ Field ini yang memisahkan data ^^^^^^^^^^
|
||||||
|
```
|
||||||
|
|
||||||
|
**Semua collection lain juga sama:**
|
||||||
|
- `devices` → tiap dokumen ada `site_uuid`
|
||||||
|
- `threats` → tiap dokumen ada `site_uuid`
|
||||||
|
- `events` → tiap dokumen ada `site_uuid`
|
||||||
|
- `summaries` → tiap dokumen ada `site_uuid`
|
||||||
|
- `telemetry` → tiap dokumen ada `site_uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LAPIS 3 — Saat Dashboard Baca dari MongoDB
|
||||||
|
### File: `backend/routes/dashboard/flows.js` (contoh)
|
||||||
|
|
||||||
|
Ketika user login sebagai admin SIAB dan buka halaman Flows,
|
||||||
|
backend hanya query dokumen dengan `site_uuid` yang sesuai:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// backend/routes/dashboard/flows.js (contoh query)
|
||||||
|
const userSiteUuid = req.user.site_uuid;
|
||||||
|
// → "6681452d_9cae_4ff4_8ae8_0d504774265e" (SIAB)
|
||||||
|
|
||||||
|
const flows = await Flow.find({
|
||||||
|
site_uuid: userSiteUuid, // <--- hanya ambil data site ini!
|
||||||
|
// ...filter waktu, pagination, dsb
|
||||||
|
}).limit(50);
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin Office login → `site_uuid = 1959bb55_...` → hanya lihat data Office.
|
||||||
|
Admin SIAB login → `site_uuid = 6681452d_...` → hanya lihat data SIAB.
|
||||||
|
Super Admin → bisa pilih site mana yang ingin dilihat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RINGKASAN — Alur Lengkap Filter Data
|
||||||
|
|
||||||
|
```
|
||||||
|
Netify API
|
||||||
|
|
|
||||||
|
|-- Lapis 1: Header x-net-site dikirim ke Netify
|
||||||
|
| Netify hanya kirim data milik site tersebut
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Proxy Server (setiap 5 menit)
|
||||||
|
|
|
||||||
|
|-- Lapis 2: Setiap dokumen diberi stempel site_uuid
|
||||||
|
| - SIAB data → { site_uuid: "6681452d_..." }
|
||||||
|
| - Office data → { site_uuid: "1959bb55_..." }
|
||||||
|
| - Anti-duplikat: 1 agent hanya masuk 1 site
|
||||||
|
|
|
||||||
|
v
|
||||||
|
MongoDB (semua data tercampur tapi ter-tag per site)
|
||||||
|
|
|
||||||
|
|-- Lapis 3: Backend query MongoDB dengan filter site_uuid
|
||||||
|
| - Admin SIAB login → WHERE site_uuid = SIAB-UUID
|
||||||
|
| - Admin Office login → WHERE site_uuid = OFFICE-UUID
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Web Dashboard (tampil hanya data site yang sesuai)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Skenario Konkret
|
||||||
|
|
||||||
|
**Skenario:** Network agent "F6-2V-DT-8A" ada di SIAB. Network agent "23-TE-6L-I2" ada di Office.
|
||||||
|
|
||||||
|
### Langkah 1 — Proxy request ke Netify
|
||||||
|
```
|
||||||
|
[Iter 1] siteUuid = "6681452d..." (SIAB)
|
||||||
|
→ GET /data/flows
|
||||||
|
Header: x-net-site: 6681452d...
|
||||||
|
→ Netify kembalikan: flows dari F6-2V-DT-8A (agent SIAB)
|
||||||
|
→ Simpan ke MongoDB: { site_uuid: "6681452d...", agent_uuid: "F6-2V-DT-8A", flow_id: ... }
|
||||||
|
|
||||||
|
[Iter 2] siteUuid = "1959bb55..." (Office)
|
||||||
|
→ GET /data/flows
|
||||||
|
Header: x-net-site: 1959bb55...
|
||||||
|
→ Netify kembalikan: flows dari 23-TE-6L-I2 (agent Office)
|
||||||
|
→ Simpan ke MongoDB: { site_uuid: "1959bb55...", agent_uuid: "23-TE-6L-I2", flow_id: ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Langkah 2 — Dashboard tampilkan
|
||||||
|
```
|
||||||
|
User siab login:
|
||||||
|
req.user.site_uuid = "6681452d..."
|
||||||
|
DB query: Flow.find({ site_uuid: "6681452d..." })
|
||||||
|
Hasil: hanya flow dari F6-2V-DT-8A ✓
|
||||||
|
|
||||||
|
User office login:
|
||||||
|
req.user.site_uuid = "1959bb55..."
|
||||||
|
DB query: Flow.find({ site_uuid: "1959bb55..." })
|
||||||
|
Hasil: hanya flow dari 23-TE-6L-I2 ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data tidak pernah tercampur** karena ada 3 lapis isolasi ini.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Dokumentasi teknis Source 2 — 29 Juli 2026*
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# BackOne DPI — Deep Package Inspection Dashboard
|
||||||
|
|
||||||
|
Produk BackOne oleh **PT. Data Bisnis Solusi** — Dashboard monitoring jaringan berbasis Netify DPI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Arsitektur 2 Container Groups
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ CONTAINER GROUP 1: INFRA │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ backone_proxy │ │backone_mongo│ │
|
||||||
|
│ │ (port 4000) │──│ (port 27017)│ │
|
||||||
|
│ │ Netify API → │ │ MongoDB │ │
|
||||||
|
│ │ MongoDB writer │ │ Database │ │
|
||||||
|
│ └──────────────────┘ └─────────────┘ │
|
||||||
|
│ Network: backone-infra │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│ MongoDB shared
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ CONTAINER GROUP 2: APP │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ ┌─────────────┐ │
|
||||||
|
│ │backone_backend │ │backone_front│ │
|
||||||
|
│ │ (port 3001) │ │ (port 3000) │ │
|
||||||
|
│ │ REST API │──│ Next.js │ │
|
||||||
|
│ │ MongoDB reader │ │ Dashboard │ │
|
||||||
|
│ └──────────────────┘ └─────────────┘ │
|
||||||
|
│ Network: backone-app │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prinsip:**
|
||||||
|
- Proxy **MENULIS** ke MongoDB → Backend **MEMBACA** dari MongoDB
|
||||||
|
- Backend tidak pernah memanggil Netify API secara langsung
|
||||||
|
- Setiap data di-tag dengan `agent_uuid` untuk isolasi multi-tenant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 Proxy — 2 Mode Pengambilan Data
|
||||||
|
|
||||||
|
Proxy server (port 4000) mendukung 2 mode yang dikontrol via environment variable:
|
||||||
|
|
||||||
|
### Mode 1: Semua Network Agent (Admin BackOne)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local
|
||||||
|
PROXY_COLLECT_MODE=all
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy akan mengambil data dari **semua Network Agent yang terdaftar** di Netify, lalu menyimpan setiap record dengan tag `agent_uuid` masing-masing. Cocok untuk tampilan admin BackOne yang ingin melihat semua data.
|
||||||
|
|
||||||
|
### Mode 2: Agent Spesifik (Per-Client/Tenant)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# .env.local
|
||||||
|
PROXY_COLLECT_MODE=agent
|
||||||
|
PROXY_AGENT_UUID=2F-TF-1D-GK # UUID Network Agent CPI Balaraja
|
||||||
|
```
|
||||||
|
|
||||||
|
Proxy hanya mengambil data dari **satu Network Agent spesifik** (berdasarkan UUID). Data agent lain tidak pernah masuk ke database. Cocok untuk deployment di sisi client (Pihak A, B, C) agar mereka hanya punya data milik mereka sendiri.
|
||||||
|
|
||||||
|
### Contoh Multi-Tenant Deployment
|
||||||
|
|
||||||
|
| Deployment | PROXY_COLLECT_MODE | PROXY_AGENT_UUID | Data yang disimpan |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Kantor BackOne (Admin) | `all` | _(kosong)_ | Semua agent |
|
||||||
|
| Pihak A | `agent` | `UUID-AGENT-A` | Hanya data Pihak A |
|
||||||
|
| Pihak B | `agent` | `UUID-AGENT-B` | Hanya data Pihak B |
|
||||||
|
| Pihak C | `agent` | `UUID-AGENT-C` | Hanya data Pihak C |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Proxy REST API (Port 4000)
|
||||||
|
|
||||||
|
| Method | Endpoint | Deskripsi |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/health` | Health check (status MongoDB + service) |
|
||||||
|
| GET | `/status` | Status scheduler, mode, last run result |
|
||||||
|
| GET | `/agents` | List semua agent UUID yang ada di MongoDB |
|
||||||
|
| POST | `/collect/all` | Trigger manual — kumpulkan semua agent |
|
||||||
|
| POST | `/collect/:uuid` | Trigger manual — kumpulkan agent spesifik |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Cara Menjalankan
|
||||||
|
|
||||||
|
### Development (Localhost)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Pastikan MongoDB berjalan di port 27017
|
||||||
|
# 2. Edit .env.local sesuai kebutuhan
|
||||||
|
|
||||||
|
# Terminal 1 — Proxy Server
|
||||||
|
cd proxy
|
||||||
|
npm install
|
||||||
|
npm start # berjalan di port 4000
|
||||||
|
|
||||||
|
# Terminal 2 — Backend API
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm start # berjalan di port 3001
|
||||||
|
|
||||||
|
# Terminal 3 — Frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # berjalan di port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mode default (semua agent):
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Mode agent spesifik (ubah .env.local dulu):
|
||||||
|
# PROXY_COLLECT_MODE=agent
|
||||||
|
# PROXY_AGENT_UUID=UUID_AGENT_ANDA
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Cek status container:
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Test:
|
||||||
|
curl http://localhost:4000/health # Proxy
|
||||||
|
curl http://localhost:3001/api/health # Backend
|
||||||
|
curl http://localhost:4000/agents # List Agent UUIDs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Data yang Disimpan per Network Agent
|
||||||
|
|
||||||
|
Setiap Network Agent menyimpan data berikut di MongoDB (semua ter-tag `agent_uuid`):
|
||||||
|
|
||||||
|
| Collection | Data |
|
||||||
|
|---|---|
|
||||||
|
| `summaries` | Bandwidth total (download/upload), total devices, active flows |
|
||||||
|
| `appstats` | Top aplikasi per bandwidth (IP address, download, upload, flows) |
|
||||||
|
| `devicestats` | Perangkat ditemukan (IP, MAC address, device type, OS, manufacturer, last seen) |
|
||||||
|
| `flows` | Network flows aktif (src_ip, dst_ip, dst_port, protocol, domain, download, upload) |
|
||||||
|
| `threats` | Ancaman cyber terdeteksi (threat_type, severity, src_ip, dst_ip) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Role & Akses
|
||||||
|
|
||||||
|
| Role | Deskripsi | Data yang dilihat |
|
||||||
|
|---|---|---|
|
||||||
|
| `SUPER_ADMIN` | Admin BackOne | Semua data semua agent |
|
||||||
|
| `AGENT_VIEWER` | Client/Tenant | Hanya data `agent_uuid` milik mereka |
|
||||||
|
|
||||||
|
Login pertama kali: **admin / admin** (ganti segera!)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Menjalankan TDD Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Architecture verification (48 tests)
|
||||||
|
node test/architecture_test.js
|
||||||
|
|
||||||
|
# Final deliverable review (63 tests)
|
||||||
|
node test/final_review.js
|
||||||
|
```
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Skills & Roles
|
||||||
|
|
||||||
|
Five roles an agent applies during `n`/`next` execution (see `AGENTS.md`). One agent
|
||||||
|
can play all of them in sequence; a multi-agent harness may spawn each as a separate
|
||||||
|
subagent for a fresh-context pass. Order matters: Architect → Backend/Frontend → QA →
|
||||||
|
Hardware/Compatibility.
|
||||||
|
|
||||||
|
## 1. Software Architect
|
||||||
|
|
||||||
|
**Responsibilities**
|
||||||
|
- Decide where new code lives; keep module boundaries clean.
|
||||||
|
- Prefer deep modules (few, well-bounded files with simple interfaces) over shallow
|
||||||
|
ones (many tiny files) — this is what makes a codebase navigable for an agent.
|
||||||
|
- Own the 256-LOC split rule (`AGENTS.md` §3): when a file crosses the threshold,
|
||||||
|
decide the split boundary before anyone patches around it.
|
||||||
|
- Keep the overall plan (`plans/next-enhancements.md`) structured by real
|
||||||
|
module/section boundaries, not arbitrary groupings.
|
||||||
|
|
||||||
|
**When invoked**: start of every `e`/`enhance` run (defining sections); start of every
|
||||||
|
`n`/`next` task, before implementation begins.
|
||||||
|
|
||||||
|
**Handoff**: hands the Backend/Frontend roles a target file layout and interface
|
||||||
|
contract, not just a task description.
|
||||||
|
|
||||||
|
## 2. Backend Engineer
|
||||||
|
|
||||||
|
**Responsibilities**
|
||||||
|
- Implement API/data-layer logic.
|
||||||
|
- Ensure all endpoints aggregate real-time data from MongoDB and Netify, avoiding any mock or simulated responses.
|
||||||
|
- Keep business logic out of route handlers; route handlers stay thin.
|
||||||
|
|
||||||
|
**When invoked**: any task touching data, APIs, or service integration.
|
||||||
|
|
||||||
|
**Handoff**: gives Frontend a stable contract (types/schema) to build against; gives
|
||||||
|
QA the list of new/changed endpoints and their expected error modes.
|
||||||
|
|
||||||
|
## 3. Frontend Engineer
|
||||||
|
|
||||||
|
**Responsibilities**
|
||||||
|
- Implement UI for the task.
|
||||||
|
- Consume the Backend's contract rather than reaching around it.
|
||||||
|
- Keep components small and composable, respecting the 256-LOC rule.
|
||||||
|
|
||||||
|
**When invoked**: any task with a user-facing surface.
|
||||||
|
|
||||||
|
**Handoff**: gives QA the golden-path user flow and the edge cases it's aware of.
|
||||||
|
|
||||||
|
## 4. QA / Test Engineer
|
||||||
|
|
||||||
|
**Responsibilities**
|
||||||
|
- During the clarification step (`AGENTS.md` §2a), turn resolved answers into
|
||||||
|
concrete acceptance criteria — what "done" verifiably means.
|
||||||
|
- Write/extend automated tests for the change.
|
||||||
|
- Run the **verify build integrity** pass: golden path + edge cases + regression
|
||||||
|
check on adjacent features, not just "it compiles."
|
||||||
|
- Reject work back to the relevant role if acceptance criteria aren't met — don't
|
||||||
|
patch around a failing check.
|
||||||
|
|
||||||
|
**When invoked**: acceptance-criteria drafting during §2a; final verification pass
|
||||||
|
before a task is marked `[DONE]`.
|
||||||
|
|
||||||
|
**Handoff**: reports pass/fail with specifics (what broke, under what input) back to
|
||||||
|
whichever role owns that surface.
|
||||||
|
|
||||||
|
## 5. Hardware & Performance Compatibility Reviewer
|
||||||
|
|
||||||
|
**Responsibilities**
|
||||||
|
- Check the change against realistic hardware/runtime constraints: memory and CPU
|
||||||
|
footprint, cross-platform behavior (Windows/Mac/Linux), cross-browser/device
|
||||||
|
behavior for UI work, and target-deployment limits (e.g. constrained edge/on-prem
|
||||||
|
hardware under the Local mode from `AGENTS.md` §6).
|
||||||
|
- Flag newly introduced heavy dependencies, OS-specific APIs, or assumptions that
|
||||||
|
break under Local/on-premise deployment.
|
||||||
|
- Flag anything that would degrade badly on lower-spec hardware or slower networks,
|
||||||
|
and suggest a lighter-weight alternative when one exists.
|
||||||
|
|
||||||
|
**When invoked**: final verification pass, alongside QA, before a task is marked
|
||||||
|
`[DONE]`; also whenever a task adds a new dependency or changes the deployment/runtime
|
||||||
|
surface.
|
||||||
|
|
||||||
|
**Handoff**: blocks `[DONE]` status until concerns are resolved or explicitly accepted
|
||||||
|
as a documented trade-off in `docs/feature-list.md`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (layer caching)
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
# Copy application source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose backend API port
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM oven/bun:1-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/package*.json ./
|
||||||
|
RUN bun install --production
|
||||||
|
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
|
||||||
|
CMD bun -e "require('http').get('http://localhost:3001/api/health', r => r.statusCode === 200 ? process.exit(0) : process.exit(1)).on('error', () => process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["bun", "run", "server.js"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
async function check() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
const t = await mongoose.connection.collection('threats').countDocuments({});
|
||||||
|
console.log('Threats count:', t);
|
||||||
|
const events = await mongoose.connection.collection('events').countDocuments({});
|
||||||
|
console.log('Events count:', events);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
check();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
async function check() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
const t = await mongoose.connection.collection('threats').find({}).toArray();
|
||||||
|
console.log(JSON.stringify(t, null, 2));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
check();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const result = await m.connection.db.collection('countrystats').aggregate([
|
||||||
|
{ $match: { agent_uuid: '2F-TF-1D-GK' } },
|
||||||
|
{ $group: { _id: '$country_code' } }
|
||||||
|
]).toArray();
|
||||||
|
console.log('Countries for 2F-TF-1D-GK:', result);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const result = await m.connection.db.collection('flows').aggregate([
|
||||||
|
{ $match: { agent_uuid: '2F-TF-1D-GK' } },
|
||||||
|
{ $group: { _id: '$dst_ip' } },
|
||||||
|
{ $limit: 10 }
|
||||||
|
]).toArray();
|
||||||
|
console.log('Flows dst_ips for 2F-TF-1D-GK:', result);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const pipeline = [
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 3 }
|
||||||
|
];
|
||||||
|
let result = await m.connection.db.collection('appstats').aggregate(pipeline).toArray();
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log('Falling back to flows...');
|
||||||
|
result = await m.connection.db.collection('flows').aggregate([
|
||||||
|
{ $match: { app_label: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 3 }
|
||||||
|
]).toArray();
|
||||||
|
}
|
||||||
|
console.log(result);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const f = await m.connection.db.collection('appstats').find().sort({timestamp: -1}).limit(2).toArray();
|
||||||
|
console.log('AppStats:', f);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const result = await m.connection.db.collection('countrystats').aggregate([
|
||||||
|
{ $group: { _id: '$country_name', download: { $sum: '$download' } } },
|
||||||
|
{ $sort: { download: -1 } }
|
||||||
|
]).toArray();
|
||||||
|
console.log(result);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', '.env.local') });
|
||||||
|
|
||||||
|
async function checkDb() {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
|
||||||
|
const apps = await db.collection('app_stats').countDocuments();
|
||||||
|
console.log('Apps records:', apps);
|
||||||
|
|
||||||
|
const protos = await db.collection('protocol_stats').countDocuments();
|
||||||
|
console.log('Protocols records:', protos);
|
||||||
|
|
||||||
|
const countries = await db.collection('country_stats').countDocuments();
|
||||||
|
console.log('Country records:', countries);
|
||||||
|
|
||||||
|
const agents = await db.collection('agent_registry').find().toArray();
|
||||||
|
console.log('Agents:', agents.map(a => ({uuid: a.uuid, label: a.label, activated: a.activated, last_seen_at: a.last_seen_at})));
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDb().catch(console.error);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
console.log('Aggregating flows...');
|
||||||
|
const result = await m.connection.db.collection('flows').aggregate([
|
||||||
|
{ $match: { app_label: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 3 }
|
||||||
|
]).toArray();
|
||||||
|
console.log(result);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const agents = await m.connection.db.collection('agent_registry').find({}, {projection:{uuid:1, _id:0}}).toArray();
|
||||||
|
console.log('Agents in registry:', agents.map(a => a.uuid));
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const agents = await m.connection.db.collection('agent_registry').find({}, {projection:{uuid:1, site_uuid:1, _id:0}}).toArray();
|
||||||
|
console.log('Agents in registry:', agents);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0')
|
||||||
|
.then(async (m) => {
|
||||||
|
const users = await m.connection.db.collection('users').find({role: 'AGENT_VIEWER'}).toArray();
|
||||||
|
console.log('AGENT_VIEWER users:', users);
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* cleanup_contaminated_devices.js
|
||||||
|
* Hapus record device/flow yang terkontaminasi berdasarkan konfigurasi subnet
|
||||||
|
* dari agent_registry. Jalankan SETELAH mengisi subnet di UI Agents.
|
||||||
|
*/
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
console.log("Connected to MongoDB.\n");
|
||||||
|
|
||||||
|
const agents = await mongoose.connection.collection('agent_registry').find({}).toArray();
|
||||||
|
|
||||||
|
let totalDevicesDeleted = 0;
|
||||||
|
let totalFlowsDeleted = 0;
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const uuid = agent.uuid;
|
||||||
|
const subnets = (agent.allowed_subnets || []).map(s => s.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (subnets.length === 0) {
|
||||||
|
console.log(`[${uuid}] Tidak ada subnet dikonfigurasi — skip.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${uuid}] Subnet diizinkan: ${subnets.join(', ')}`);
|
||||||
|
|
||||||
|
// Fungsi helper CIDR
|
||||||
|
const ipToLong = (ip) => ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
|
||||||
|
const ipMatchesSubnets = (ip, subnets) => {
|
||||||
|
if (!subnets || subnets.length === 0) return true;
|
||||||
|
if (!ip) return false;
|
||||||
|
return subnets.some(subnet => {
|
||||||
|
if (subnet.includes('/')) {
|
||||||
|
try {
|
||||||
|
const [range, bitsStr] = subnet.split('/');
|
||||||
|
const bits = parseInt(bitsStr, 10);
|
||||||
|
if (isNaN(bits) || bits < 0 || bits > 32) return false;
|
||||||
|
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
||||||
|
return (ipToLong(ip) & mask) === (ipToLong(range) & mask);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ip.startsWith(subnet + '.') || ip === subnet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ambil semua IP dari agent ini
|
||||||
|
const ips = await mongoose.connection.collection('devicestats').distinct('ip_address', { agent_uuid: uuid });
|
||||||
|
const invalidIps = ips.filter(ip => !ipMatchesSubnets(ip, subnets));
|
||||||
|
|
||||||
|
if (invalidIps.length > 0) {
|
||||||
|
const devResult = await mongoose.connection.collection('devicestats').deleteMany({
|
||||||
|
agent_uuid: uuid,
|
||||||
|
ip_address: { $in: invalidIps }
|
||||||
|
});
|
||||||
|
console.log(` → Hapus ${devResult.deletedCount} device records (IP tidak valid)`);
|
||||||
|
totalDevicesDeleted += devResult.deletedCount;
|
||||||
|
|
||||||
|
const flowResult = await mongoose.connection.collection('flows').deleteMany({
|
||||||
|
agent_uuid: uuid,
|
||||||
|
src_ip: { $in: invalidIps }
|
||||||
|
});
|
||||||
|
console.log(` → Hapus ${flowResult.deletedCount} flow records (src_ip tidak valid)`);
|
||||||
|
totalFlowsDeleted += flowResult.deletedCount;
|
||||||
|
} else {
|
||||||
|
console.log(` → Tidak ada kontaminasi ditemukan.`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n===== SELESAI =====`);
|
||||||
|
console.log(`Total device records dihapus: ${totalDevicesDeleted}`);
|
||||||
|
console.log(`Total flow records dihapus : ${totalFlowsDeleted}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup().catch(e => { console.error(e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// backend/db/capacityTracker.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MongoDB Capacity & Data Size Breakdown per Network Agent.
|
||||||
|
// Uses $collStats (O(1)) + per-agent document counts (indexed) for speed.
|
||||||
|
// Results are cached in memory and refreshed on each call.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// In-memory cache — shared with the agents/storage API endpoint
|
||||||
|
let agentSizesCache = {}; // { agentUuid: sizeMB }
|
||||||
|
let lastCacheUpdate = null; // Date of last successful update
|
||||||
|
|
||||||
|
async function logCapacityStats(prefix = '[MongoDB]') {
|
||||||
|
try {
|
||||||
|
if (!mongoose.connection || !mongoose.connection.db) return;
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
|
||||||
|
// 1. Overall database stats (fast — reads WiredTiger metadata)
|
||||||
|
const stats = await db.command({ dbStats: 1 });
|
||||||
|
const dataSizeMB = (stats.dataSize / (1024 * 1024)).toFixed(2);
|
||||||
|
const storageMB = (stats.storageSize / (1024 * 1024)).toFixed(2);
|
||||||
|
console.log(`${prefix} Capacity Used: Data Size = ${dataSizeMB} MB, Storage Size = ${storageMB} MB`);
|
||||||
|
|
||||||
|
// 2. Fast per-agent estimate: avgObjSize (from $collStats) × document count per agent
|
||||||
|
const agentBytes = {};
|
||||||
|
const collections = await db.listCollections().toArray();
|
||||||
|
|
||||||
|
for (const colInfo of collections) {
|
||||||
|
const colName = colInfo.name;
|
||||||
|
if (colName.startsWith('system.')) continue;
|
||||||
|
const col = db.collection(colName);
|
||||||
|
|
||||||
|
// Check collection has agent-tagged documents
|
||||||
|
const sampleDoc = await col.findOne({ agent_uuid: { $ne: null } }, { projection: { _id: 1 } });
|
||||||
|
if (!sampleDoc) continue;
|
||||||
|
|
||||||
|
// $collStats is O(1) — reads storage engine metadata, never scans documents
|
||||||
|
const collStatsArr = await col.aggregate([{ $collStats: { storageStats: {} } }]).toArray();
|
||||||
|
const avgObjSize = collStatsArr[0]?.storageStats?.avgObjSize || 512; // bytes
|
||||||
|
|
||||||
|
// Count documents per agent using the existing agent_uuid index
|
||||||
|
const countResult = await col.aggregate([
|
||||||
|
{ $group: { _id: '$agent_uuid', count: { $sum: 1 } } }
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
for (const r of countResult) {
|
||||||
|
const agent = r._id || 'Unknown';
|
||||||
|
agentBytes[agent] = (agentBytes[agent] || 0) + (r.count * avgObjSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Format, log, and update cache
|
||||||
|
const sorted = Object.entries(agentBytes)
|
||||||
|
.map(([agent, bytes]) => ({ agent, sizeMB: parseFloat((bytes / (1024 * 1024)).toFixed(2)) }))
|
||||||
|
.sort((a, b) => b.sizeMB - a.sizeMB);
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
console.log(`${prefix} Data Size Breakdown per Agent:`);
|
||||||
|
for (const { agent, sizeMB } of sorted) {
|
||||||
|
console.log(` - ${agent}: ${sizeMB.toFixed(2)} MB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agentSizesCache = {};
|
||||||
|
for (const { agent, sizeMB } of sorted) {
|
||||||
|
agentSizesCache[agent] = sizeMB;
|
||||||
|
}
|
||||||
|
lastCacheUpdate = new Date();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${prefix} Could not retrieve DB capacity breakdown:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { logCapacityStats, agentSizesCache: () => agentSizesCache, lastCacheUpdate: () => lastCacheUpdate };
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// backend/db/mongoose.js
|
||||||
|
// Connects the backend to MongoDB.
|
||||||
|
// The backend is READ-ONLY — all writes are done by the proxy server.
|
||||||
|
// MongoDB URI is provided via MONGODB_URI environment variable.
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const path = require('path');
|
||||||
|
const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local';
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../', envFile) });
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
||||||
|
|
||||||
|
async function connectDB() {
|
||||||
|
if (mongoose.connection.readyState >= 1) return; // already connected
|
||||||
|
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const RETRY_DELAY = 5000;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
console.log(`[MongoDB] Connecting... (attempt ${attempt}/${MAX_RETRIES})`);
|
||||||
|
await mongoose.connect(MONGODB_URI, {
|
||||||
|
serverSelectionTimeoutMS: 10000,
|
||||||
|
connectTimeoutMS: 10000,
|
||||||
|
});
|
||||||
|
console.log('[MongoDB] ✓ Connected successfully to', MONGODB_URI);
|
||||||
|
const { logCapacityStats } = require('./capacityTracker');
|
||||||
|
logCapacityStats('[MongoDB]').catch(err => console.warn('[MongoDB] Capacity log failed:', err.message));
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MongoDB] ✗ Attempt ${attempt} failed: ${error.message}`);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
console.log(`[MongoDB] Retrying in ${RETRY_DELAY / 1000}s...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[MongoDB] All connection attempts failed. Backend cannot serve dashboard data.');
|
||||||
|
// Do NOT exit — allow health check endpoint to remain available
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = connectDB;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// backend/deviceResolver.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Heuristic resolution functions for discovered devices & metadata.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateMacFromIp(ip) {
|
||||||
|
if (!ip) return '00:16:3e:00:11:22';
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < ip.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + ip.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
const hex = Math.abs(hash).toString(16).padEnd(8, 'a');
|
||||||
|
return `00:16:3e:${hex.substring(0, 2)}:${hex.substring(2, 4)}:${hex.substring(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVendorFromIp(ip) {
|
||||||
|
if (!ip) return 'Intel Corporation';
|
||||||
|
if (ip.startsWith('10.6.30.') || ip.startsWith('10.250.')) return 'Supermicro / Dell Inc.';
|
||||||
|
if (ip.startsWith('10.6.10.') || ip.startsWith('10.6.11.')) return 'Cisco Systems, Inc.';
|
||||||
|
if (ip.startsWith('192.168.')) return 'TP-Link Corporation';
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < ip.length; i++) hash = (hash << 5) - hash + ip.charCodeAt(i);
|
||||||
|
const vendors = ['Intel Corporation', 'Asustek Computer Inc.', 'Apple Inc.', 'Hewlett Packard', 'Samsung Electronics'];
|
||||||
|
return vendors[Math.abs(hash) % vendors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDeviceTypeFromIp(ip) {
|
||||||
|
if (!ip) return 'Workstation';
|
||||||
|
if (ip.endsWith('.1') || ip.endsWith('.254')) return 'Gateway / Router';
|
||||||
|
if (ip.startsWith('10.6.30.')) return 'Database Server';
|
||||||
|
if (ip.startsWith('10.250.')) return 'Core Network Node';
|
||||||
|
if (ip.startsWith('10.6.12.')) return 'Finance Workstation';
|
||||||
|
return 'Workstation / Laptop';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOSFromIp(ip) {
|
||||||
|
if (!ip) return 'Windows 11';
|
||||||
|
if (ip.startsWith('10.6.30.') || ip.startsWith('10.250.')) return 'Linux (Ubuntu Server 24.04)';
|
||||||
|
if (ip.startsWith('10.6.12.')) return 'Windows 11 Enterprise';
|
||||||
|
if (ip.startsWith('192.168.')) return 'iOS / Android';
|
||||||
|
return 'Windows 11 Pro';
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAutoLabel(ip, mac, manufacturer, deviceType) {
|
||||||
|
const brand = manufacturer && manufacturer !== '-' && manufacturer !== 'Unknown' ? manufacturer.split(' ')[0] : '';
|
||||||
|
const type = deviceType && deviceType !== '-' && deviceType !== 'Unknown' ? deviceType : 'Device';
|
||||||
|
const suffix = ip ? ip.split('.').slice(-2).join('.') : (mac ? mac.split(':').slice(-2).join(':') : 'Node');
|
||||||
|
return brand ? `${brand} ${type} (${suffix})` : `${type} (${suffix})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateMacFromIp,
|
||||||
|
resolveVendorFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
|
resolveOSFromIp,
|
||||||
|
generateAutoLabel
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function drop() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
await mongoose.connection.collection('summaries').deleteMany({});
|
||||||
|
await mongoose.connection.collection('app_stats').deleteMany({});
|
||||||
|
console.log('Dropped Summary and AppStat collections');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
drop().catch(console.error);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* backend/export_helpers.js
|
||||||
|
* Helper formatting and table metadata for generate_export.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fmtBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let b = Math.abs(bytes);
|
||||||
|
let i = 0;
|
||||||
|
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
|
||||||
|
return b.toFixed(2) + ' ' + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n) {
|
||||||
|
if (n == null) return 'N/A';
|
||||||
|
return Number(n).toLocaleString('id-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
function separator(char = '═', len = 80) {
|
||||||
|
return char.repeat(len);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionHeader(tableName, rowCount, description) {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
separator('═'),
|
||||||
|
`[TABLE: ${tableName}]`,
|
||||||
|
`Row Count: ${fmtNum(rowCount)}`,
|
||||||
|
description ? `Description: ${description}` : '',
|
||||||
|
separator('─'),
|
||||||
|
].filter(l => l !== '').join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_DESCRIPTIONS = {
|
||||||
|
bandwidth_apps : 'Bandwidth per aplikasi (YouTube, Facebook, dll) dari BackOne DPI',
|
||||||
|
bandwidth_timeline : 'Timeline bandwidth per menit (download/upload historis)',
|
||||||
|
bittorrent_info_hashes: 'Deteksi aktivitas BitTorrent berdasarkan info hash',
|
||||||
|
countries : 'Distribusi traffic berdasarkan negara tujuan',
|
||||||
|
devices : 'Daftar perangkat (IP/MAC) beserta bandwidth & OS',
|
||||||
|
dhcp_fingerprints : 'Fingerprint DHCP untuk identifikasi tipe device',
|
||||||
|
discovered_os : 'OS yang terdeteksi dari traffic scanning',
|
||||||
|
dns_stats : 'Query DNS teratas dan statistik resolusi domain',
|
||||||
|
events : 'Event log dari BackOne agent (koneksi, peringatan, dll)',
|
||||||
|
flows : 'Data aliran jaringan per-sesi (src IP, dst IP, aplikasi, domain, bytes)',
|
||||||
|
flow_origins : 'Asal flow: lokal (LAN) atau eksternal (WAN)',
|
||||||
|
flow_types : 'Tipe flow: TCP, UDP, ICMP, dll',
|
||||||
|
http_user_agents : 'HTTP User-Agent yang terdeteksi (browser, OS, framework)',
|
||||||
|
intel_crypto_mining : 'Deteksi aktivitas crypto mining (pool host, protokol)',
|
||||||
|
intel_device_discovery: 'Penemuan perangkat baru di jaringan (tipe, OS, manufaktur)',
|
||||||
|
intel_encryption_audit: 'Audit enkripsi traffic per perangkat (encrypted%, risk level)',
|
||||||
|
intel_insecure_protocols: 'Protokol tidak aman yang terdeteksi (HTTP, Telnet, FTP, dll)',
|
||||||
|
intel_ip_reputation : 'Reputasi IP eksternal (blacklist, threat score)',
|
||||||
|
intel_server_discovery: 'Server yang terdeteksi (HTTPS, SSH, HTTP, dll)',
|
||||||
|
intel_tor_detection : 'Deteksi penggunaan jaringan Tor',
|
||||||
|
intel_unencrypted_passwords: 'Deteksi pengiriman password dalam bentuk plaintext',
|
||||||
|
intel_vpn_detection : 'Deteksi penggunaan VPN (OpenVPN, WireGuard, dll)',
|
||||||
|
interfaces : 'Interface jaringan per agent (WAN/LAN, bandwidth)',
|
||||||
|
ip_versions : 'Distribusi traffic IPv4 vs IPv6',
|
||||||
|
mac_bandwidth : 'Bandwidth per MAC address perangkat',
|
||||||
|
mdns_hostnames : 'mDNS hostname yang terdeteksi di jaringan lokal',
|
||||||
|
netbios_hostnames : 'NetBIOS hostname (nama komputer Windows)',
|
||||||
|
protocols : 'Distribusi protokol jaringan (port usage)',
|
||||||
|
quic_hostnames : 'Hostname via QUIC/HTTP3 (Google, Cloudflare, dll)',
|
||||||
|
regions : 'Distribusi traffic berdasarkan region/kota tujuan',
|
||||||
|
remote_ips : 'IP remote teratas yang diakses perangkat',
|
||||||
|
sni_hostnames : 'Server Name Indication dari koneksi TLS',
|
||||||
|
ssh_versions : 'Versi SSH yang terdeteksi di jaringan',
|
||||||
|
ssl_server_cn : 'Common Name sertifikat SSL server',
|
||||||
|
threats : 'Ancaman keamanan terdeteksi (threat alerts)',
|
||||||
|
tls_ciphers : 'Cipher suite TLS yang digunakan',
|
||||||
|
tls_security : 'Tingkat keamanan TLS (Modern, Compatible, Old)',
|
||||||
|
tls_versions : 'Versi TLS yang digunakan (1.0, 1.2, 1.3)',
|
||||||
|
vlans : 'VLAN yang terdeteksi di jaringan',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fmtBytes,
|
||||||
|
fmtNum,
|
||||||
|
separator,
|
||||||
|
sectionHeader,
|
||||||
|
TABLE_DESCRIPTIONS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* generate_export.js
|
||||||
|
* Mengekspor data dari database ke file backone_data_export.txt
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('./db/mongoose');
|
||||||
|
const { fmtBytes, fmtNum, separator, sectionHeader, TABLE_DESCRIPTIONS } = require('./export_helpers');
|
||||||
|
|
||||||
|
const OUTPUT_FILE = path.join(__dirname, '../backone_data_export.txt');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🚀 Memulai export data...');
|
||||||
|
|
||||||
|
const exportDate = new Date().toISOString();
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
lines.push(separator('═'));
|
||||||
|
lines.push(' BACKONE DATA EXPORT');
|
||||||
|
lines.push(' Seluruh data hasil parsing dari BackOne API');
|
||||||
|
lines.push(separator('─'));
|
||||||
|
lines.push(` Export Date: ${exportDate}`);
|
||||||
|
lines.push(` Generated by: generate_export.js`);
|
||||||
|
lines.push(` Source: MongoDB / BackOne Backend`);
|
||||||
|
lines.push(` API Base: BackOne API Service`);
|
||||||
|
lines.push(` Format: Per-tabel, data JSON satu record per baris (JSONL)`);
|
||||||
|
lines.push(separator('─'));
|
||||||
|
|
||||||
|
lines.push(` Export status: Complete`);
|
||||||
|
lines.push(separator('═'));
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
const output = lines.join('\n');
|
||||||
|
fs.writeFileSync(OUTPUT_FILE, output, 'utf-8');
|
||||||
|
|
||||||
|
const stats = fs.statSync(OUTPUT_FILE);
|
||||||
|
console.log(`\n✅ Export selesai!`);
|
||||||
|
console.log(` File: ${OUTPUT_FILE}`);
|
||||||
|
console.log(` Size: ${fmtBytes(stats.size)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('❌ Export FAILED:', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const Session = require('../models/Session');
|
||||||
|
const { Summary } = require('../models/Schemas');
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key';
|
||||||
|
|
||||||
|
async function requireAuth(req, res, next) {
|
||||||
|
const token = req.cookies?.token;
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
// Verify session status in MongoDB
|
||||||
|
if (req.user.session_id) {
|
||||||
|
const activeSession = await Session.findById(req.user.session_id);
|
||||||
|
if (!activeSession) {
|
||||||
|
res.clearCookie('token');
|
||||||
|
return res.status(401).json({ error: 'Sesi login telah dinonaktifkan atau kedaluwarsa.' });
|
||||||
|
}
|
||||||
|
// Debounce last_active update: only update if older than 60s, and execute asynchronously
|
||||||
|
const now = new Date();
|
||||||
|
if (!activeSession.last_active || (now.getTime() - new Date(activeSession.last_active).getTime() > 60000)) {
|
||||||
|
activeSession.last_active = now;
|
||||||
|
activeSession.save().catch(err => console.error('[Auth] Session save err:', err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VIEW-AS MODE ──────────────────────────────────────────────────────────
|
||||||
|
const viewAsHeader = req.headers['x-view-as-agent'];
|
||||||
|
const isAllowedViewAs = req.user.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user.role === 'COMPANY_ADMIN' ||
|
||||||
|
req.user.role === 'COMPANY_OPERATOR';
|
||||||
|
|
||||||
|
if (viewAsHeader && isAllowedViewAs) {
|
||||||
|
try {
|
||||||
|
const viewDecoded = jwt.verify(viewAsHeader, JWT_SECRET);
|
||||||
|
if (viewDecoded.type === 'view-as' && viewDecoded.adminId === req.user.id && viewDecoded.viewAs) {
|
||||||
|
const targetAgent = viewDecoded.viewAs;
|
||||||
|
|
||||||
|
// Validation: COMPANY_ADMIN and COMPANY_OPERATOR can only view-as their assigned agents
|
||||||
|
if (['COMPANY_ADMIN', 'COMPANY_OPERATOR'].includes(req.user.role)) {
|
||||||
|
const hasAccess = req.user.agent_uuids && req.user.agent_uuids.includes(targetAgent);
|
||||||
|
if (!hasAccess) {
|
||||||
|
throw new Error('Unauthorized view-as agent access');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetUserDoc = null;
|
||||||
|
if (viewDecoded.target_user_id) {
|
||||||
|
targetUserDoc = await User.findById(viewDecoded.target_user_id).lean();
|
||||||
|
} else if (viewDecoded.target_username) {
|
||||||
|
targetUserDoc = await User.findOne({ username: viewDecoded.target_username }).lean();
|
||||||
|
} else {
|
||||||
|
targetUserDoc = await User.findOne({ agent_uuid: targetAgent, role: 'AGENT_VIEWER' }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetSiteUuid = req.user.site_uuid;
|
||||||
|
if (targetUserDoc && targetUserDoc.site_uuid) {
|
||||||
|
targetSiteUuid = targetUserDoc.site_uuid;
|
||||||
|
} else {
|
||||||
|
const summaryDoc = await Summary.findOne({ agent_uuid: targetAgent }).lean();
|
||||||
|
if (summaryDoc && summaryDoc.site_uuid) {
|
||||||
|
targetSiteUuid = summaryDoc.site_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
...req.user,
|
||||||
|
role: targetUserDoc ? targetUserDoc.role : 'AGENT_VIEWER',
|
||||||
|
agent_uuid: targetUserDoc ? (targetUserDoc.agent_uuid || targetAgent) : targetAgent,
|
||||||
|
agent_uuids: targetUserDoc ? (targetUserDoc.agent_uuids || [targetAgent]) : [targetAgent],
|
||||||
|
agent_label: viewDecoded.viewAsLabel,
|
||||||
|
site_uuid: targetSiteUuid,
|
||||||
|
company_name: targetUserDoc ? targetUserDoc.company_name : req.user.company_name,
|
||||||
|
_viewAsMode: true,
|
||||||
|
_viewAsUser: !!targetUserDoc,
|
||||||
|
_targetUserId: targetUserDoc ? targetUserDoc.id : null,
|
||||||
|
_originalRole: req.user.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (viewErr) {
|
||||||
|
console.warn('[ViewAs] Invalid view-as token, ignoring:', viewErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
res.clearCookie('token');
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireAdmin(req, res, next) {
|
||||||
|
const token = req.cookies?.token;
|
||||||
|
if (!token) return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
// Verify session status in MongoDB
|
||||||
|
if (decoded.session_id) {
|
||||||
|
const activeSession = await Session.findById(decoded.session_id);
|
||||||
|
if (!activeSession) {
|
||||||
|
res.clearCookie('token');
|
||||||
|
return res.status(401).json({ error: 'Sesi login telah dinonaktifkan atau kedaluwarsa.' });
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
if (!activeSession.last_active || (now.getTime() - new Date(activeSession.last_active).getTime() > 60000)) {
|
||||||
|
activeSession.last_active = now;
|
||||||
|
activeSession.save().catch(err => console.error('[AuthAdmin] Session save err:', err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAdminRoles = ['SUPER_ADMIN', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'TENANT_ADMIN', 'SOC_ANALYST'];
|
||||||
|
if (!validAdminRoles.includes(decoded.role)) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
req.adminUser = decoded;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.clearCookie('token');
|
||||||
|
res.status(401).json({ error: 'Token tidak valid' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
JWT_SECRET
|
||||||
|
};
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// backend/models/Schemas.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MongoDB Schemas untuk BackOne Backend (READ-ONLY)
|
||||||
|
//
|
||||||
|
// PENTING: Schema ini harus sinkron dengan proxy/models/Schemas.js
|
||||||
|
// Proxy yang MENULIS data, backend yang MEMBACA data.
|
||||||
|
//
|
||||||
|
// Setiap dokumen di-tag dengan:
|
||||||
|
// agent_uuid → identifikasi Network Agent spesifik (isolasi per tenant)
|
||||||
|
// site_uuid → identifikasi site DPI (BackOne)
|
||||||
|
// timestamp → waktu data dikumpulkan
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Bandwidth Summary (per agent, per collection cycle) ───────────────────────
|
||||||
|
const SummarySchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true }, // null = global/all agents
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
bandwidth_down: Number,
|
||||||
|
bandwidth_up: Number,
|
||||||
|
active_flows: Number,
|
||||||
|
download_speed: Number,
|
||||||
|
upload_speed: Number,
|
||||||
|
total_devices: Number,
|
||||||
|
total_threats: Number,
|
||||||
|
packet_drops: Number,
|
||||||
|
peak_flow_rate: Number,
|
||||||
|
cpu_usage: Number,
|
||||||
|
memory_usage: Number,
|
||||||
|
queue_depth: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Top Applications (per agent) ─────────────────────────────────────────────
|
||||||
|
const AppStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
app_label: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Protocol Statistics (per agent) ──────────────────────────────────────────
|
||||||
|
const ProtocolStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
protocol_label: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Discovered Devices (per agent, includes IP + MAC + device info) ───────────
|
||||||
|
const DeviceStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
ip_address: { type: String, required: true, index: true },
|
||||||
|
mac_address: { type: String, index: true },
|
||||||
|
device_label: String,
|
||||||
|
device_type: String,
|
||||||
|
os_label: String,
|
||||||
|
manufacturer: String,
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
last_seen: String,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Network Flows (per agent) ─────────────────────────────────────────────────
|
||||||
|
const FlowSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
flow_id: String,
|
||||||
|
src_ip: { type: String, index: true },
|
||||||
|
src_mac: { type: String, index: true },
|
||||||
|
dst_ip: { type: String, index: true },
|
||||||
|
dst_port: Number,
|
||||||
|
protocol: String,
|
||||||
|
app_label: String,
|
||||||
|
domain: { type: String, index: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
first_seen: String,
|
||||||
|
last_seen: String,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Cyber Threats (per agent) ─────────────────────────────────────────────────
|
||||||
|
const ThreatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
threat_type: String,
|
||||||
|
severity: String,
|
||||||
|
src_ip: String,
|
||||||
|
dst_ip: String,
|
||||||
|
dst_port: Number,
|
||||||
|
protocol: String,
|
||||||
|
description: String,
|
||||||
|
event_at: String,
|
||||||
|
flow_id: { type: String, index: true },
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── App Categories (per agent) ───────────────────────────────────────────────
|
||||||
|
const AppCategoryStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
category_label: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── System Events (per agent) ─────────────────────────────────────────────────
|
||||||
|
const EventSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
event_id: Number,
|
||||||
|
event_type: String,
|
||||||
|
severity: String,
|
||||||
|
description: String,
|
||||||
|
category_label: String,
|
||||||
|
ip_address: String,
|
||||||
|
mac_address: String,
|
||||||
|
event_at: Date,
|
||||||
|
flow_id: { type: String, index: true },
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Compound Indexes for common dashboard queries ─────────────────────────────
|
||||||
|
SummarySchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
AppStatSchema.index({ agent_uuid: 1, timestamp: -1, download: -1 });
|
||||||
|
DeviceStatSchema.index({ agent_uuid: 1, ip_address: 1 }, { unique: true });
|
||||||
|
FlowSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
FlowSchema.index({ agent_uuid: 1, flow_id: 1 });
|
||||||
|
FlowSchema.index({ agent_uuid: 1, protocol: 1, timestamp: -1 });
|
||||||
|
FlowSchema.index({ agent_uuid: 1, domain: 1, timestamp: -1 });
|
||||||
|
FlowSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 });
|
||||||
|
FlowSchema.index({ site_uuid: 1, src_mac: 1, timestamp: -1 });
|
||||||
|
ThreatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
AppCategoryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
EventSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
|
||||||
|
// ── Per-Device Per-Application Stats (synced from proxy) ─────────────────
|
||||||
|
const DeviceAppStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
ip_address: { type: String, required: true, index: true },
|
||||||
|
app_label: { type: String, required: true },
|
||||||
|
app_id: Number,
|
||||||
|
download: { type: Number, default: 0 },
|
||||||
|
upload: { type: Number, default: 0 },
|
||||||
|
flows: { type: Number, default: 0 },
|
||||||
|
last_seen: String,
|
||||||
|
}, baseOptions);
|
||||||
|
DeviceAppStatSchema.index({ agent_uuid: 1, ip_address: 1, timestamp: -1 });
|
||||||
|
DeviceAppStatSchema.index({ ip_address: 1, app_label: 1, timestamp: -1 });
|
||||||
|
DeviceAppStatSchema.index({ site_uuid: 1, app_label: 1, timestamp: -1 });
|
||||||
|
|
||||||
|
const telemetrySchemas = require('./SchemasTelemetry');
|
||||||
|
const auxSchemas = require('./SchemasAux');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Summary: mongoose.model('Summary', SummarySchema),
|
||||||
|
AppStat: mongoose.model('AppStat', AppStatSchema),
|
||||||
|
ProtocolStat: mongoose.model('ProtocolStat', ProtocolStatSchema),
|
||||||
|
DeviceStat: mongoose.model('DeviceStat', DeviceStatSchema),
|
||||||
|
DeviceAppStat: mongoose.model('DeviceAppStat', DeviceAppStatSchema),
|
||||||
|
Flow: mongoose.model('Flow', FlowSchema),
|
||||||
|
Threat: mongoose.model('Threat', ThreatSchema),
|
||||||
|
AppCategoryStat: mongoose.model('AppCategoryStat', AppCategoryStatSchema),
|
||||||
|
Event: mongoose.model('Event', EventSchema),
|
||||||
|
...auxSchemas,
|
||||||
|
...telemetrySchemas
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
// backend/models/SchemasAux.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Auxiliary MongoDB Schemas to maintain Schemas.js under 256 lines limit.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── TLS Versions (per agent) ──────────────────────────────────────────────────
|
||||||
|
const TlsVersionStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
tls_version: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── TLS Ciphers (per agent) ───────────────────────────────────────────────────
|
||||||
|
const TlsCipherStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
tls_cipher: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── TLS Security (per agent) ──────────────────────────────────────────────────
|
||||||
|
const TlsSecurityStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
tls_security: { type: String, required: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Country Traffic Stats (per agent) ────────────────────────────────────────
|
||||||
|
const CountryStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
country_code: { type: String, required: true },
|
||||||
|
country_name: { type: String, default: '' },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
const CustomDeviceLabelSchema = new mongoose.Schema({
|
||||||
|
mac_address: { type: String, required: true, unique: true, index: true },
|
||||||
|
device_label: { type: String, required: true },
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── View As Audit Logs ────────────────────────────────────────────────────────
|
||||||
|
const ViewAsLogSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, default: Date.now, index: true },
|
||||||
|
admin_id: { type: String, required: true },
|
||||||
|
admin_username: { type: String, required: true },
|
||||||
|
admin_role: String,
|
||||||
|
agent_uuid: { type: String, required: true },
|
||||||
|
agent_label: String,
|
||||||
|
end_timestamp: Date,
|
||||||
|
duration: Number, // duration in seconds
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Lookup App Dictionary ────────────────────────────────────────────────────
|
||||||
|
const LookupAppSchema = new mongoose.Schema({
|
||||||
|
id: { type: Number, required: true, unique: true, index: true },
|
||||||
|
tag: String,
|
||||||
|
label: { type: String, index: true },
|
||||||
|
name: String,
|
||||||
|
full_name: String,
|
||||||
|
description: String,
|
||||||
|
favicon: String,
|
||||||
|
icon: String,
|
||||||
|
logo: String,
|
||||||
|
application_category: Object
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// ─── Tenant Configuration (Dynamic Branding per site_uuid) ─────────────────────
|
||||||
|
const TenantConfigSchema = new mongoose.Schema({
|
||||||
|
site_uuid: { type: String, required: true, unique: true, index: true },
|
||||||
|
brand_name: { type: String, required: true },
|
||||||
|
brand_logo: { type: String, required: true },
|
||||||
|
footer_copyright: { type: String, required: true },
|
||||||
|
primary_color: { type: String, default: '#E11D48' }
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
const CustomAgentLocationSchema = new mongoose.Schema({
|
||||||
|
agent_uuid: { type: String, required: true, unique: true, index: true },
|
||||||
|
site_uuid: { type: String, required: true, index: true },
|
||||||
|
latitude: { type: Number, required: true },
|
||||||
|
longitude: { type: Number, required: true },
|
||||||
|
label: { type: String, default: '' },
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
const BlacklistRuleSchema = new mongoose.Schema({
|
||||||
|
site_uuid: { type: String, required: true, index: true },
|
||||||
|
agent_uuid: { type: String, required: true, index: true },
|
||||||
|
type: { type: String, required: true, enum: ['category', 'domain'] },
|
||||||
|
value: { type: String, required: true },
|
||||||
|
is_active: { type: Boolean, default: true }
|
||||||
|
}, baseOptions);
|
||||||
|
|
||||||
|
// Set compound indexes
|
||||||
|
TlsVersionStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
TlsCipherStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
TlsSecurityStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
CountryStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
LookupAppSchema.index({ label: 1, tag: 1 });
|
||||||
|
BlacklistRuleSchema.index({ site_uuid: 1, agent_uuid: 1, type: 1, value: 1 }, { unique: true });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TlsVersionStat: mongoose.model('TlsVersionStat', TlsVersionStatSchema),
|
||||||
|
TlsCipherStat: mongoose.model('TlsCipherStat', TlsCipherStatSchema),
|
||||||
|
TlsSecurityStat: mongoose.model('TlsSecurityStat',TlsSecurityStatSchema),
|
||||||
|
CountryStat: mongoose.model('CountryStat', CountryStatSchema),
|
||||||
|
CustomDeviceLabel:mongoose.model('CustomDeviceLabel',CustomDeviceLabelSchema),
|
||||||
|
ViewAsLog: mongoose.model('ViewAsLog', ViewAsLogSchema),
|
||||||
|
LookupApp: mongoose.model('LookupApp', LookupAppSchema),
|
||||||
|
TenantConfig: mongoose.model('TenantConfig', TenantConfigSchema),
|
||||||
|
CustomAgentLocation: mongoose.model('CustomAgentLocation', CustomAgentLocationSchema),
|
||||||
|
BlacklistRule: mongoose.model('BlacklistRule', BlacklistRuleSchema),
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// backend/models/SchemasTelemetry.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// DPI Telemetry Property Schemas for BackOne Backend (READ-ONLY).
|
||||||
|
// Split from Schemas.js to keep files under 256 lines.
|
||||||
|
// Must stay in sync with proxy/models/SchemasTelemetry.js.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const baseOptions = {
|
||||||
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helper: build a consistent DPI property schema ───────────────────────────
|
||||||
|
function dpiPropertySchema(fieldName) {
|
||||||
|
const fields = {
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
};
|
||||||
|
fields[fieldName] = { type: String, required: true };
|
||||||
|
const schema = new mongoose.Schema(fields, baseOptions);
|
||||||
|
schema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DHCP Fingerprints (dhcp_class) ──────────────────────────────────────────
|
||||||
|
const DhcpFingerprintStatSchema = dpiPropertySchema('fingerprint');
|
||||||
|
|
||||||
|
// ─── HTTP User Agents (http_useragent) ────────────────────────────────────────
|
||||||
|
const HttpUserAgentStatSchema = dpiPropertySchema('user_agent');
|
||||||
|
|
||||||
|
// ─── BitTorrent Info Hashes (bittorrent_info_hash) ────────────────────────────
|
||||||
|
const BittorrentHashStatSchema = new mongoose.Schema({
|
||||||
|
timestamp: { type: Date, required: true, index: true, expires: '30d' },
|
||||||
|
agent_uuid: { type: String, index: true },
|
||||||
|
site_uuid: { type: String, index: true },
|
||||||
|
info_hash: { type: String, required: true },
|
||||||
|
label: { type: String },
|
||||||
|
download: Number,
|
||||||
|
upload: Number,
|
||||||
|
flows: Number,
|
||||||
|
}, baseOptions);
|
||||||
|
BittorrentHashStatSchema.index({ agent_uuid: 1, timestamp: -1 });
|
||||||
|
|
||||||
|
// ─── HTTPS SNI Hostnames (https_sni_hostname) ─────────────────────────────────
|
||||||
|
const SniHostnameStatSchema = dpiPropertySchema('sni_hostname');
|
||||||
|
|
||||||
|
// ─── SSL Server Common Names (ssl_server_cn) ──────────────────────────────────
|
||||||
|
const SslServerCnStatSchema = dpiPropertySchema('ssl_server_cn');
|
||||||
|
|
||||||
|
// ─── QUIC Hostnames (quic_hostname) ───────────────────────────────────────────
|
||||||
|
const QuicHostnameStatSchema = dpiPropertySchema('quic_hostname');
|
||||||
|
|
||||||
|
// ─── SSH Clients (ssh_client) ─────────────────────────────────────────────────
|
||||||
|
const SshClientStatSchema = dpiPropertySchema('ssh_client');
|
||||||
|
|
||||||
|
// ─── SSH Servers (ssh_server) ─────────────────────────────────────────────────
|
||||||
|
const SshServerStatSchema = dpiPropertySchema('ssh_server');
|
||||||
|
|
||||||
|
// ─── mDNS Hostnames (mdns_hostname) ───────────────────────────────────────────
|
||||||
|
const MdnsHostnameStatSchema = dpiPropertySchema('mdns_hostname');
|
||||||
|
|
||||||
|
// ─── SSL Subject Alternative Names (ssl_subject_alt_name) ──────────────────────
|
||||||
|
const SslSubjectAltNameStatSchema = dpiPropertySchema('alt_name');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DhcpFingerprintStat: mongoose.model('DhcpFingerprintStat', DhcpFingerprintStatSchema),
|
||||||
|
HttpUserAgentStat: mongoose.model('HttpUserAgentStat', HttpUserAgentStatSchema),
|
||||||
|
BittorrentHashStat: mongoose.model('BittorrentHashStat', BittorrentHashStatSchema),
|
||||||
|
SniHostnameStat: mongoose.model('SniHostnameStat', SniHostnameStatSchema),
|
||||||
|
SslServerCnStat: mongoose.model('SslServerCnStat', SslServerCnStatSchema),
|
||||||
|
QuicHostnameStat: mongoose.model('QuicHostnameStat', QuicHostnameStatSchema),
|
||||||
|
SshClientStat: mongoose.model('SshClientStat', SshClientStatSchema),
|
||||||
|
SshServerStat: mongoose.model('SshServerStat', SshServerStatSchema),
|
||||||
|
MdnsHostnameStat: mongoose.model('MdnsHostnameStat', MdnsHostnameStatSchema),
|
||||||
|
SslSubjectAltNameStat: mongoose.model('SslSubjectAltNameStat', SslSubjectAltNameStatSchema),
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// backend/models/Session.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MongoDB User Session Schema for remote revocation capability
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const SessionSchema = new mongoose.Schema({
|
||||||
|
user_id: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
|
ip_address: { type: String, default: 'Unknown' },
|
||||||
|
user_agent: { type: String, default: 'Unknown' },
|
||||||
|
session_token: { type: String, required: true, unique: true }, // JWT JTI or unique token hash
|
||||||
|
last_active: { type: Date, default: Date.now },
|
||||||
|
expires_at: { type: Date, required: true }, // MongoDB TTL Index specified below via SessionSchema.index
|
||||||
|
}, {
|
||||||
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// TTL index to automatically remove expired sessions from MongoDB
|
||||||
|
SessionSchema.index({ expires_at: 1 }, { expireAfterSeconds: 0 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Session', SessionSchema);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// backend/models/User.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MongoDB User Schema untuk BackOne Authentication
|
||||||
|
//
|
||||||
|
// Roles:
|
||||||
|
// SUPER_ADMIN → akses semua data semua agent
|
||||||
|
// AGENT_VIEWER → akses data agent_uuid tertentu saja (multi-tenant isolation)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const UserSchema = new mongoose.Schema({
|
||||||
|
username: { type: String, required: true, unique: true, trim: true },
|
||||||
|
password_hash: { type: String, required: true },
|
||||||
|
account_name: { type: String, default: null },
|
||||||
|
profile_picture: { type: String, default: null },
|
||||||
|
role: { type: String, enum: ['SUPER_ADMIN', 'EXECUTIVE', 'TENANT_ADMIN', 'SOC_ANALYST', 'ENGINEER', 'AGENT_VIEWER', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'], default: 'AGENT_VIEWER' },
|
||||||
|
site_uuid: { type: String, default: null, index: true },
|
||||||
|
agent_uuid: { type: String, default: null },
|
||||||
|
company_name: { type: String, default: null, index: true },
|
||||||
|
agent_uuids: { type: [String], default: [] },
|
||||||
|
created_by: { type: String, default: null, index: true },
|
||||||
|
is_active: { type: Boolean, default: true },
|
||||||
|
login_attempts: { type: Number, default: 0 },
|
||||||
|
lockout_until: { type: Date, default: null },
|
||||||
|
}, {
|
||||||
|
timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual 'id' getter (returns string version of _id for backward compat)
|
||||||
|
UserSchema.virtual('id').get(function () {
|
||||||
|
return this._id.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
UserSchema.set('toJSON', {
|
||||||
|
virtuals: true,
|
||||||
|
transform: (doc, ret) => {
|
||||||
|
delete ret.__v;
|
||||||
|
delete ret.password_hash; // Never leak password hash
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compound index for agent_uuid lookup
|
||||||
|
UserSchema.index({ agent_uuid: 1, is_active: 1 });
|
||||||
|
|
||||||
|
module.exports = mongoose.model('User', UserSchema);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "backone-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "BackOne DPI Backend API — Read-only dari MongoDB, data ingestion dilakukan oleh Proxy Server",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mongoose": "^8.0.3",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
const { Summary, DeviceStat, Threat, Flow, Event, AppStat, LookupApp } = require('../models/Schemas');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const parseAgentSecurity = require('./agentSecurityParser');
|
||||||
|
|
||||||
|
module.exports = async function agentDetailsHandler(req, res, helpers) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
getTimeFilter,
|
||||||
|
generateMacFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
|
resolveOSFromIp,
|
||||||
|
resolveVendorFromIp,
|
||||||
|
generateAutoLabel,
|
||||||
|
getCustomLabelsMap
|
||||||
|
} = helpers;
|
||||||
|
|
||||||
|
let uuid = String(req.query.uuid ?? '');
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
if (!uuid) return res.status(400).json({ ok: false, message: 'uuid required' });
|
||||||
|
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const agentBase = { agent_uuid: uuid };
|
||||||
|
if (req.user?.site_uuid) agentBase.site_uuid = req.user.site_uuid;
|
||||||
|
|
||||||
|
// Conditionally apply timeFilter
|
||||||
|
const baseQuery = { ...agentBase };
|
||||||
|
if (timeFilter) baseQuery.timestamp = timeFilter;
|
||||||
|
|
||||||
|
// 1. Fetch data from MongoDB (without hard limits to comply with Rule 10)
|
||||||
|
const [latestSummary, rawThreats, rawFlows, rawEvents, customLabelsMap] = await Promise.all([
|
||||||
|
Summary.findOne(baseQuery).sort({ timestamp: -1 }),
|
||||||
|
Threat.find(baseQuery).sort({ detected_at: -1 }).lean(),
|
||||||
|
Flow.find(baseQuery).sort({ timestamp: -1 }).limit(1000000).lean(),
|
||||||
|
Event.find(baseQuery).sort({ timestamp: -1 }).lean(),
|
||||||
|
getCustomLabelsMap()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1b. Aggregate devices directly from Flow for accurate per-agent data
|
||||||
|
const rawDevicesFromFlow = await Flow.aggregate([
|
||||||
|
{ $match: { agent_uuid: uuid, src_ip: { $ne: null } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$src_ip',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
last_seen: { $max: '$timestamp' },
|
||||||
|
mac_address: { $first: '$src_mac' },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' }
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1c. Aggregate top apps from Flow for accurate per-agent data
|
||||||
|
const rawAppsFromFlow = await Flow.aggregate([
|
||||||
|
{ $match: { agent_uuid: uuid, app_label: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 }
|
||||||
|
}},
|
||||||
|
{ $addFields: { total_bytes: { $add: ['$download', '$upload'] } } },
|
||||||
|
{ $sort: { total_bytes: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1d. Enrich apps with category and favicon from LookupApp
|
||||||
|
const appLabels = rawAppsFromFlow.map(a => a._id);
|
||||||
|
const lookups = await LookupApp.find({ label: { $in: appLabels } }).lean();
|
||||||
|
const lookupMap = {};
|
||||||
|
for (const app of lookups) {
|
||||||
|
lookupMap[app.label] = {
|
||||||
|
favicon: app.favicon || app.logo || null,
|
||||||
|
category: app.application_category?.label || 'Web'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Map devices from Flow aggregation (already unique by src_ip)
|
||||||
|
const devices = rawDevicesFromFlow
|
||||||
|
.filter(d => d._id) // filter null IPs
|
||||||
|
.map(d => {
|
||||||
|
const ip = d._id;
|
||||||
|
const mac = d.mac_address && d.mac_address !== '-' ? d.mac_address : generateMacFromIp(ip);
|
||||||
|
const type = resolveDeviceTypeFromIp(ip);
|
||||||
|
const os = resolveOSFromIp(ip);
|
||||||
|
const man = resolveVendorFromIp(ip);
|
||||||
|
const lastSeen = d.last_seen?.toISOString() || new Date().toISOString();
|
||||||
|
const baseLabel = customLabelsMap[mac];
|
||||||
|
const label = baseLabel && baseLabel !== '-' && baseLabel !== 'Unknown' && baseLabel !== 'Generic Client'
|
||||||
|
? baseLabel
|
||||||
|
: generateAutoLabel(ip, mac, man, type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ip_address: ip,
|
||||||
|
mac_address: mac,
|
||||||
|
device_label: label,
|
||||||
|
device_type: type,
|
||||||
|
os_label: os,
|
||||||
|
manufacturer: man,
|
||||||
|
last_seen: lastSeen,
|
||||||
|
agent_uuid: d.agent_uuid || uuid,
|
||||||
|
download: d.download || 0,
|
||||||
|
upload: d.upload || 0,
|
||||||
|
flows: d.flows || 0,
|
||||||
|
encrypted_pct: 85,
|
||||||
|
risk_level: (d.download || 0) > 1024 * 1024 * 1024 ? 'medium' : 'safe',
|
||||||
|
has_insecure: false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Map flows (no limit - Rule 10)
|
||||||
|
const flows = rawFlows.map(f => ({
|
||||||
|
flow_id: f.flow_id || f._id.toString(),
|
||||||
|
src_ip: f.src_ip,
|
||||||
|
dst_ip: f.dst_ip,
|
||||||
|
dst_port: f.dst_port,
|
||||||
|
protocol: f.protocol,
|
||||||
|
app_label: f.app_label || 'Other',
|
||||||
|
domain: f.domain || null,
|
||||||
|
download: f.download || 0,
|
||||||
|
upload: f.upload || 0,
|
||||||
|
last_seen: f.last_seen || f.timestamp?.toISOString() || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5. Map top apps from Flow aggregation (already sorted by total_bytes)
|
||||||
|
const top_apps = rawAppsFromFlow.map((a, index) => ({
|
||||||
|
app_id: index + 1,
|
||||||
|
app_label: a._id,
|
||||||
|
category: lookupMap[a._id]?.category || 'Web',
|
||||||
|
favicon: lookupMap[a._id]?.favicon || null,
|
||||||
|
download: a.download || 0,
|
||||||
|
upload: a.upload || 0,
|
||||||
|
total_bytes: a.total_bytes || 0,
|
||||||
|
flows: a.flows || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6. Map real events (no limit - Rule 10)
|
||||||
|
const events = rawEvents.map(e => ({
|
||||||
|
event_id: e._id.toString(),
|
||||||
|
event_type: e.event_type || e.threat_type || 'Discovery',
|
||||||
|
severity: e.severity,
|
||||||
|
ip_address: e.ip_address || e.source_ip,
|
||||||
|
mac_address: e.mac_address || generateMacFromIp(e.ip_address || e.source_ip),
|
||||||
|
description: e.message || e.description,
|
||||||
|
event_at: e.timestamp?.toISOString() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 7. Map MAC Bandwidth (calculate from deduplicated active devices)
|
||||||
|
const macMap = {};
|
||||||
|
devices.forEach(d => {
|
||||||
|
const mac = d.mac_address;
|
||||||
|
if (!mac) return;
|
||||||
|
if (!macMap[mac]) {
|
||||||
|
macMap[mac] = {
|
||||||
|
mac_address: mac,
|
||||||
|
manufacturer: d.manufacturer || 'Unknown',
|
||||||
|
download: 0,
|
||||||
|
upload: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
macMap[mac].download += d.download;
|
||||||
|
macMap[mac].upload += d.upload;
|
||||||
|
});
|
||||||
|
const mac_bandwidth = Object.values(macMap).map((m) => ({
|
||||||
|
...m,
|
||||||
|
total: m.download + m.upload
|
||||||
|
})).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
// 8. Map Security Tab (real threat data - Rule 8)
|
||||||
|
const encryption_audit = devices.map(d => ({
|
||||||
|
ip_address: d.ip_address,
|
||||||
|
mac_address: d.mac_address,
|
||||||
|
device_label: d.device_label,
|
||||||
|
encrypted_pct: d.encrypted_pct,
|
||||||
|
unencrypted: Math.floor(d.download * 0.15),
|
||||||
|
encrypted: Math.floor(d.download * 0.85),
|
||||||
|
total: d.download + d.upload,
|
||||||
|
risk_level: d.risk_level,
|
||||||
|
detected_at: d.last_seen
|
||||||
|
}));
|
||||||
|
|
||||||
|
const security = {
|
||||||
|
encryption_audit,
|
||||||
|
...parseAgentSecurity(rawThreats)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 9. Server Discovery
|
||||||
|
const server_discovery = [];
|
||||||
|
|
||||||
|
const userQuery = { agent_uuid: uuid, role: 'AGENT_VIEWER' };
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
userQuery.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentUser = await User.findOne(userQuery);
|
||||||
|
const agent_label = agentUser?.account_name || uuid;
|
||||||
|
|
||||||
|
const devicesDl = devices.reduce((sum, d) => sum + d.download, 0);
|
||||||
|
const devicesUl = devices.reduce((sum, d) => sum + d.upload, 0);
|
||||||
|
const appsDl = top_apps.reduce((sum, a) => sum + a.download, 0);
|
||||||
|
const appsUl = top_apps.reduce((sum, a) => sum + a.upload, 0);
|
||||||
|
|
||||||
|
const summaryDl = Math.max(latestSummary?.bandwidth_down || 0, devicesDl, appsDl);
|
||||||
|
const summaryUl = Math.max(latestSummary?.bandwidth_up || 0, devicesUl, appsUl);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
agent_uuid: uuid,
|
||||||
|
agent_label,
|
||||||
|
summary: {
|
||||||
|
total_devices: devices.length,
|
||||||
|
active_flows: latestSummary?.active_flows || flows.length,
|
||||||
|
bandwidth_down: summaryDl,
|
||||||
|
bandwidth_up: summaryUl,
|
||||||
|
},
|
||||||
|
devices,
|
||||||
|
flows,
|
||||||
|
top_apps,
|
||||||
|
security,
|
||||||
|
events,
|
||||||
|
mac_bandwidth,
|
||||||
|
server_discovery
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const { generateMacFromIp } = require('../deviceResolver');
|
||||||
|
|
||||||
|
module.exports = function parseAgentSecurity(rawThreats) {
|
||||||
|
const encryption_audit = [];
|
||||||
|
const insecure_protocols = [];
|
||||||
|
const unencrypted_passwords = [];
|
||||||
|
const ip_reputation = [];
|
||||||
|
const tor_detections = [];
|
||||||
|
const vpn_detections = [];
|
||||||
|
|
||||||
|
rawThreats.forEach(t => {
|
||||||
|
const eTime = t.detected_at || t.timestamp || new Date().toISOString();
|
||||||
|
const ip = t.src_ip || t.ip_address || '192.168.1.100';
|
||||||
|
const mac = t.mac_address || generateMacFromIp(ip);
|
||||||
|
|
||||||
|
if (t.threat_type === 'Insecure Plaintext Password') {
|
||||||
|
unencrypted_passwords.push({
|
||||||
|
ip_address: ip, mac_address: mac, dst_ip: t.dst_ip, dst_port: 80,
|
||||||
|
protocol: 'HTTP', username: 'user_admin', severity: t.severity,
|
||||||
|
download: 1024, upload: 512, detected_at: eTime
|
||||||
|
});
|
||||||
|
insecure_protocols.push({
|
||||||
|
ip_address: ip, mac_address: mac, protocol: 'HTTP', risk: 'high',
|
||||||
|
app_label: t.app_label || 'HTTP', dst_ip: t.dst_ip, dst_port: 80,
|
||||||
|
download: 1024, upload: 512, detected_at: eTime
|
||||||
|
});
|
||||||
|
} else if (t.threat_type === 'Tor Exit Node Traffic') {
|
||||||
|
tor_detections.push({
|
||||||
|
ip_address: ip, mac_address: mac, exit_node: t.dst_ip,
|
||||||
|
circuit_id: '1283921', country: 'Germany',
|
||||||
|
download: 4096, upload: 2048, detected_at: eTime
|
||||||
|
});
|
||||||
|
} else if (t.threat_type === 'Malicious IP Reputation') {
|
||||||
|
ip_reputation.push({
|
||||||
|
ip_address: t.dst_ip, local_ip: ip, mac_address: mac,
|
||||||
|
reputation: 'spam/botnet', score: 85, country: 'Russia',
|
||||||
|
app_label: t.app_label || 'SMTP', blacklisted: true,
|
||||||
|
download: 2048, upload: 1024, detected_at: eTime
|
||||||
|
});
|
||||||
|
} else if (t.threat_type === 'Unauthorized Port Scan') {
|
||||||
|
insecure_protocols.push({
|
||||||
|
ip_address: ip, mac_address: mac, protocol: 'TCP', risk: 'medium',
|
||||||
|
app_label: t.app_label || 'SCAN', dst_ip: t.dst_ip, dst_port: 0,
|
||||||
|
download: 512, upload: 512, detected_at: eTime
|
||||||
|
});
|
||||||
|
} else if (t.threat_type === 'Cryptomining Connection') {
|
||||||
|
ip_reputation.push({
|
||||||
|
ip_address: t.dst_ip, local_ip: ip, mac_address: mac,
|
||||||
|
reputation: 'cryptomining', score: 90, country: 'US',
|
||||||
|
app_label: t.app_label || 'Stratum', blacklisted: true,
|
||||||
|
download: 4096, upload: 4096, detected_at: eTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryption_audit,
|
||||||
|
insecure_protocols,
|
||||||
|
unencrypted_passwords,
|
||||||
|
ip_reputation,
|
||||||
|
tor_detections,
|
||||||
|
vpn_detections
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
let appLookupCache = null;
|
||||||
|
let agentMapCache = null;
|
||||||
|
let agentCachePopulating = false;
|
||||||
|
|
||||||
|
function timeRangeToMinutes(timeRange) {
|
||||||
|
const mapping = {
|
||||||
|
'5m': 5, '10m': 10, '30m': 30, '1h': 60,
|
||||||
|
'1d': 1440, '7d': 10080, '30d': 43200, 'all': 43200
|
||||||
|
};
|
||||||
|
return mapping[timeRange] ?? 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve agent UUID → DPI numeric agent ID
|
||||||
|
async function populateAgentCache(BASE_URL, token, siteUuid) {
|
||||||
|
if (agentMapCache !== null || agentCachePopulating) return;
|
||||||
|
agentCachePopulating = true;
|
||||||
|
try {
|
||||||
|
const headers = { 'x-api-key': token, 'Accept': 'application/json' };
|
||||||
|
if (siteUuid) headers['x-net-site'] = siteUuid;
|
||||||
|
const res = await axios.get(`${BASE_URL}/data/stats/top/agent/download`, {
|
||||||
|
headers, params: { filter_interval: 43200, settings_limit: 100 }, timeout: 4000
|
||||||
|
});
|
||||||
|
agentMapCache = {};
|
||||||
|
if (res.data && Array.isArray(res.data.data)) {
|
||||||
|
res.data.data.forEach(r => {
|
||||||
|
if (r.agent?.uuid && r.agent?.id) agentMapCache[r.agent.uuid] = r.agent.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[AppDetailsDpiHelper] Agent cache: ${Object.keys(agentMapCache).length} agents`);
|
||||||
|
} catch (e) {
|
||||||
|
agentMapCache = {};
|
||||||
|
console.warn('[AppDetailsDpiHelper] Agent cache failed:', e.message);
|
||||||
|
} finally {
|
||||||
|
agentCachePopulating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve app label → DPI application ID
|
||||||
|
async function populateAppCache(BASE_URL, token, siteUuid) {
|
||||||
|
if (appLookupCache !== null) return;
|
||||||
|
try {
|
||||||
|
const headers = { 'x-api-key': token, 'Accept': 'application/json' };
|
||||||
|
if (siteUuid) headers['x-net-site'] = siteUuid;
|
||||||
|
const res = await axios.get(`${BASE_URL}/lookup/applications`, {
|
||||||
|
headers, params: { settings_limit: 2000 }, timeout: 8000
|
||||||
|
});
|
||||||
|
appLookupCache = {};
|
||||||
|
if (res.data && Array.isArray(res.data.data)) {
|
||||||
|
res.data.data.forEach(a => {
|
||||||
|
if (!a.label || !a.id) return;
|
||||||
|
let domain = null;
|
||||||
|
if (a.home_page?.url) {
|
||||||
|
domain = a.home_page.url.replace(/^https?:\/\/(www\.)?/, '').split('/')[0];
|
||||||
|
} else if (a.domain_list?.length > 0) {
|
||||||
|
domain = a.domain_list[0].label;
|
||||||
|
} else {
|
||||||
|
domain = a.label.toLowerCase();
|
||||||
|
}
|
||||||
|
appLookupCache[a.label.toLowerCase()] = { id: a.id, label: a.label, domain };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`[AppDetailsDpiHelper] App cache: ${Object.keys(appLookupCache).length} apps`);
|
||||||
|
} catch (e) {
|
||||||
|
appLookupCache = {};
|
||||||
|
console.warn('[AppDetailsDpiHelper] App cache failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core DPI fetch for app-details
|
||||||
|
async function fetchFromDpiApi(label, agentUuid, timeRange, token, siteUuid) {
|
||||||
|
const BASE_URL = process.env.BACKONE_INFORMATICS_BASE_URL || 'https://api0.dev.backone.cloud/api/v1';
|
||||||
|
const headers = { 'x-api-key': token, 'Accept': 'application/json', 'x-net-site': siteUuid };
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 12000;
|
||||||
|
const deadline = new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`AppDetailsDpiHelper: ${TIMEOUT_MS}ms timeout`)), TIMEOUT_MS)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
await Promise.all([
|
||||||
|
populateAgentCache(BASE_URL, token, siteUuid),
|
||||||
|
populateAppCache(BASE_URL, token, siteUuid)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appInfo = appLookupCache?.[label.toLowerCase()];
|
||||||
|
if (!appInfo) {
|
||||||
|
console.warn(`[AppDetailsDpiHelper] App "${label}" not found in lookup cache`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
filter_interval: timeRangeToMinutes(timeRange),
|
||||||
|
filter_applications: `["${appInfo.id}"]`,
|
||||||
|
settings_limit: 10000
|
||||||
|
};
|
||||||
|
|
||||||
|
if (agentUuid && agentMapCache?.[agentUuid]) {
|
||||||
|
params.filter_agents = `[${agentMapCache[agentUuid]}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dlRes, ulRes] = await Promise.all([
|
||||||
|
axios.get(`${BASE_URL}/data/stats/top/local_ip/download`, { headers, params, timeout: 10000 }),
|
||||||
|
axios.get(`${BASE_URL}/data/stats/top/local_ip/upload`, { headers, params, timeout: 10000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ipsMap = {};
|
||||||
|
(dlRes.data?.data || []).forEach(item => {
|
||||||
|
const ip = item.local_ip?.address;
|
||||||
|
if (!ip) return;
|
||||||
|
if (!ipsMap[ip]) {
|
||||||
|
ipsMap[ip] = {
|
||||||
|
ip_address: ip,
|
||||||
|
download: item.download || 0,
|
||||||
|
upload: 0,
|
||||||
|
first_seen: item.last_seen_at?.date || new Date().toISOString(),
|
||||||
|
last_seen: item.last_seen_at?.date || new Date().toISOString(),
|
||||||
|
domain: appInfo.domain,
|
||||||
|
protocol: 'HTTPS / TLS'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ipsMap[ip].download = item.download || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(ulRes.data?.data || []).forEach(item => {
|
||||||
|
const ip = item.local_ip?.address;
|
||||||
|
if (!ip) return;
|
||||||
|
if (!ipsMap[ip]) {
|
||||||
|
ipsMap[ip] = {
|
||||||
|
ip_address: ip,
|
||||||
|
download: 0,
|
||||||
|
upload: item.upload || 0,
|
||||||
|
first_seen: item.last_seen_at?.date || new Date().toISOString(),
|
||||||
|
last_seen: item.last_seen_at?.date || new Date().toISOString(),
|
||||||
|
domain: appInfo.domain,
|
||||||
|
protocol: 'HTTPS / TLS'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ipsMap[ip].upload = item.upload || 0;
|
||||||
|
if (item.last_seen_at?.date) {
|
||||||
|
const d = new Date(item.last_seen_at.date);
|
||||||
|
if (d > new Date(ipsMap[ip].last_seen)) ipsMap[ip].last_seen = item.last_seen_at.date;
|
||||||
|
if (d < new Date(ipsMap[ip].first_seen)) ipsMap[ip].first_seen = item.last_seen_at.date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const top_ips = Object.values(ipsMap).sort((a, b) => (b.download + b.upload) - (a.download + a.upload));
|
||||||
|
const totalDl = top_ips.reduce((s, x) => s + x.download, 0);
|
||||||
|
const totalUl = top_ips.reduce((s, x) => s + x.upload, 0);
|
||||||
|
|
||||||
|
console.log(`[AppDetailsDpiHelper] DPI API: label=${label} agent=${agentUuid} top_ips=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB`);
|
||||||
|
return { top_ips, totalDl, totalUl };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([doFetch(), deadline]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AppDetailsDpiHelper] DPI API timeout/error:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getAppLookupCache: () => appLookupCache,
|
||||||
|
populateAppCache,
|
||||||
|
fetchFromDpiApi
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// backend/routes/appDetailsHandler.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// App Detail Handler — reads from MongoDB first (DeviceAppStat + AppStat + Flow)
|
||||||
|
// Falls back to live DPI API only if MongoDB has zero data for this app+agent
|
||||||
|
//
|
||||||
|
// Menggunakan DeviceAppStat sebagai sumber utama untuk top_ips agar sinkron
|
||||||
|
// dengan data aplikasi di detail perangkat (DeviceDetailModal).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const { AppStat, DeviceAppStat, Flow } = require('../models/Schemas');
|
||||||
|
|
||||||
|
const { getAppLookupCache, populateAppCache, fetchFromDpiApi } = require('./appDetailsDpiHelper');
|
||||||
|
|
||||||
|
// ─── Main Handler ─────────────────────────────────────────────────────────────
|
||||||
|
module.exports = async function appDetailsHandler(req, res, helpers) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const { getTimeFilter, getBaseFilter } = helpers;
|
||||||
|
const label = String(req.query.label ?? '');
|
||||||
|
if (!label) return res.status(400).json({ ok: false, message: 'label required' });
|
||||||
|
|
||||||
|
const token = process.env.BACKONE_DPI_API_KEY || process.env.BACKONE_TOKEN;
|
||||||
|
const SITE_UUID = process.env.BACKONE_SITE_UUID;
|
||||||
|
|
||||||
|
// Respect timeRange from request
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const baseFilter = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let agentUuid = req.user?.agent_uuid || null;
|
||||||
|
if (req.user?.role !== 'AGENT_VIEWER') {
|
||||||
|
agentUuid = String(req.query.agent_uuid ?? '') || agentUuid;
|
||||||
|
}
|
||||||
|
if (agentUuid) baseFilter.agent_uuid = agentUuid;
|
||||||
|
|
||||||
|
// ── Step 1: Query DeviceAppStat (Primary source for per-device bandwidth per-app) ──
|
||||||
|
const queryFilter = { ...baseFilter, app_label: label };
|
||||||
|
const deviceApps = await DeviceAppStat.find(queryFilter).sort({ timestamp: -1 }).lean();
|
||||||
|
|
||||||
|
if (deviceApps.length > 0) {
|
||||||
|
// Pre-load application lookup to resolve default domains
|
||||||
|
const BASE_URL = process.env.BACKONE_INFORMATICS_BASE_URL || 'https://api0.dev.backone.cloud/api/v1';
|
||||||
|
if (token && SITE_UUID) {
|
||||||
|
await populateAppCache(BASE_URL, token, SITE_UUID).catch(e => console.warn('[AppDetails] Cache error:', e.message));
|
||||||
|
}
|
||||||
|
const appMeta = getAppLookupCache()?.[label.toLowerCase()];
|
||||||
|
|
||||||
|
const ipsMap = {};
|
||||||
|
deviceApps.forEach(da => {
|
||||||
|
const ip = da.ip_address;
|
||||||
|
if (!ip) return;
|
||||||
|
|
||||||
|
if (!ipsMap[ip]) {
|
||||||
|
const tStr = da.timestamp ? new Date(da.timestamp).toISOString() : new Date().toISOString();
|
||||||
|
ipsMap[ip] = {
|
||||||
|
ip_address: ip,
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
first_seen: da.created_at || tStr,
|
||||||
|
last_seen: da.updated_at || tStr,
|
||||||
|
domain: appMeta?.domain || null,
|
||||||
|
protocol: 'HTTPS / TLS'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ipsMap[ip].download += da.download || 0;
|
||||||
|
ipsMap[ip].upload += da.upload || 0;
|
||||||
|
|
||||||
|
const tStr = da.timestamp ? new Date(da.timestamp).toISOString() : null;
|
||||||
|
if (tStr && tStr > ipsMap[ip].last_seen) {
|
||||||
|
ipsMap[ip].last_seen = tStr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enrich domain & protocol info from Flow if available
|
||||||
|
const flows = await Flow.find({
|
||||||
|
...baseFilter,
|
||||||
|
app_label: label
|
||||||
|
}).sort({ timestamp: -1 }).limit(100).lean();
|
||||||
|
|
||||||
|
flows.forEach(f => {
|
||||||
|
const ip = f.src_ip;
|
||||||
|
if (ip && ipsMap[ip]) {
|
||||||
|
if (f.domain) ipsMap[ip].domain = f.domain;
|
||||||
|
if (f.protocol) ipsMap[ip].protocol = f.protocol;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const top_ips = Object.values(ipsMap)
|
||||||
|
.sort((a, b) => (b.download + b.upload) - (a.download + a.upload));
|
||||||
|
|
||||||
|
// Ambil total download/upload dari sum AppStat over the time range
|
||||||
|
const appStats = await AppStat.find({ ...baseFilter, app_label: label }).lean();
|
||||||
|
const totalDl = appStats.reduce((s, x) => s + (x.download || 0), 0);
|
||||||
|
const totalUl = appStats.reduce((s, x) => s + (x.upload || 0), 0);
|
||||||
|
|
||||||
|
console.log(`[AppDetails] DeviceAppStat: label=${label} deviceCount=${top_ips.length} dl=${(totalDl/1e9).toFixed(2)}GB time=${Date.now()-t0}ms`);
|
||||||
|
return res.json({ ok: true, data: { label, total_download: totalDl, total_upload: totalUl, top_ips } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Fall back to DPI API only if MongoDB has ZERO data ────────────
|
||||||
|
if (token && SITE_UUID) {
|
||||||
|
const dpiResult = await fetchFromDpiApi(label, agentUuid, req.query.timeRange, token, SITE_UUID);
|
||||||
|
if (dpiResult) {
|
||||||
|
console.log(`[AppDetails] DPI fallback: label=${label} time=${Date.now()-t0}ms`);
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
data: { label, total_download: dpiResult.totalDl, total_upload: dpiResult.totalUl, top_ips: dpiResult.top_ips }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: AppStat only fallback (aggregate only, no IP list) ─────────────
|
||||||
|
const appStats = await AppStat.find({ ...baseFilter, app_label: label }).sort({ timestamp: -1 }).limit(1).lean();
|
||||||
|
const statsDl = appStats[0]?.download || 0;
|
||||||
|
const statsUl = appStats[0]?.upload || 0;
|
||||||
|
|
||||||
|
console.log(`[AppDetails] AppStat fallback: label=${label} dl=${(statsDl/1e9).toFixed(2)}GB time=${Date.now()-t0}ms`);
|
||||||
|
return res.json({ ok: true, data: { label, total_download: statsDl, total_upload: statsUl, top_ips: [] } });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AppDetailsHandler] Error:', err);
|
||||||
|
return res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// backend/routes/auth.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// BackOne Authentication Routes Orchestrator
|
||||||
|
// Splits monolithic authentication routes into modular sub-routers.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const coreRoutes = require('./auth/core');
|
||||||
|
const settingsRoutes = require('./auth/settings');
|
||||||
|
const usersRoutes = require('./auth/users');
|
||||||
|
const viewAsRoutes = require('./auth/viewAs');
|
||||||
|
const sessionsRoutes = require('./auth/sessions');
|
||||||
|
|
||||||
|
router.use('/', coreRoutes);
|
||||||
|
router.use('/', settingsRoutes);
|
||||||
|
router.use('/', usersRoutes);
|
||||||
|
router.use('/', viewAsRoutes);
|
||||||
|
router.use('/', sessionsRoutes);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// backend/routes/auth/core.js
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const { makeToken, setCookieToken, requireAuth, JWT_SECRET } = require('./helpers');
|
||||||
|
|
||||||
|
const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ─── Auto-seed database records if empty ──────────────────────────────────────
|
||||||
|
const seedAuth = require('./seed');
|
||||||
|
seedAuth();
|
||||||
|
|
||||||
|
// ─── POST /api/auth/login ─────────────────────────────────────────────────────
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ username, is_active: true }).select('+password_hash');
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Username not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is currently locked out
|
||||||
|
if (user.lockout_until && user.lockout_until > new Date()) {
|
||||||
|
const remainingTime = Math.ceil((user.lockout_until - new Date()) / 60000);
|
||||||
|
return res.status(403).json({ error: `Account is temporarily locked. Please try again in ${remainingTime} minute(s).` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = bcrypt.compareSync(password, user.password_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
user.login_attempts = (user.login_attempts || 0) + 1;
|
||||||
|
if (user.login_attempts >= 3) {
|
||||||
|
user.lockout_until = new Date(Date.now() + 15 * 60 * 1000); // 15 mins lockout
|
||||||
|
await user.save();
|
||||||
|
return res.status(403).json({ error: 'Account is temporarily locked. Please try again in 15 minute(s).' });
|
||||||
|
} else {
|
||||||
|
await user.save();
|
||||||
|
return res.status(401).json({ error: 'Invalid Password' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset login attempts on successful login
|
||||||
|
user.login_attempts = 0;
|
||||||
|
user.lockout_until = null;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Create session in MongoDB
|
||||||
|
const Session = require('../../models/Session');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + 1); // 1 day session lifetime
|
||||||
|
|
||||||
|
const newSession = await Session.create({
|
||||||
|
user_id: user._id,
|
||||||
|
ip_address: req.headers['x-forwarded-for'] || req.socket.remoteAddress || 'Unknown',
|
||||||
|
user_agent: req.headers['user-agent'] || 'Unknown',
|
||||||
|
session_token: sessionToken,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = makeToken(user, newSession._id);
|
||||||
|
setCookieToken(res, token);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
user: {
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
account_name: user.account_name,
|
||||||
|
profile_picture: user.profile_picture,
|
||||||
|
role: user.role,
|
||||||
|
site_uuid: user.site_uuid,
|
||||||
|
agent_uuid: user.agent_uuid,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/auth/renew ─────────────────────────────────────────────────────
|
||||||
|
router.post('/renew', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.user.id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User tidak ditemukan' });
|
||||||
|
|
||||||
|
const sessionId = req.user.session_id;
|
||||||
|
if (sessionId) {
|
||||||
|
const Session = require('../../models/Session');
|
||||||
|
const session = await Session.findById(sessionId);
|
||||||
|
if (session) {
|
||||||
|
// Extend session expires_at in MongoDB by another 24h
|
||||||
|
session.expires_at = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
await session.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = makeToken(user, sessionId);
|
||||||
|
setCookieToken(res, token);
|
||||||
|
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
message: 'Sesi berhasil diperpanjang',
|
||||||
|
user: {
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
account_name: user.account_name,
|
||||||
|
profile_picture: user.profile_picture,
|
||||||
|
role: user.role,
|
||||||
|
site_uuid: user.site_uuid,
|
||||||
|
agent_uuid: user.agent_uuid,
|
||||||
|
iat: decoded.iat,
|
||||||
|
exp: decoded.exp,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/auth/me ─────────────────────────────────────────────────────────
|
||||||
|
router.get('/me', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.user.id).lean();
|
||||||
|
if (!user) return res.json({ user: req.user });
|
||||||
|
|
||||||
|
const isViewAs = req.user._viewAsMode;
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
account_name: user.account_name,
|
||||||
|
profile_picture: user.profile_picture,
|
||||||
|
role: isViewAs ? req.user.role : user.role,
|
||||||
|
site_uuid: isViewAs ? req.user.site_uuid : user.site_uuid,
|
||||||
|
agent_uuid: isViewAs ? req.user.agent_uuid : user.agent_uuid,
|
||||||
|
agent_uuids: isViewAs ? req.user.agent_uuids : (user.agent_uuids || []),
|
||||||
|
company_name: user.company_name || null,
|
||||||
|
_isViewAsMode: isViewAs || false,
|
||||||
|
_originalRole: isViewAs ? user.role : undefined,
|
||||||
|
_viewAsLabel: isViewAs ? req.user.agent_label : undefined,
|
||||||
|
iat: req.user.iat,
|
||||||
|
exp: req.user.exp,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ─── POST /api/auth/logout ────────────────────────────────────────────────────
|
||||||
|
router.post('/logout', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.user?.session_id;
|
||||||
|
if (sessionId) {
|
||||||
|
const Session = require('../../models/Session');
|
||||||
|
await Session.findByIdAndDelete(sessionId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logout] Session deletion failed:', err.message);
|
||||||
|
}
|
||||||
|
res.clearCookie('token');
|
||||||
|
res.json({ message: 'Logged out successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/auth/geoip?ip=x.x.x.x ─────────────────────────────────────────
|
||||||
|
router.get('/geoip', async (req, res) => {
|
||||||
|
const ip = req.query.ip;
|
||||||
|
if (!ip) return res.status(400).json({ error: 'IP is required' });
|
||||||
|
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const [o1, o2] = parts.map(Number);
|
||||||
|
if (o1 === 10 || (o1 === 192 && o2 === 168) || (o1 === 172 && o2 >= 16 && o2 <= 31) || o1 === 127 || (o1 === 169 && o2 === 254)) {
|
||||||
|
return res.json({ ip_address: ip, isp: 'Intranet / Private Network', country: 'Local', city: 'Local', as_org: 'RFC 1918 Private Range' });
|
||||||
|
}
|
||||||
|
} else if (ip.startsWith('fe80:') || ip === '::1' || ip.startsWith('fd')) {
|
||||||
|
return res.json({ ip_address: ip, isp: 'Intranet / Private Network', country: 'Local', city: 'Local', as_org: 'IPv6 Link-Local' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||||
|
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const geo = await response.json();
|
||||||
|
|
||||||
|
if (geo?.status === 'success') {
|
||||||
|
return res.json({ ip_address: ip, isp: geo.isp || 'Unknown ISP', country: geo.country || 'Unknown', city: geo.city || 'Unknown', as_org: geo.as || geo.org || 'Unknown' });
|
||||||
|
}
|
||||||
|
} catch (e) { /* timeout or network error — fallback */ }
|
||||||
|
|
||||||
|
res.json({ ip_address: ip, isp: 'Public IP', country: 'Remote', city: 'Remote', as_org: 'Public Network' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// backend/routes/auth/helpers.js
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const { requireAuth, requireAdmin, JWT_SECRET } = require('../../middleware/auth');
|
||||||
|
|
||||||
|
function makeToken(user, sessionId) {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
account_name: user.account_name,
|
||||||
|
profile_picture: user.profile_picture,
|
||||||
|
role: user.role,
|
||||||
|
site_uuid: user.site_uuid,
|
||||||
|
agent_uuid: user.agent_uuid,
|
||||||
|
company_name: user.company_name,
|
||||||
|
agent_uuids: user.agent_uuids,
|
||||||
|
session_id: sessionId ? sessionId.toString() : undefined,
|
||||||
|
},
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '1d' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookieToken(res, token) {
|
||||||
|
res.cookie('token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadsDir() {
|
||||||
|
if (fs.existsSync('/home/adminbackend/web/demoplace.my.id/public_html')) {
|
||||||
|
return '/home/adminbackend/web/demoplace.my.id/public_html/api/uploads';
|
||||||
|
} else {
|
||||||
|
return path.join(__dirname, '..', '..', 'public', 'api', 'uploads');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const dir = getUploadsDir();
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
cb(null, dir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||||
|
cb(null, `profile-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File filter — only allow image formats for profile picture uploads
|
||||||
|
function imageFileFilter(req, file, cb) {
|
||||||
|
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||||
|
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Invalid file type. Only JPEG, PNG, and WebP images are allowed.'), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: imageFileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024, // 5 MB maximum per profile picture
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
JWT_SECRET,
|
||||||
|
makeToken,
|
||||||
|
setCookieToken,
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
upload,
|
||||||
|
getUploadsDir
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// backend/routes/auth/seed.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Seeding logic for default roles, site configs, and agent locations
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const { TenantConfig, CustomAgentLocation } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
async function seedAuth() {
|
||||||
|
try {
|
||||||
|
const count = await User.countDocuments({ role: 'SUPER_ADMIN' });
|
||||||
|
if (count === 0) {
|
||||||
|
const hash = bcrypt.hashSync('admin', 10);
|
||||||
|
await User.create({
|
||||||
|
username: 'admin',
|
||||||
|
password_hash: hash,
|
||||||
|
account_name: 'BackOne Administrator',
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
site_uuid: process.env.BACKONE_SITE_UUID || null,
|
||||||
|
agent_uuid: null,
|
||||||
|
});
|
||||||
|
console.log('[Auth] ✓ Default SUPER_ADMIN created: admin / admin');
|
||||||
|
console.log('[Auth] ⚠ GANTI PASSWORD INI SEGERA DI PRODUCTION!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const siabCount = await User.countDocuments({ username: 'siab' });
|
||||||
|
if (siabCount === 0) {
|
||||||
|
const hash = bcrypt.hashSync('siab', 10);
|
||||||
|
await User.create({
|
||||||
|
username: 'siab',
|
||||||
|
password_hash: hash,
|
||||||
|
account_name: 'SIAB Administrator',
|
||||||
|
role: 'TENANT_ADMIN',
|
||||||
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
|
agent_uuid: null,
|
||||||
|
});
|
||||||
|
console.log('[Auth] ✓ Default SIAB Tenant created: siab / siab');
|
||||||
|
}
|
||||||
|
|
||||||
|
const officeCount = await User.countDocuments({ username: 'office' });
|
||||||
|
if (officeCount === 0) {
|
||||||
|
const hash = bcrypt.hashSync('office', 10);
|
||||||
|
await User.create({
|
||||||
|
username: 'office',
|
||||||
|
password_hash: hash,
|
||||||
|
account_name: 'Office Administrator',
|
||||||
|
role: 'TENANT_ADMIN',
|
||||||
|
site_uuid: '1959bb55_045b_47c7_bbdd_f33b7db197b9',
|
||||||
|
agent_uuid: null,
|
||||||
|
});
|
||||||
|
console.log('[Auth] ✓ Default Office Tenant created: office / office');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair/Migration: Ensure legacy users have appropriate created_by values
|
||||||
|
try {
|
||||||
|
const missingCreatedBy = await User.find({ $or: [{ created_by: { $exists: false } }, { created_by: null }] });
|
||||||
|
if (missingCreatedBy.length > 0) {
|
||||||
|
console.log(`[Auth] Migrating ${missingCreatedBy.length} legacy users to set created_by...`);
|
||||||
|
for (const u of missingCreatedBy) {
|
||||||
|
if (u.username === 'admin') {
|
||||||
|
u.created_by = 'admin';
|
||||||
|
} else if (u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e') {
|
||||||
|
u.created_by = 'siab';
|
||||||
|
} else if (u.site_uuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9') {
|
||||||
|
u.created_by = 'office';
|
||||||
|
} else {
|
||||||
|
u.created_by = 'admin';
|
||||||
|
}
|
||||||
|
await u.save();
|
||||||
|
}
|
||||||
|
console.log(`[Auth] Migration complete.`);
|
||||||
|
}
|
||||||
|
} catch (migrateErr) {
|
||||||
|
console.error('[Auth] Migration failed:', migrateErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConfigs = [
|
||||||
|
{
|
||||||
|
site_uuid: 'default',
|
||||||
|
brand_name: 'BackOne',
|
||||||
|
brand_logo: '/backone-logo.png',
|
||||||
|
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||||
|
primary_color: '#E11D48',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
|
brand_name: 'SIAB',
|
||||||
|
brand_logo: '/siab-logo.png',
|
||||||
|
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||||
|
primary_color: '#3B82F6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
site_uuid: '1959bb55_045b_47c7_bbdd_f33b7db197b9',
|
||||||
|
brand_name: 'Office',
|
||||||
|
brand_logo: '/backone-logo.png',
|
||||||
|
footer_copyright: 'PT. Data Bisnis Solusi',
|
||||||
|
primary_color: '#E11D48',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const config of defaultConfigs) {
|
||||||
|
await TenantConfig.findOneAndUpdate({ site_uuid: config.site_uuid }, config, { upsert: true });
|
||||||
|
console.log(`[Auth] ✓ Seeded/Updated TenantConfig for: ${config.brand_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default agent locations
|
||||||
|
const defaultLocations = [
|
||||||
|
{
|
||||||
|
agent_uuid: 'F6-2V-DT-8A',
|
||||||
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
|
latitude: -6.2263304,
|
||||||
|
longitude: 106.4247322,
|
||||||
|
label: 'CPI Balaraja Agent Office'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent_uuid: '2F-TF-1D-GK',
|
||||||
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
|
latitude: -6.3763318,
|
||||||
|
longitude: 106.8983017,
|
||||||
|
label: 'JRP Cibubur Agent Office'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent_uuid: '8A-V3-PB-85',
|
||||||
|
site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e',
|
||||||
|
latitude: -6.2253265,
|
||||||
|
longitude: 106.8061484,
|
||||||
|
label: 'IFG LT.18 Agent HQ'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const loc of defaultLocations) {
|
||||||
|
const existing = await CustomAgentLocation.findOne({ agent_uuid: loc.agent_uuid });
|
||||||
|
if (!existing) {
|
||||||
|
await CustomAgentLocation.create(loc);
|
||||||
|
console.log(`[Auth] ✓ Seeded CustomAgentLocation for: ${loc.agent_uuid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Auth] Seed skipped (MongoDB not ready yet):', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = seedAuth;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// backend/routes/auth/sessions.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// User Session Management Routes (Active Sessions & Remote Revocation)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const Session = require('../../models/Session');
|
||||||
|
const { requireAuth, requireAdmin } = require('./helpers');
|
||||||
|
|
||||||
|
// Helper to block SOC_ANALYST from write actions
|
||||||
|
function blockAnalyst(req, res, next) {
|
||||||
|
if (req.adminUser.role === 'SOC_ANALYST') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Aksi ini tidak diizinkan untuk peran SOC Analyst' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/auth/sessions (Current User Sessions) ──────────────────────────
|
||||||
|
router.get('/sessions', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessions = await Session.find({ user_id: req.user.id }).sort({ last_active: -1 });
|
||||||
|
|
||||||
|
const data = sessions.map(s => ({
|
||||||
|
id: s._id.toString(),
|
||||||
|
ip_address: s.ip_address,
|
||||||
|
user_agent: s.user_agent,
|
||||||
|
last_active: s.last_active,
|
||||||
|
created_at: s.created_at,
|
||||||
|
is_current: req.user.session_id === s._id.toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── DELETE /api/auth/sessions/:id (Revoke Current User Session) ──────────────
|
||||||
|
router.delete('/sessions/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const session = await Session.findById(req.params.id);
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ ok: false, error: 'Sesi tidak ditemukan' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can only revoke their own sessions
|
||||||
|
if (session.user_id.toString() !== req.user.id) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Aksi dilarang' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Session.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
// Clear cookies if the user revokes their own current session
|
||||||
|
if (req.user.session_id === req.params.id) {
|
||||||
|
res.clearCookie('token');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Sesi berhasil diakhiri' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/auth/admin/sessions (Tenant/All Sessions) ──────────────────────
|
||||||
|
router.get('/admin/sessions', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let userQuery = {};
|
||||||
|
if (req.adminUser.role === 'TENANT_ADMIN') {
|
||||||
|
userQuery = { site_uuid: req.adminUser.site_uuid };
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await User.find(userQuery, 'username role account_name site_uuid');
|
||||||
|
const userIds = users.map(u => u._id);
|
||||||
|
|
||||||
|
const sessions = await Session.find({ user_id: { $in: userIds } })
|
||||||
|
.populate('user_id', 'username role account_name site_uuid')
|
||||||
|
.sort({ last_active: -1 });
|
||||||
|
|
||||||
|
const data = sessions.map(s => {
|
||||||
|
const u = s.user_id || {};
|
||||||
|
return {
|
||||||
|
id: s._id.toString(),
|
||||||
|
username: u.username || 'Unknown',
|
||||||
|
role: u.role || 'Unknown',
|
||||||
|
account_name: u.account_name || 'Unknown',
|
||||||
|
site_uuid: u.site_uuid || null,
|
||||||
|
ip_address: s.ip_address,
|
||||||
|
user_agent: s.user_agent,
|
||||||
|
last_active: s.last_active,
|
||||||
|
created_at: s.created_at,
|
||||||
|
is_current: req.adminUser.session_id === s._id.toString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── DELETE /api/auth/admin/sessions/:id (Revoke Any Session by Admin) ───────
|
||||||
|
router.delete('/admin/sessions/:id', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const session = await Session.findById(req.params.id).populate('user_id');
|
||||||
|
if (!session) {
|
||||||
|
return res.status(404).json({ ok: false, error: 'Sesi tidak ditemukan' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant Admin can only revoke sessions within their own site
|
||||||
|
if (req.adminUser.role !== 'SUPER_ADMIN') {
|
||||||
|
const sessionUser = session.user_id || {};
|
||||||
|
if (sessionUser.site_uuid !== req.adminUser.site_uuid) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: Sesi berada di luar site Anda' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Session.findByIdAndDelete(req.params.id);
|
||||||
|
res.json({ ok: true, message: 'Sesi berhasil diakhiri oleh administrator' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// backend/routes/auth/settings.js
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const { requireAuth, makeToken, setCookieToken, upload, getUploadsDir } = require('./helpers');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ─── POST /api/auth/change-password ──────────────────────────────────────────
|
||||||
|
router.post('/change-password', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.user.id).select('+password_hash');
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||||
|
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,}$/;
|
||||||
|
if (!passwordRegex.test(newPassword)) {
|
||||||
|
return res.status(400).json({ error: 'Password baru tidak memenuhi kriteria: minimal 6 karakter, serta mengandung huruf besar, huruf kecil, dan angka.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.password_hash = bcrypt.hashSync(newPassword, 10);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Password berhasil diubah!' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/auth/change-username ──────────────────────────────────────────
|
||||||
|
router.post('/change-username', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newUsername } = req.body;
|
||||||
|
if (!currentPassword || !newUsername) {
|
||||||
|
return res.status(400).json({ error: 'Current password and new username are required' });
|
||||||
|
}
|
||||||
|
if (newUsername.length < 4 || /[^a-zA-Z0-9_]/.test(newUsername)) {
|
||||||
|
return res.status(400).json({ error: 'Username baru tidak valid (minimal 4 karakter, hanya huruf, angka, dan underscore).' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.user.id).select('+password_hash');
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||||
|
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await User.findOne({ username: newUsername });
|
||||||
|
if (existing) return res.status(400).json({ error: 'Username sudah digunakan oleh akun lain' });
|
||||||
|
|
||||||
|
user.username = newUsername;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const newToken = makeToken(user);
|
||||||
|
setCookieToken(res, newToken);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Username berhasil diubah!', newUsername });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/auth/change-account-name ──────────────────────────────────────
|
||||||
|
router.post('/change-account-name', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newAccountName } = req.body;
|
||||||
|
if (!currentPassword || newAccountName == null) {
|
||||||
|
return res.status(400).json({ error: 'Current password and new account name are required' });
|
||||||
|
}
|
||||||
|
if (!newAccountName.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Nama akun tidak boleh kosong' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.user.id).select('+password_hash');
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||||
|
return res.status(400).json({ error: 'Password saat ini salah' });
|
||||||
|
}
|
||||||
|
|
||||||
|
user.account_name = newAccountName.trim();
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const newToken = makeToken(user);
|
||||||
|
setCookieToken(res, newToken);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Nama akun berhasil diubah!', newAccountName: user.account_name });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POST /api/auth/upload-profile-picture ───────────────────────────────────
|
||||||
|
// Menerima JSON: { profile_picture_base64: "data:image/png;base64,...", user_id? }
|
||||||
|
// Menghindari multipart/form-data yang bermasalah melalui Apache proxy layer
|
||||||
|
router.post('/upload-profile-picture', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { profile_picture_base64, user_id } = req.body;
|
||||||
|
|
||||||
|
if (!profile_picture_base64) {
|
||||||
|
return res.status(400).json({ error: 'No image data provided. Please select an image file first.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validasi format base64 data URL
|
||||||
|
const matches = profile_picture_base64.match(/^data:image\/(png|jpg|jpeg|gif|webp);base64,(.+)$/);
|
||||||
|
if (!matches) {
|
||||||
|
return res.status(400).json({ error: 'Invalid image format. Only PNG, JPG, GIF, WEBP are allowed.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
||||||
|
const base64Data = matches[2];
|
||||||
|
|
||||||
|
// Validasi ukuran (max 5MB uncompressed)
|
||||||
|
const fileSizeBytes = Buffer.byteLength(base64Data, 'base64');
|
||||||
|
if (fileSizeBytes > 5 * 1024 * 1024) {
|
||||||
|
return res.status(400).json({ error: 'Image too large. Maximum size is 5MB.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentukan target user (self atau admin update user lain)
|
||||||
|
const targetId = user_id || req.user.id;
|
||||||
|
const user = await User.findById(targetId);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
// Hapus foto profil lama jika ada
|
||||||
|
if (user.profile_picture) {
|
||||||
|
const oldPath = path.join(getUploadsDir(), user.profile_picture);
|
||||||
|
if (fs.existsSync(oldPath)) {
|
||||||
|
try { fs.unlinkSync(oldPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpan file baru
|
||||||
|
const filename = `profile-${targetId}-${Date.now()}.${ext}`;
|
||||||
|
const filePath = path.join(getUploadsDir(), filename);
|
||||||
|
fs.writeFileSync(filePath, base64Data, 'base64');
|
||||||
|
|
||||||
|
user.profile_picture = filename;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Perbarui token hanya jika user mengupdate foto dirinya sendiri
|
||||||
|
if (String(targetId) === String(req.user.id)) {
|
||||||
|
const newToken = makeToken(user);
|
||||||
|
setCookieToken(res, newToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Profile picture updated successfully.', profile_picture: filename });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Upload Error]', err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ─── POST /api/auth/remove-profile-picture ───────────────────────────────────
|
||||||
|
router.post('/remove-profile-picture', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.user.id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
if (user.profile_picture) {
|
||||||
|
const filePath = path.join(getUploadsDir(), user.profile_picture);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.profile_picture = null;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
const newToken = makeToken(user);
|
||||||
|
setCookieToken(res, newToken);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Foto profil berhasil dihapus' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// backend/routes/auth/users.js
|
||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
const { requireAdmin, upload } = require('./helpers');
|
||||||
|
const { blockAnalyst, resolveSiteUuidForAgent, mapUserData } = require('./usersHelper');
|
||||||
|
const { handleCreateExternalUser } = require('./usersCreateExternal');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/auth/admin/users — daftar semua users (admin & analyst)
|
||||||
|
router.get('/admin/users', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
let query = {};
|
||||||
|
if (req.adminUser.role === 'TENANT_ADMIN') {
|
||||||
|
query = {
|
||||||
|
$or: [
|
||||||
|
{ role: 'AGENT_VIEWER', site_uuid: req.adminUser.site_uuid },
|
||||||
|
{ created_by: req.adminUser.username }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
query = { company_name: req.adminUser.company_name };
|
||||||
|
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
|
||||||
|
return res.json({ ok: true, data: users.map(mapUserData) });
|
||||||
|
}
|
||||||
|
query.username = { $ne: req.adminUser.username };
|
||||||
|
const users = await User.find(query, '-password_hash').sort({ created_at: 1 });
|
||||||
|
res.json({ ok: true, data: users.map(mapUserData) });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/admin/unlock-user — unlock akun yang terkunci
|
||||||
|
router.post('/admin/unlock-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) return res.status(400).json({ ok: false, error: 'user_id wajib diisi' });
|
||||||
|
|
||||||
|
const target = await User.findById(user_id);
|
||||||
|
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||||
|
|
||||||
|
if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: Account does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
target.login_attempts = 0;
|
||||||
|
target.lockout_until = null;
|
||||||
|
await target.save();
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Akun berhasil di-unlock' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/admin/create-agent-user — buat akun Network Agent baru
|
||||||
|
router.post('/admin/create-agent-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password, account_name, agent_uuid } = req.body;
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Username dan password wajib diisi' });
|
||||||
|
}
|
||||||
|
const passwordHash = bcrypt.hashSync(password, 10);
|
||||||
|
const siteUuid = await resolveSiteUuidForAgent(agent_uuid, null, req.adminUser, req.body.site_uuid);
|
||||||
|
|
||||||
|
const newUser = await User.create({
|
||||||
|
username: username.trim(),
|
||||||
|
password_hash: passwordHash,
|
||||||
|
account_name: account_name?.trim() || null,
|
||||||
|
agent_uuid: agent_uuid?.trim() || null,
|
||||||
|
role: 'AGENT_VIEWER',
|
||||||
|
site_uuid: siteUuid,
|
||||||
|
created_by: req.adminUser.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Akun Network Agent berhasil dibuat', userId: newUser._id.toString() });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.code === 11000 ? 'Username sudah digunakan' : err.message;
|
||||||
|
res.status(400).json({ ok: false, error: msg });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/admin/update-agent-user — update akun Network Agent / Company User
|
||||||
|
router.post('/admin/update-agent-user', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { user_id, username, password, account_name, agent_uuid, company_name } = req.body;
|
||||||
|
let agent_uuids = null;
|
||||||
|
if (req.body.agent_uuids) {
|
||||||
|
try {
|
||||||
|
agent_uuids = typeof req.body.agent_uuids === 'string' ? JSON.parse(req.body.agent_uuids) : req.body.agent_uuids;
|
||||||
|
} catch {
|
||||||
|
agent_uuids = [req.body.agent_uuids];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user_id) return res.status(400).json({ ok: false, error: 'user_id wajib diisi' });
|
||||||
|
|
||||||
|
const target = await User.findById(user_id).select('+password_hash');
|
||||||
|
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||||
|
if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa mengubah akun SUPER_ADMIN dari sini' });
|
||||||
|
|
||||||
|
if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
if (target.company_name !== req.adminUser.company_name) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
|
||||||
|
}
|
||||||
|
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username?.trim()) {
|
||||||
|
const existing = await User.findOne({ username: username.trim(), _id: { $ne: user_id } });
|
||||||
|
if (existing) return res.status(400).json({ ok: false, error: 'Username sudah digunakan' });
|
||||||
|
target.username = username.trim();
|
||||||
|
}
|
||||||
|
if (password) target.password_hash = bcrypt.hashSync(password, 10);
|
||||||
|
if (account_name != null) target.account_name = account_name?.trim() || null;
|
||||||
|
|
||||||
|
if (company_name !== undefined && req.adminUser.role === 'SUPER_ADMIN') {
|
||||||
|
target.company_name = company_name?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent_uuids != null) {
|
||||||
|
if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
const allowedAgents = req.adminUser.agent_uuids || [];
|
||||||
|
const invalidAgents = agent_uuids.filter(uuid => !allowedAgents.includes(uuid));
|
||||||
|
if (invalidAgents.length > 0) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Akses ditolak: Anda tidak memiliki wewenang untuk menetapkan agen tersebut.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.agent_uuids = agent_uuids;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent_uuid != null) {
|
||||||
|
target.agent_uuid = agent_uuid?.trim() || null;
|
||||||
|
if (agent_uuid.trim()) {
|
||||||
|
target.site_uuid = await resolveSiteUuidForAgent(agent_uuid, target.site_uuid, req.adminUser, req.body.site_uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.file) target.profile_picture = req.file.filename;
|
||||||
|
|
||||||
|
await target.save();
|
||||||
|
const updated = await User.findById(user_id, '-password_hash');
|
||||||
|
res.json({ ok: true, message: 'Akun berhasil diperbarui', user: updated });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/admin/delete-agent-user/:id — hapus akun Network Agent
|
||||||
|
router.delete('/admin/delete-agent-user/:id', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const target = await User.findById(req.params.id);
|
||||||
|
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||||
|
if (target.role === 'SUPER_ADMIN') return res.status(403).json({ ok: false, error: 'Tidak bisa menghapus SUPER_ADMIN' });
|
||||||
|
|
||||||
|
if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
if (target.company_name !== req.adminUser.company_name) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
|
||||||
|
}
|
||||||
|
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
await User.findByIdAndDelete(req.params.id);
|
||||||
|
res.json({ ok: true, message: 'Akun berhasil dihapus' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/admin/upload-agent-picture/:id — upload foto profil agent oleh admin
|
||||||
|
router.post('/admin/upload-agent-picture/:id', requireAdmin, blockAnalyst, upload.single('profile_picture'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ ok: false, error: 'File gambar wajib diupload' });
|
||||||
|
const target = await User.findById(req.params.id);
|
||||||
|
if (!target) return res.status(404).json({ ok: false, error: 'User tidak ditemukan' });
|
||||||
|
|
||||||
|
if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
if (target.company_name !== req.adminUser.company_name) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Access Denied: Akun ini bukan milik perusahaan Anda.' });
|
||||||
|
}
|
||||||
|
} else if (req.adminUser.role !== 'SUPER_ADMIN' && target.site_uuid !== req.adminUser.site_uuid) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: This account does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
target.profile_picture = req.file.filename;
|
||||||
|
await target.save();
|
||||||
|
res.json({ ok: true, message: 'Foto profil berhasil diperbarui', filename: req.file.filename });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/admin/create-external-user — buat akun Eksternal (SOC Analyst, Engineer, dll)
|
||||||
|
router.post('/admin/create-external-user', requireAdmin, blockAnalyst, handleCreateExternalUser);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// backend/routes/auth/usersCreateExternal.js
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const User = require('../../models/User');
|
||||||
|
|
||||||
|
async function handleCreateExternalUser(req, res) {
|
||||||
|
try {
|
||||||
|
const { username, password, account_name, role, company_name } = req.body;
|
||||||
|
let agent_uuids = [];
|
||||||
|
if (req.body.agent_uuids) {
|
||||||
|
agent_uuids = Array.isArray(req.body.agent_uuids)
|
||||||
|
? req.body.agent_uuids
|
||||||
|
: (() => { try { return JSON.parse(req.body.agent_uuids); } catch { return [req.body.agent_uuids]; } })();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username || !password || !role) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Username, password, dan role wajib diisi' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let validRoles = [];
|
||||||
|
if (req.adminUser.role === 'SUPER_ADMIN') {
|
||||||
|
validRoles = ['EXECUTIVE', 'SOC_ANALYST', 'ENGINEER', 'TENANT_ADMIN', 'COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||||
|
} else if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
validRoles = ['COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||||
|
} else {
|
||||||
|
validRoles = ['SOC_ANALYST', 'ENGINEER', 'TENANT_ADMIN'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Role tidak valid untuk pembuatan akun eksternal' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCompanyName = req.adminUser.role === 'COMPANY_ADMIN'
|
||||||
|
? req.adminUser.company_name
|
||||||
|
: (company_name?.trim() || null);
|
||||||
|
|
||||||
|
if (targetCompanyName) {
|
||||||
|
const existingCount = await User.countDocuments({ company_name: targetCompanyName });
|
||||||
|
if (existingCount >= 5) {
|
||||||
|
return res.status(400).json({ ok: false, error: `Batas maksimum 5 akun untuk perusahaan ${targetCompanyName} telah tercapai.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.adminUser.role === 'COMPANY_ADMIN') {
|
||||||
|
const allowedAgents = req.adminUser.agent_uuids || [];
|
||||||
|
const invalidAgents = agent_uuids.filter(uuid => !allowedAgents.includes(uuid));
|
||||||
|
if (invalidAgents.length > 0) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Akses ditolak: Anda tidak memiliki wewenang untuk menetapkan agen tersebut.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await User.findOne({ username: username.trim() });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Username sudah digunakan' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = bcrypt.hashSync(password, 10);
|
||||||
|
const siteUuid = (role === 'EXECUTIVE' || role === 'COMPANY_ADMIN')
|
||||||
|
? null
|
||||||
|
: req.adminUser.role === 'SUPER_ADMIN'
|
||||||
|
? (req.body.site_uuid || process.env.BACKONE_SITE_UUID || null)
|
||||||
|
: req.adminUser.site_uuid;
|
||||||
|
|
||||||
|
const createdBy = req.adminUser.role === 'SUPER_ADMIN'
|
||||||
|
? (req.body.created_by || req.adminUser.username)
|
||||||
|
: req.adminUser.username;
|
||||||
|
|
||||||
|
const newUser = await User.create({
|
||||||
|
username: username.trim(),
|
||||||
|
password_hash: passwordHash,
|
||||||
|
account_name: account_name?.trim() || null,
|
||||||
|
role: role,
|
||||||
|
site_uuid: siteUuid,
|
||||||
|
company_name: targetCompanyName,
|
||||||
|
agent_uuids: agent_uuids,
|
||||||
|
created_by: createdBy,
|
||||||
|
profile_picture: null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Akun eksternal berhasil dibuat', userId: newUser._id.toString() });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.code === 11000 ? 'Username sudah digunakan' : err.message;
|
||||||
|
res.status(400).json({ ok: false, error: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handleCreateExternalUser };
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// backend/routes/auth/usersHelper.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// User management helper logic & site UUID resolver (BackOne API compliant)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { Summary } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
function blockAnalyst(req, res, next) {
|
||||||
|
if (req.adminUser.role === 'SOC_ANALYST') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Aksi ini tidak diizinkan untuk peran SOC Analyst' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSiteUuidForAgent(agentUuid, fallbackSiteUuid, adminUser, bodySiteUuid) {
|
||||||
|
let siteUuid = null;
|
||||||
|
if (agentUuid) {
|
||||||
|
const summaryDoc = await Summary.findOne({ agent_uuid: agentUuid.trim() });
|
||||||
|
if (summaryDoc) {
|
||||||
|
siteUuid = summaryDoc.site_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!siteUuid) {
|
||||||
|
siteUuid = adminUser.role === 'SUPER_ADMIN'
|
||||||
|
? (bodySiteUuid || process.env.BACKONE_SITE_UUID || fallbackSiteUuid || null)
|
||||||
|
: adminUser.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return siteUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserData(user) {
|
||||||
|
return {
|
||||||
|
id: user._id.toString(),
|
||||||
|
username: user.username,
|
||||||
|
account_name: user.account_name,
|
||||||
|
profile_picture: user.profile_picture,
|
||||||
|
role: user.role,
|
||||||
|
site_uuid: user.site_uuid,
|
||||||
|
agent_uuid: user.agent_uuid,
|
||||||
|
company_name: user.company_name,
|
||||||
|
agent_uuids: user.agent_uuids || [],
|
||||||
|
is_active: user.is_active,
|
||||||
|
login_attempts: user.login_attempts || 0,
|
||||||
|
lockout_until: user.lockout_until || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
blockAnalyst,
|
||||||
|
resolveSiteUuidForAgent,
|
||||||
|
mapUserData,
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// backend/routes/auth/viewAs.js
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { requireAdmin, requireAuth, JWT_SECRET } = require('./helpers');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Block SOC_ANALYST from starting view-as sessions; EXECUTIVE is allowed (read-only viewer)
|
||||||
|
function blockAnalyst(req, res, next) {
|
||||||
|
if (req.adminUser.role === 'SOC_ANALYST') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Aksi ini tidak diizinkan untuk peran SOC Analyst' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/admin/view-as — admin masuk mode "lihat sebagai user/agent"
|
||||||
|
router.post('/admin/view-as', requireAdmin, blockAnalyst, async (req, res) => {
|
||||||
|
const { agent_uuid, agent_label, target_user_id, target_username, target_role } = req.body;
|
||||||
|
if (!agent_uuid) return res.status(400).json({ ok: false, error: 'agent_uuid wajib diisi' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||||
|
const User = mongoose.model('User');
|
||||||
|
|
||||||
|
let targetUserDoc = null;
|
||||||
|
if (target_user_id) {
|
||||||
|
targetUserDoc = await User.findById(target_user_id).lean();
|
||||||
|
} else if (target_username) {
|
||||||
|
targetUserDoc = await User.findOne({ username: target_username }).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
adminId: req.adminUser.id,
|
||||||
|
adminUsername: req.adminUser.username,
|
||||||
|
viewAs: agent_uuid,
|
||||||
|
viewAsLabel: agent_label || agent_uuid,
|
||||||
|
target_user_id: targetUserDoc ? (targetUserDoc.id || targetUserDoc._id) : (target_user_id || null),
|
||||||
|
target_username: targetUserDoc ? targetUserDoc.username : (target_username || null),
|
||||||
|
target_role: targetUserDoc ? targetUserDoc.role : (target_role || null),
|
||||||
|
type: 'view-as'
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewToken = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' });
|
||||||
|
|
||||||
|
// Simpan log audit lengkap ke MongoDB
|
||||||
|
await new ViewAsLog({
|
||||||
|
admin_id: req.adminUser.id,
|
||||||
|
admin_username: req.adminUser.username,
|
||||||
|
admin_role: req.adminUser.role,
|
||||||
|
target_user_id: payload.target_user_id,
|
||||||
|
target_username: payload.target_username,
|
||||||
|
target_role: payload.target_role,
|
||||||
|
agent_uuid,
|
||||||
|
agent_label: agent_label || agent_uuid
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
message: `Sekarang melihat sebagai ${payload.target_username || agent_label || agent_uuid}`,
|
||||||
|
view_token: viewToken,
|
||||||
|
agent_uuid,
|
||||||
|
agent_label: agent_label || agent_uuid,
|
||||||
|
target_user_id: payload.target_user_id,
|
||||||
|
target_username: payload.target_username,
|
||||||
|
target_role: payload.target_role
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/admin/view-as/logs — ambil riwayat audit view-as
|
||||||
|
router.get('/admin/view-as/logs', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||||
|
|
||||||
|
// Role-based visibility logic:
|
||||||
|
const query = {};
|
||||||
|
if (req.adminUser.role === 'SOC_ANALYST') {
|
||||||
|
query.admin_role = { $ne: 'SUPER_ADMIN' };
|
||||||
|
query.admin_username = { $ne: 'admin' };
|
||||||
|
} else if (req.adminUser.role === 'TENANT_ADMIN') {
|
||||||
|
const Summary = mongoose.model('Summary');
|
||||||
|
const siteAgents = await Summary.distinct('agent_uuid', { site_uuid: req.adminUser.site_uuid });
|
||||||
|
query.agent_uuid = { $in: siteAgents };
|
||||||
|
query.admin_role = { $ne: 'SUPER_ADMIN' };
|
||||||
|
query.admin_username = { $ne: 'admin' };
|
||||||
|
} else if (req.adminUser.role === 'COMPANY_ADMIN' || req.adminUser.role === 'COMPANY_OPERATOR') {
|
||||||
|
// COMPANY_ADMIN/OPERATOR hanya lihat log mereka sendiri
|
||||||
|
query.admin_id = req.adminUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await ViewAsLog.find(query).sort({ timestamp: -1 }).lean();
|
||||||
|
res.json({ ok: true, data: logs });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/auth/admin/view-as — keluar dari mode view-as
|
||||||
|
router.delete('/admin/view-as', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ViewAsLog = mongoose.model('ViewAsLog');
|
||||||
|
const latestLog = await ViewAsLog.findOne({
|
||||||
|
admin_id: req.adminUser.id,
|
||||||
|
end_timestamp: { $exists: false }
|
||||||
|
}).sort({ timestamp: -1 });
|
||||||
|
|
||||||
|
if (latestLog) {
|
||||||
|
latestLog.end_timestamp = new Date();
|
||||||
|
const diffMs = latestLog.end_timestamp.getTime() - latestLog.timestamp.getTime();
|
||||||
|
latestLog.duration = Math.round(diffMs / 1000); // durasi dalam detik
|
||||||
|
await latestLog.save();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Gagal menyimpan durasi sesi view-as:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.clearCookie('view_as_token');
|
||||||
|
res.json({ ok: true, message: 'Kembali ke tampilan admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/view-as — cek status view-as
|
||||||
|
router.get('/view-as', requireAuth, (req, res) => {
|
||||||
|
const viewToken = req.cookies?.view_as_token;
|
||||||
|
if (!viewToken) return res.json({ active: false });
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(viewToken, JWT_SECRET);
|
||||||
|
res.json({ active: true, agent_uuid: decoded.viewAs, agent_label: decoded.viewAsLabel });
|
||||||
|
} catch {
|
||||||
|
res.clearCookie('view_as_token');
|
||||||
|
res.json({ active: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// backend/routes/categoryDetail.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Category Detail endpoint for the BackOne Network Intelligence page.
|
||||||
|
// Returns the real MongoDB breakdown for a clicked category.
|
||||||
|
// GET /api/dashboard/category-detail?category=<CategoryName>
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { DeviceAppStat, DeviceStat, LookupApp } = require('../models/Schemas');
|
||||||
|
|
||||||
|
// ─── Helper: build base filter from request user/time ──────────────────────────
|
||||||
|
function buildBaseFilter(req) {
|
||||||
|
const range = req.query.timeRange || 'all';
|
||||||
|
const filter = {};
|
||||||
|
if (req.user?.site_uuid) filter.site_uuid = req.user.site_uuid;
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
filter.agent_uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
if (range !== 'all') {
|
||||||
|
const ms = { '5m': 300000, '30m': 1800000, '1h': 3600000, '1d': 86400000, '7d': 604800000 };
|
||||||
|
const delta = ms[range];
|
||||||
|
if (delta) filter.timestamp = { $gte: new Date(Date.now() - delta) };
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/dashboard/category-detail ───────────────────────────────────────
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { category } = req.query;
|
||||||
|
if (!category) return res.status(400).json({ ok: false, error: 'category is required' });
|
||||||
|
|
||||||
|
const base = buildBaseFilter(req);
|
||||||
|
try {
|
||||||
|
// Lookup all application labels that belong to this category
|
||||||
|
const appsInCategory = await LookupApp.find({ 'application_category.label': category }).lean();
|
||||||
|
const appLabels = appsInCategory.map(app => app.label);
|
||||||
|
|
||||||
|
if (appLabels.length === 0) {
|
||||||
|
return res.json({ ok: true, category, apps: [], devices: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = { ...base, app_label: { $in: appLabels } };
|
||||||
|
|
||||||
|
// 1. Get Top Apps for this category
|
||||||
|
const topAppsData = await DeviceAppStat.aggregate([
|
||||||
|
{ $match: filter },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' },
|
||||||
|
last_seen: { $max: '$timestamp' }
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 200 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const apps = topAppsData.map(r => ({
|
||||||
|
app_label: r._id,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
flows: r.flows,
|
||||||
|
agent_uuid: r.agent_uuid,
|
||||||
|
last_seen: r.last_seen
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Get Top Devices for this category
|
||||||
|
const topDevicesData = await DeviceAppStat.aggregate([
|
||||||
|
{ $match: filter },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ip_address',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' },
|
||||||
|
last_seen: { $max: '$timestamp' }
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 200 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Enrich devices with DeviceStat info (mac, os, manufacturer)
|
||||||
|
const { generateMacFromIp, resolveVendorFromIp, resolveOSFromIp, generateAutoLabel } = require('../deviceResolver');
|
||||||
|
const ips = topDevicesData.map(r => r._id).filter(Boolean);
|
||||||
|
|
||||||
|
const agentFilter = {};
|
||||||
|
if (base.agent_uuid) agentFilter.agent_uuid = base.agent_uuid;
|
||||||
|
if (base.site_uuid) agentFilter.site_uuid = base.site_uuid;
|
||||||
|
|
||||||
|
const devicesInfo = await DeviceStat.find({ ip_address: { $in: ips }, ...agentFilter }).lean();
|
||||||
|
const deviceMap = {};
|
||||||
|
for (const d of devicesInfo) deviceMap[d.ip_address] = d;
|
||||||
|
|
||||||
|
const devices = topDevicesData.map(r => {
|
||||||
|
const ip = r._id;
|
||||||
|
const d = deviceMap[ip];
|
||||||
|
const mac = d?.mac_address && d.mac_address !== '-' ? d.mac_address : generateMacFromIp(ip);
|
||||||
|
const manufacturer = d?.manufacturer && d.manufacturer !== '-' && d.manufacturer !== 'Unknown' ? d.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
const os = d?.os_label && d.os_label !== '-' && d.os_label !== 'Unknown' ? d.os_label : resolveOSFromIp(ip);
|
||||||
|
const label = d?.device_label && d.device_label !== '-' && d.device_label !== ip ? d.device_label : generateAutoLabel(ip, mac, manufacturer, 'Workstation');
|
||||||
|
|
||||||
|
return {
|
||||||
|
src_ip: ip,
|
||||||
|
device_label: label,
|
||||||
|
mac_address: mac,
|
||||||
|
manufacturer: manufacturer,
|
||||||
|
os_label: os,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
flows: r.flows,
|
||||||
|
agent_uuid: r.agent_uuid,
|
||||||
|
last_seen: r.last_seen
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
category,
|
||||||
|
apps,
|
||||||
|
devices
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[CategoryDetail] Error:`, error);
|
||||||
|
res.status(500).json({ ok: false, error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// backend/routes/dashboard.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// BackOne Dashboard Routes Entry Point
|
||||||
|
// Mounts all modular sub-routers under /api/dashboard.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const PROXY_URL = process.env.PROXY_URL || 'http://localhost:4010';
|
||||||
|
|
||||||
|
// ─── Rebranding Helper (Memory Safe & Fast) ──────────────────────────────────
|
||||||
|
const BRAND_NAMES = {
|
||||||
|
'1959bb55_045b_47c7_bbdd_f33b7db197b9': 'Office',
|
||||||
|
'6681452d_9cae_4ff4_8ae8_0d504774265e': 'SIAB',
|
||||||
|
'default': 'BackOne'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBrandNameForRequest(req) {
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
const userSiteUuid = req.user?.site_uuid;
|
||||||
|
|
||||||
|
const siteUuid = (req.user?.role === 'SUPER_ADMIN' || !userSiteUuid || userSiteUuid === 'default')
|
||||||
|
? (requestedSiteUuid || 'default')
|
||||||
|
: userSiteUuid;
|
||||||
|
|
||||||
|
return BRAND_NAMES[siteUuid] || 'BackOne';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebrandString(str, brandName) {
|
||||||
|
if (typeof str !== 'string') return str;
|
||||||
|
|
||||||
|
if (brandName === 'Nexus') {
|
||||||
|
return str
|
||||||
|
.replace(/netify\.unclassified/gi, 'nexus.unclassified')
|
||||||
|
.replace(/netify\.(?!ai)/gi, 'nexus.')
|
||||||
|
.replace(/Netify's/g, "Nexus'")
|
||||||
|
.replace(/netify's/g, "nexus'")
|
||||||
|
.replace(/Netify(?!(\.ai))/g, 'Nexus')
|
||||||
|
.replace(/netify(?!(\.ai))/g, 'nexus')
|
||||||
|
.replace(/BackOne's/g, "Nexus'")
|
||||||
|
.replace(/backone's/g, "nexus'")
|
||||||
|
.replace(/BackOne/g, 'Nexus')
|
||||||
|
.replace(/backone/g, 'nexus')
|
||||||
|
.replace(/PT\.?\s*Data\s*Bisnis\s*Solusi/g, 'PT. Nexus Solusi');
|
||||||
|
} else if (brandName === 'SIAB') {
|
||||||
|
return str
|
||||||
|
.replace(/netify\.unclassified/gi, 'siab.unclassified')
|
||||||
|
.replace(/netify\.(?!ai)/gi, 'siab.')
|
||||||
|
.replace(/Netify's/g, "SIAB's")
|
||||||
|
.replace(/netify's/g, "siab's")
|
||||||
|
.replace(/Netify(?!(\.ai))/g, 'SIAB')
|
||||||
|
.replace(/netify(?!(\.ai))/g, 'siab');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default (BackOne)
|
||||||
|
return str
|
||||||
|
.replace(/netify\.unclassified/gi, 'backone.unclassified')
|
||||||
|
.replace(/netify\.(?!ai)/gi, 'backone.')
|
||||||
|
.replace(/Netify's/g, "BackOne's")
|
||||||
|
.replace(/netify's/g, "backone's")
|
||||||
|
.replace(/Netify(?!(\.ai))/g, 'BackOne')
|
||||||
|
.replace(/netify(?!(\.ai))/g, 'backone');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebrandObj(obj, brandName) {
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (let i = 0; i < obj.length; i++) {
|
||||||
|
obj[i] = rebrandObj(obj[i], brandName);
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'object') {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||||
|
if (typeof obj[key] === 'string') {
|
||||||
|
obj[key] = rebrandString(obj[key], brandName);
|
||||||
|
} else if (typeof obj[key] === 'object') {
|
||||||
|
obj[key] = rebrandObj(obj[key], brandName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return rebrandString(obj, brandName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rebranding Middleware ────────────────────────────────────────────────────
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const brandName = getBrandNameForRequest(req);
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = function (body) {
|
||||||
|
if (body) {
|
||||||
|
try {
|
||||||
|
body = rebrandObj(body, brandName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] Rebrand error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalJson(body);
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dashboard/refresh
|
||||||
|
router.post('/refresh', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${PROXY_URL}/collect/all`, {}, { timeout: 10000 });
|
||||||
|
res.json({ ok: true, message: 'Collection triggered on proxy.', proxy_result: response.data });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[/refresh] Proxy not reachable:', err.message);
|
||||||
|
res.json({ ok: false, message: 'Could not reach proxy server. Data will be updated on next scheduled run.', error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount Sub-routers
|
||||||
|
router.use(require('./dashboard/summary'));
|
||||||
|
router.use(require('./dashboard/agents'));
|
||||||
|
router.use(require('./dashboard/apps'));
|
||||||
|
router.use(require('./dashboard/devices'));
|
||||||
|
router.use(require('./dashboard/deviceLabeling'));
|
||||||
|
router.use(require('./dashboard/flows'));
|
||||||
|
router.use(require('./dashboard/flowStats'));
|
||||||
|
router.use(require('./dashboard/threats'));
|
||||||
|
router.use(require('./dashboard/geo'));
|
||||||
|
router.use(require('./dashboard/tls'));
|
||||||
|
router.use(require('./dashboard/telemetry'));
|
||||||
|
router.use(require('./dashboard/events'));
|
||||||
|
router.use(require('./dashboard/sslSan'));
|
||||||
|
router.use(require('./dashboard/tenantConfig'));
|
||||||
|
router.use(require('./dashboard/agentLocations'));
|
||||||
|
router.use(require('./dashboard/blacklist'));
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { CustomAgentLocation, Summary, DeviceStat, Flow } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// ─── 1. GET /api/dashboard/agent-locations ──────────────────────────────────────
|
||||||
|
router.get('/agent-locations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
let query = {};
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
query.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
query.agent_uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const locations = await CustomAgentLocation.find(query).lean();
|
||||||
|
res.json({ ok: true, data: locations });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GET /agent-locations]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. POST /api/dashboard/agent-locations ─────────────────────────────────────
|
||||||
|
router.post('/agent-locations', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'SUPER_ADMIN' && req.user?.role !== 'TENANT_ADMIN') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Only administrators can configure agent geolocations.' });
|
||||||
|
}
|
||||||
|
const { agent_uuid, latitude, longitude, label } = req.body;
|
||||||
|
if (!agent_uuid || latitude === undefined || longitude === undefined) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'agent_uuid, latitude, and longitude are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine site_uuid
|
||||||
|
let siteUuid = null;
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
const agentBelongs = await Summary.findOne({ agent_uuid, site_uuid: req.user.site_uuid });
|
||||||
|
if (!agentBelongs) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: This agent does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
siteUuid = req.user.site_uuid;
|
||||||
|
} else {
|
||||||
|
// Find the site_uuid from Summary collection for this agent
|
||||||
|
const summaryDoc = await Summary.findOne({ agent_uuid });
|
||||||
|
if (summaryDoc) {
|
||||||
|
siteUuid = summaryDoc.site_uuid;
|
||||||
|
} else {
|
||||||
|
// Fallback or use standard env site_uuid
|
||||||
|
siteUuid = process.env.BACKONE_SITE_UUID || '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findQuery = { agent_uuid };
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
findQuery.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserted = await CustomAgentLocation.findOneAndUpdate(
|
||||||
|
findQuery,
|
||||||
|
{
|
||||||
|
agent_uuid,
|
||||||
|
site_uuid: siteUuid,
|
||||||
|
latitude: parseFloat(latitude),
|
||||||
|
longitude: parseFloat(longitude),
|
||||||
|
label: label || ''
|
||||||
|
},
|
||||||
|
{ new: true, upsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true, data: upserted });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[POST /agent-locations]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 3. DELETE /api/dashboard/agent-locations/:agent_uuid ────────────────────────
|
||||||
|
router.delete('/agent-locations/:agent_uuid', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'SUPER_ADMIN' && req.user?.role !== 'TENANT_ADMIN') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Only administrators can delete agent geolocations.' });
|
||||||
|
}
|
||||||
|
const { agent_uuid } = req.params;
|
||||||
|
|
||||||
|
let query = { agent_uuid };
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
query.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resDelete = await CustomAgentLocation.deleteOne(query);
|
||||||
|
res.json({ ok: true, deleted: resDelete.deletedCount > 0 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DELETE /agent-locations]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. GET /api/dashboard/agent-flows ──────────────────────────────────────────
|
||||||
|
router.get('/agent-flows', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
const siteUuid = (isGlobalUser && requestedSiteUuid)
|
||||||
|
? requestedSiteUuid
|
||||||
|
: (req.user?.site_uuid || '6681452d_9cae_4ff4_8ae8_0d504774265e');
|
||||||
|
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
|
||||||
|
// Build IP-to-Agent mapping from DeviceStat
|
||||||
|
const deviceQuery = { site_uuid: { $in: [siteUuid, 'global'] } };
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
deviceQuery.agent_uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
const devices = await DeviceStat.find(deviceQuery).select('ip_address agent_uuid').lean();
|
||||||
|
const deviceIpToAgent = {};
|
||||||
|
for (const dev of devices) {
|
||||||
|
if (dev.ip_address && dev.agent_uuid) {
|
||||||
|
deviceIpToAgent[dev.ip_address] = dev.agent_uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query flows
|
||||||
|
const flowsQuery = { site_uuid: { $in: [siteUuid, 'global'] } };
|
||||||
|
if (timeFilter) flowsQuery.timestamp = timeFilter;
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
flowsQuery.agent_uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flows = await Flow.find(flowsQuery)
|
||||||
|
.select('agent_uuid src_ip dst_ip download upload app_label')
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.limit(5000)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const flowMap = {};
|
||||||
|
for (const flow of flows) {
|
||||||
|
const srcAgent = flow.agent_uuid;
|
||||||
|
const dstAgent = deviceIpToAgent[flow.dst_ip];
|
||||||
|
|
||||||
|
if (srcAgent && dstAgent && srcAgent !== dstAgent) {
|
||||||
|
const key = `${srcAgent}->${dstAgent}`;
|
||||||
|
if (!flowMap[key]) {
|
||||||
|
flowMap[key] = {
|
||||||
|
source: srcAgent,
|
||||||
|
target: dstAgent,
|
||||||
|
bytes: 0,
|
||||||
|
flowsCount: 0,
|
||||||
|
details: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const bytes = ((flow.download || 0) + (flow.upload || 0));
|
||||||
|
flowMap[key].bytes += bytes;
|
||||||
|
flowMap[key].flowsCount += 1;
|
||||||
|
flowMap[key].details.push({
|
||||||
|
src_ip: flow.src_ip,
|
||||||
|
dst_ip: flow.dst_ip,
|
||||||
|
app: flow.app_label || 'Unclassified',
|
||||||
|
bytes: bytes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Object.values(flowMap);
|
||||||
|
for (const f of result) {
|
||||||
|
f.details.sort((a, b) => b.bytes - a.bytes);
|
||||||
|
f.details = f.details.slice(0, 5); // top 5 sub-flows
|
||||||
|
}
|
||||||
|
res.json({ ok: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[GET /agent-flows]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// backend/routes/dashboard/agents.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Agent Management and Telemetry API Router
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { Summary } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/agents/uptime
|
||||||
|
router.get('/agents/uptime', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const range = req.query.timeRange || '1d';
|
||||||
|
const cyclesMap = {
|
||||||
|
'5m': 1,
|
||||||
|
'30m': 6,
|
||||||
|
'1h': 12,
|
||||||
|
'1d': 288,
|
||||||
|
'7d': 2016,
|
||||||
|
'30d': 8640,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ideal = cyclesMap[range] ?? 12;
|
||||||
|
let timeFilter = getTimeFilter(req);
|
||||||
|
if (!timeFilter) {
|
||||||
|
const now = new Date();
|
||||||
|
timeFilter = { $gte: new Date(now.getTime() - 30 * 24 * 3600000) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = { timestamp: timeFilter };
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
if (isGlobalUser && requestedSiteUuid) {
|
||||||
|
query.site_uuid = { $in: [requestedSiteUuid, 'global'] };
|
||||||
|
} else if (req.user?.site_uuid) {
|
||||||
|
query.site_uuid = { $in: [req.user.site_uuid, 'global'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await Summary.aggregate([
|
||||||
|
{ $match: query },
|
||||||
|
{ $group: { _id: "$agent_uuid", count: { $sum: 1 } } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const uptimeMap = {};
|
||||||
|
stats.forEach(s => {
|
||||||
|
if (s._id) {
|
||||||
|
const pct = Math.min(100, Math.round((s.count / ideal) * 1000) / 10);
|
||||||
|
uptimeMap[s._id] = pct;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, uptime: uptimeMap });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/agents
|
||||||
|
router.get('/agents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
req.user?.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Forbidden: Admin access only' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always filter out null/empty agent_uuid entries
|
||||||
|
const query = { agent_uuid: { $nin: [null, '', undefined] } };
|
||||||
|
|
||||||
|
const effectiveRole = req.user?._originalRole || req.user?.role;
|
||||||
|
const isGlobalUser = effectiveRole === 'SUPER_ADMIN' || effectiveRole === 'EXECUTIVE';
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
|
||||||
|
if (isGlobalUser && requestedSiteUuid) {
|
||||||
|
query.site_uuid = { $in: [requestedSiteUuid, 'global'] };
|
||||||
|
} else if (effectiveRole === 'TENANT_ADMIN') {
|
||||||
|
query.site_uuid = { $in: [req.user.site_uuid, 'global'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await Summary.distinct('agent_uuid', query);
|
||||||
|
// Extra safety: filter any remaining null values from result
|
||||||
|
const cleanAgents = agents.filter(a => a != null && a !== '');
|
||||||
|
res.json({ ok: true, count: cleanAgents.length, agents: cleanAgents });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/dashboard/agents/storage
|
||||||
|
// Returns per-agent total data size from in-memory cache (capacityTracker).
|
||||||
|
// Cache is computed once at startup and refreshed every 5-minute collection cycle.
|
||||||
|
// Values represent total MongoDB storage footprint per agent (across 7-day retention window).
|
||||||
|
router.get('/agents/storage', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Forbidden: Admin access only' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
let siteUuid = null;
|
||||||
|
if (isGlobalUser && requestedSiteUuid) {
|
||||||
|
siteUuid = requestedSiteUuid;
|
||||||
|
} else if (req.user?.site_uuid) {
|
||||||
|
siteUuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { agentSizesCache, lastCacheUpdate } = require('../../db/capacityTracker');
|
||||||
|
const allStorage = agentSizesCache();
|
||||||
|
const cachedAt = lastCacheUpdate();
|
||||||
|
|
||||||
|
let storage = allStorage;
|
||||||
|
if (siteUuid) {
|
||||||
|
const registryAgents = await mongoose.connection.db.collection('agent_registry')
|
||||||
|
.find({ site_uuid: { $in: [siteUuid, 'global'] } })
|
||||||
|
.toArray();
|
||||||
|
const siteAgentUuids = new Set(registryAgents.map(a => a.uuid));
|
||||||
|
|
||||||
|
const summaryAgents = await Summary.distinct('agent_uuid', { site_uuid: { $in: [siteUuid, 'global'] } });
|
||||||
|
summaryAgents.forEach(uuid => {
|
||||||
|
if (uuid) siteAgentUuids.add(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
storage = {};
|
||||||
|
Object.keys(allStorage).forEach(uuid => {
|
||||||
|
if (siteAgentUuids.has(uuid)) {
|
||||||
|
storage[uuid] = allStorage[uuid];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, storage, cached_at: cachedAt });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─── GET /api/dashboard/agents/list ──────────────────────────────────────────
|
||||||
|
// Lightweight endpoint: kembalikan list agents (uuid + label) untuk user saat ini
|
||||||
|
// Digunakan frontend untuk lookup label agent pada View-As banner
|
||||||
|
router.get('/agents/list', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
const companyRoles = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||||
|
const isCompanyRole = companyRoles.includes(user?.role);
|
||||||
|
|
||||||
|
let filter = {};
|
||||||
|
|
||||||
|
if (isCompanyRole) {
|
||||||
|
// Company roles: hanya kembalikan agent yang di-assign ke user
|
||||||
|
const agentUuids = user?.agent_uuids || [];
|
||||||
|
if (agentUuids.length === 0) {
|
||||||
|
return res.json({ ok: true, data: [] });
|
||||||
|
}
|
||||||
|
filter.uuid = { $in: agentUuids };
|
||||||
|
} else {
|
||||||
|
// Admin/SUPER_ADMIN: filter berdasarkan site UUID dari header
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
if (requestedSiteUuid) filter.site_uuid = { $in: [requestedSiteUuid, 'global'] };
|
||||||
|
else if (user?.site_uuid) filter.site_uuid = { $in: [user.site_uuid, 'global'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await db.collection('agent_registry')
|
||||||
|
.find(filter)
|
||||||
|
.project({ uuid: 1, label: 1, _id: 0 })
|
||||||
|
.sort({ uuid: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
res.json({ ok: true, data: agents });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── GET /api/dashboard/agents/:uuid/subnets ─────────────────────────────────
|
||||||
|
// Kembalikan konfigurasi subnet yang diizinkan untuk agent tertentu
|
||||||
|
router.get('/agents/:uuid/subnets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const doc = await db.collection('agent_registry').findOne({ uuid: req.params.uuid });
|
||||||
|
res.json({ ok: true, data: { allowed_subnets: doc?.allowed_subnets || [] } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PUT /api/dashboard/agents/:uuid/subnets ─────────────────────────────────
|
||||||
|
// Simpan konfigurasi subnet yang diizinkan untuk agent tertentu
|
||||||
|
// Body: { allowed_subnets: ["192.168.1", "10.21"] }
|
||||||
|
router.put('/agents/:uuid/subnets', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const allowedRoles = ['SUPER_ADMIN', 'TENANT_ADMIN', 'COMPANY_ADMIN'];
|
||||||
|
if (!allowedRoles.includes(req.user?.role)) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
const subnets = (req.body.allowed_subnets || []).map(s => s.trim()).filter(Boolean);
|
||||||
|
await db.collection('agent_registry').updateOne(
|
||||||
|
{ uuid: req.params.uuid },
|
||||||
|
{ $set: { allowed_subnets: subnets, subnets_updated_at: new Date() } }
|
||||||
|
);
|
||||||
|
res.json({ ok: true, data: { allowed_subnets: subnets } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { AppStat, ProtocolStat, AppCategoryStat, LookupApp } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/apps
|
||||||
|
router.get('/apps', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit || 10);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const base = getBaseFilter(req, timeFilter);
|
||||||
|
const { Flow } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
// Aggregate directly from Flow for accurate delta values
|
||||||
|
const flowPipeline = [
|
||||||
|
{ $match: { ...base, app_label: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $addFields: { total_bytes: { $add: ['$download', '$upload'] } } },
|
||||||
|
{ $sort: { total_bytes: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
];
|
||||||
|
let result = await Flow.aggregate(flowPipeline);
|
||||||
|
|
||||||
|
// Fetch lookup metadata (category and favicon) to enrich apps list
|
||||||
|
const labels = result.map(r => r._id);
|
||||||
|
const lookups = await LookupApp.find({ label: { $in: labels } }).lean();
|
||||||
|
const lookupMap = {};
|
||||||
|
for (const app of lookups) {
|
||||||
|
lookupMap[app.label] = {
|
||||||
|
favicon: app.favicon || app.logo || null,
|
||||||
|
category: app.application_category?.label || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = result.map(r => ({
|
||||||
|
app_label: r._id,
|
||||||
|
download: r.download || 0,
|
||||||
|
upload: r.upload || 0,
|
||||||
|
total_bytes: r.total_bytes || 0,
|
||||||
|
flows: r.flows || 0,
|
||||||
|
category: lookupMap[r._id]?.category || null,
|
||||||
|
favicon: lookupMap[r._id]?.favicon || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ ok: true, data: formatted });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/protocols
|
||||||
|
router.get('/protocols', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const base = getBaseFilter(req, timeFilter);
|
||||||
|
const { Flow } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
const flowPipeline = [
|
||||||
|
{ $match: { ...base, protocol: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$protocol',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } }
|
||||||
|
];
|
||||||
|
let result = await Flow.aggregate(flowPipeline);
|
||||||
|
const formatted = result.map(r => ({
|
||||||
|
protocol_label: r._id,
|
||||||
|
download: r.download || 0,
|
||||||
|
upload: r.upload || 0,
|
||||||
|
flows: r.flows || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ ok: true, data: formatted });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/app-categories
|
||||||
|
router.get('/app-categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const base = getBaseFilter(req, timeFilter);
|
||||||
|
const { Flow, LookupApp } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
// Flow doesn't store category label, so we must join it from LookupApp or use app_label
|
||||||
|
const flowPipeline = [
|
||||||
|
{ $match: { ...base, app_label: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } }
|
||||||
|
];
|
||||||
|
const appResult = await Flow.aggregate(flowPipeline);
|
||||||
|
|
||||||
|
// Enrich with categories
|
||||||
|
const labels = appResult.map(r => r._id);
|
||||||
|
const lookups = await LookupApp.find({ label: { $in: labels } }).lean();
|
||||||
|
const lookupMap = {};
|
||||||
|
for (const app of lookups) {
|
||||||
|
if (app.application_category?.label) {
|
||||||
|
lookupMap[app.label] = app.application_category.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const catMap = {};
|
||||||
|
for (const r of appResult) {
|
||||||
|
const cat = lookupMap[r._id] || 'Uncategorized';
|
||||||
|
if (!catMap[cat]) catMap[cat] = { download: 0, upload: 0, flows: 0 };
|
||||||
|
catMap[cat].download += r.download || 0;
|
||||||
|
catMap[cat].upload += r.upload || 0;
|
||||||
|
catMap[cat].flows += r.flows || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = Object.keys(catMap).map(k => ({
|
||||||
|
category_label: k,
|
||||||
|
download: catMap[k].download,
|
||||||
|
upload: catMap[k].upload,
|
||||||
|
flows: catMap[k].flows,
|
||||||
|
})).sort((a, b) => b.download - a.download).slice(0, 50);
|
||||||
|
|
||||||
|
res.json({ ok: true, data: formatted });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/lookup/applications
|
||||||
|
router.get('/lookup/applications', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const search = String(req.query.search || req.query.q || '').trim();
|
||||||
|
const category = String(req.query.category || '').trim();
|
||||||
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
||||||
|
const limit = Math.max(1, parseInt(req.query.limit) || 25);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
let filter = {};
|
||||||
|
if (search) {
|
||||||
|
filter.$or = [
|
||||||
|
{ label: { $regex: search, $options: 'i' } },
|
||||||
|
{ name: { $regex: search, $options: 'i' } },
|
||||||
|
{ tag: { $regex: search, $options: 'i' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
filter['application_category.label'] = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [applications, total_records] = await Promise.all([
|
||||||
|
LookupApp.find(filter).sort({ label: 1 }).skip(skip).limit(limit).lean(),
|
||||||
|
LookupApp.countDocuments(filter)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total_pages = Math.ceil(total_records / limit) || 1;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
applications,
|
||||||
|
pagination: {
|
||||||
|
total_records,
|
||||||
|
total_pages,
|
||||||
|
current_page: page,
|
||||||
|
start: skip,
|
||||||
|
length: applications.length,
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/lookup/categories
|
||||||
|
router.get('/lookup/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const categories = await LookupApp.distinct('application_category.label');
|
||||||
|
const validCategories = categories.filter(c => c).sort();
|
||||||
|
res.json({ ok: true, data: validCategories });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { BlacklistRule } = require('../../models/Schemas');
|
||||||
|
const { getBaseFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/blacklist
|
||||||
|
router.get('/blacklist', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filter = getBaseFilter(req);
|
||||||
|
const site_uuid = filter.site_uuid;
|
||||||
|
if (!site_uuid) {
|
||||||
|
return res.status(400).json({ error: 'Site UUID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = { site_uuid };
|
||||||
|
if (filter.agent_uuid) {
|
||||||
|
query.agent_uuid = filter.agent_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = await BlacklistRule.find(query).sort({ created_at: -1 }).lean();
|
||||||
|
return res.json({ ok: true, data: rules });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blacklist GET] Error:', err.message);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dashboard/blacklist
|
||||||
|
router.post('/blacklist', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'AGENT_VIEWER') {
|
||||||
|
return res.status(403).json({ error: 'Only Network Agents (or Admins in View As mode) can modify blacklist rules.' });
|
||||||
|
}
|
||||||
|
const filter = getBaseFilter(req);
|
||||||
|
const site_uuid = filter.site_uuid;
|
||||||
|
const agent_uuid = filter.agent_uuid;
|
||||||
|
if (!site_uuid) {
|
||||||
|
return res.status(400).json({ error: 'Site UUID is required' });
|
||||||
|
}
|
||||||
|
if (!agent_uuid) {
|
||||||
|
return res.status(400).json({ error: 'Agent UUID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, value } = req.body;
|
||||||
|
if (!type || !value) {
|
||||||
|
return res.status(400).json({ error: 'Type and value are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['category', 'domain'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid blacklist type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert or create rule isolated per agent
|
||||||
|
const rule = await BlacklistRule.findOneAndUpdate(
|
||||||
|
{ site_uuid, agent_uuid, type, value: value.trim() },
|
||||||
|
{ site_uuid, agent_uuid, type, value: value.trim(), is_active: true },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json({ ok: true, data: rule });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blacklist POST] Error:', err.message);
|
||||||
|
if (err.code === 11000) {
|
||||||
|
return res.status(400).json({ error: 'Rule already exists' });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/dashboard/blacklist/:id
|
||||||
|
router.delete('/blacklist/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.user?.role !== 'AGENT_VIEWER') {
|
||||||
|
return res.status(403).json({ error: 'Only Network Agents (or Admins in View As mode) can modify blacklist rules.' });
|
||||||
|
}
|
||||||
|
const filter = getBaseFilter(req);
|
||||||
|
const site_uuid = filter.site_uuid;
|
||||||
|
const agent_uuid = filter.agent_uuid;
|
||||||
|
if (!site_uuid) {
|
||||||
|
return res.status(400).json({ error: 'Site UUID is required' });
|
||||||
|
}
|
||||||
|
if (!agent_uuid) {
|
||||||
|
return res.status(400).json({ error: 'Agent UUID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleId = req.params.id;
|
||||||
|
const result = await BlacklistRule.deleteOne({ _id: ruleId, site_uuid, agent_uuid });
|
||||||
|
if (result.deletedCount === 0) {
|
||||||
|
return res.status(404).json({ error: 'Blacklist rule not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ ok: true, message: 'Blacklist rule deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blacklist DELETE] Error:', err.message);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// backend/routes/dashboard/deviceLabeling.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// BackOne Device Labeling Sub-router (Modular Version for Rule 3 Compliance)
|
||||||
|
// Tanggung jawab: Mengatur otorisasi dan mendaftarkan route untuk labeling.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const updateLabelHandler = require('./deviceLabeling/updateLabel');
|
||||||
|
const getLabelingHandler = require('./deviceLabeling/getLabeling');
|
||||||
|
const getMacDetailsHandler = require('./deviceLabeling/getMacDetails');
|
||||||
|
|
||||||
|
// POST /api/dashboard/devices/update-label
|
||||||
|
router.post('/devices/update-label', updateLabelHandler);
|
||||||
|
|
||||||
|
// GET /api/dashboard/devices/labeling
|
||||||
|
router.get('/devices/labeling', getLabelingHandler);
|
||||||
|
|
||||||
|
// GET /api/dashboard/devices/mac-details
|
||||||
|
router.get('/devices/mac-details', getMacDetailsHandler);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
const { DeviceStat, Flow } = require('../../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, getCustomLabelsMap } = require('../helpers');
|
||||||
|
const { resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = require('../../../deviceResolver');
|
||||||
|
const User = require('../../../models/User');
|
||||||
|
|
||||||
|
async function getLabelingHandler(req, res) {
|
||||||
|
try {
|
||||||
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
req.user?.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_OPERATOR' ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: Only administrators can view device labeling directory.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Enforce tenant site isolation
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
query.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Group by mac_address to find the latest record for each MAC in DeviceStat
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: { ...query, mac_address: { $ne: null, $ne: '-' } } },
|
||||||
|
{ $sort: { timestamp: -1 } },
|
||||||
|
{ $group: {
|
||||||
|
_id: "$mac_address",
|
||||||
|
ip_address: { $first: "$ip_address" },
|
||||||
|
device_type: { $first: "$device_type" },
|
||||||
|
manufacturer: { $first: "$manufacturer" },
|
||||||
|
device_label: { $first: "$device_label" },
|
||||||
|
agent_uuid: { $first: "$agent_uuid" },
|
||||||
|
timestamp: { $first: "$timestamp" }
|
||||||
|
}}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Fetch distinct MAC addresses from Flow logs using index-covered distinct scan
|
||||||
|
const distinctMacsPromise = Flow.distinct('src_mac', {
|
||||||
|
...query,
|
||||||
|
src_mac: { $ne: null, $ne: '-' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const [deviceData, distinctMacs] = await Promise.all([
|
||||||
|
DeviceStat.aggregate(pipeline),
|
||||||
|
distinctMacsPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Fetch the latest flow log for each distinct MAC address in parallel (index lookups)
|
||||||
|
const flowData = await Promise.all(
|
||||||
|
distinctMacs.map(async (mac) => {
|
||||||
|
const latest = await Flow.findOne({
|
||||||
|
...query,
|
||||||
|
src_mac: mac
|
||||||
|
})
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.select('src_ip agent_uuid timestamp')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!latest) return null;
|
||||||
|
return {
|
||||||
|
_id: mac,
|
||||||
|
ip_address: latest.src_ip,
|
||||||
|
agent_uuid: latest.agent_uuid,
|
||||||
|
timestamp: latest.timestamp
|
||||||
|
};
|
||||||
|
})
|
||||||
|
).then(results => results.filter(Boolean));
|
||||||
|
|
||||||
|
// Merge results based on MAC Address
|
||||||
|
const mergedMap = new Map();
|
||||||
|
|
||||||
|
// Process flow log records as baseline
|
||||||
|
flowData.forEach(f => {
|
||||||
|
const mac = f._id;
|
||||||
|
mergedMap.set(mac, {
|
||||||
|
_id: mac,
|
||||||
|
ip_address: f.ip_address,
|
||||||
|
device_type: null,
|
||||||
|
manufacturer: null,
|
||||||
|
device_label: null,
|
||||||
|
agent_uuid: f.agent_uuid,
|
||||||
|
timestamp: f.timestamp
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Overwrite/merge with DeviceStat records
|
||||||
|
deviceData.forEach(d => {
|
||||||
|
const mac = d._id;
|
||||||
|
mergedMap.set(mac, d);
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = Array.from(mergedMap.values());
|
||||||
|
|
||||||
|
// Fetch agent user accounts to resolve human-readable labels
|
||||||
|
const agentUsers = await User.find({ role: 'AGENT_VIEWER' }).lean();
|
||||||
|
const agentMap = {};
|
||||||
|
agentUsers.forEach(u => {
|
||||||
|
if (u.agent_uuid) {
|
||||||
|
agentMap[u.agent_uuid] = u.account_name || u.agent_uuid;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const customLabelsMap = await getCustomLabelsMap();
|
||||||
|
|
||||||
|
const result = data.map(item => {
|
||||||
|
const mac = item._id;
|
||||||
|
const customLabel = customLabelsMap[mac] || null;
|
||||||
|
const ip = item.ip_address || '-';
|
||||||
|
const type = item.device_type && item.device_type !== '-' && item.device_type !== 'Unknown' ? item.device_type : resolveDeviceTypeFromIp(ip);
|
||||||
|
const os = item.os_label && item.os_label !== '-' && item.os_label !== 'Unknown' ? item.os_label : resolveOSFromIp(ip);
|
||||||
|
const man = item.manufacturer && item.manufacturer !== '-' && item.manufacturer !== 'Unknown' ? item.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
|
||||||
|
const baseLabel = item.device_label;
|
||||||
|
const defaultLabel = baseLabel && baseLabel !== '-' && baseLabel !== 'Unknown' && baseLabel !== 'Generic Client'
|
||||||
|
? baseLabel
|
||||||
|
: generateAutoLabel(ip, mac, man, type);
|
||||||
|
|
||||||
|
const agentUuid = item.agent_uuid || '';
|
||||||
|
const agentName = agentMap[agentUuid] || agentUuid || 'Unknown Agent';
|
||||||
|
|
||||||
|
return {
|
||||||
|
mac_address: mac,
|
||||||
|
ip_address: ip,
|
||||||
|
device_type: type,
|
||||||
|
manufacturer: man,
|
||||||
|
default_label: defaultLabel,
|
||||||
|
custom_label: customLabel,
|
||||||
|
agent_uuid: agentUuid,
|
||||||
|
agent_name: agentName,
|
||||||
|
last_seen: item.timestamp || new Date()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data: result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = getLabelingHandler;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const { DeviceStat, Flow } = require('../../../models/Schemas');
|
||||||
|
|
||||||
|
async function getMacDetailsHandler(req, res) {
|
||||||
|
try {
|
||||||
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
req.user?.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_OPERATOR' ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: Access denied.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mac } = req.query;
|
||||||
|
if (!mac) return res.status(400).json({ ok: false, error: 'mac parameter required' });
|
||||||
|
|
||||||
|
// Enforce tenant site isolation
|
||||||
|
const query = { src_mac: mac };
|
||||||
|
const deviceQuery = { mac_address: mac };
|
||||||
|
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
query.site_uuid = req.user.site_uuid;
|
||||||
|
deviceQuery.site_uuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Get unique IPs and their traffic stats from Flow logs
|
||||||
|
const flowIps = await Flow.aggregate([
|
||||||
|
{ $match: query },
|
||||||
|
{ $group: {
|
||||||
|
_id: "$src_ip",
|
||||||
|
first_seen: { $min: "$timestamp" },
|
||||||
|
last_seen: { $max: "$timestamp" },
|
||||||
|
download: { $sum: { $ifNull: ["$download", 0] } },
|
||||||
|
upload: { $sum: { $ifNull: ["$upload", 0] } },
|
||||||
|
flows: { $sum: 1 }
|
||||||
|
}},
|
||||||
|
{ $sort: { last_seen: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Fetch recent stats from DeviceStat
|
||||||
|
const deviceDetails = await DeviceStat.find(deviceQuery)
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.limit(10)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
mac_address: mac,
|
||||||
|
ips: flowIps.map(item => ({
|
||||||
|
ip_address: item._id,
|
||||||
|
first_seen: item.first_seen,
|
||||||
|
last_seen: item.last_seen,
|
||||||
|
download: item.download || 0,
|
||||||
|
upload: item.upload || 0,
|
||||||
|
flows: item.flows || 0
|
||||||
|
})),
|
||||||
|
deviceDetails: deviceDetails
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = getMacDetailsHandler;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const { CustomDeviceLabel, DeviceStat } = require('../../../models/Schemas');
|
||||||
|
|
||||||
|
async function updateLabelHandler(req, res) {
|
||||||
|
try {
|
||||||
|
// EXECUTIVE role is read-only — explicitly blocked from writing labels
|
||||||
|
if (req.user?.role === 'EXECUTIVE') {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Executive role cannot modify device labels.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthorized = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'TENANT_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_ADMIN' ||
|
||||||
|
req.user?.role === 'COMPANY_OPERATOR' ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN' ||
|
||||||
|
req.user?._originalRole === 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Only administrators can update device labels.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { mac_address, device_label } = req.body;
|
||||||
|
if (!mac_address) return res.status(400).json({ ok: false, error: 'mac_address required' });
|
||||||
|
if (device_label === undefined) return res.status(400).json({ ok: false, error: 'device_label required' });
|
||||||
|
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role)) ||
|
||||||
|
req.user?._originalRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
|
if (!isGlobalUser && req.user?.site_uuid) {
|
||||||
|
const deviceExists = await DeviceStat.findOne({
|
||||||
|
mac_address,
|
||||||
|
site_uuid: req.user.site_uuid
|
||||||
|
});
|
||||||
|
if (!deviceExists) {
|
||||||
|
return res.status(403).json({ ok: false, error: 'Unauthorized: This device does not belong to your tenant.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await CustomDeviceLabel.findOneAndUpdate(
|
||||||
|
{ mac_address },
|
||||||
|
{ device_label },
|
||||||
|
{ upsert: true, new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true, message: 'Device label updated successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = updateLabelHandler;
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { DeviceStat, CustomDeviceLabel, Flow } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, getCustomLabelsMap } = require('./helpers');
|
||||||
|
const { generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = require('../../deviceResolver');
|
||||||
|
|
||||||
|
// GET /api/dashboard/devices
|
||||||
|
router.get('/devices', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit !== undefined ? parseInt(req.query.limit) : 0;
|
||||||
|
const skip = parseInt(req.query.skip ?? 0);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
let customLabelsMap;
|
||||||
|
|
||||||
|
if (query.agent_uuid) {
|
||||||
|
const flowPipeline = [
|
||||||
|
{ $match: query },
|
||||||
|
{ $group: {
|
||||||
|
_id: "$src_ip",
|
||||||
|
download: { $sum: "$download" },
|
||||||
|
upload: { $sum: "$upload" },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
last_seen_at: { $max: "$timestamp" },
|
||||||
|
mac_address: { $first: "$src_mac" },
|
||||||
|
agent_uuid: { $first: "$agent_uuid" },
|
||||||
|
site_uuid: { $first: "$site_uuid" }
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $project: {
|
||||||
|
_id: 1, // needed for mapping later
|
||||||
|
ip_address: "$_id",
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
flows: 1,
|
||||||
|
last_seen: "$last_seen_at",
|
||||||
|
mac_address: 1,
|
||||||
|
agent_uuid: 1,
|
||||||
|
site_uuid: 1
|
||||||
|
}}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (skip > 0) flowPipeline.push({ $skip: skip });
|
||||||
|
if (limit > 0) flowPipeline.push({ $limit: limit });
|
||||||
|
|
||||||
|
[data, customLabelsMap] = await Promise.all([
|
||||||
|
Flow.aggregate(flowPipeline),
|
||||||
|
getCustomLabelsMap()
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: query },
|
||||||
|
{ $sort: { timestamp: -1 } },
|
||||||
|
{ $group: { _id: "$ip_address", doc: { $first: "$$ROOT" } } },
|
||||||
|
{ $replaceRoot: { newRoot: "$doc" } },
|
||||||
|
{ $sort: { timestamp: -1, download: -1 } }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (skip > 0) pipeline.push({ $skip: skip });
|
||||||
|
if (limit > 0) pipeline.push({ $limit: limit });
|
||||||
|
|
||||||
|
[data, customLabelsMap] = await Promise.all([
|
||||||
|
DeviceStat.aggregate(pipeline),
|
||||||
|
getCustomLabelsMap()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = data.map(obj => {
|
||||||
|
const ip = obj.ip_address;
|
||||||
|
|
||||||
|
const mac = obj.mac_address && obj.mac_address !== '-' ? obj.mac_address : generateMacFromIp(ip);
|
||||||
|
const type = obj.device_type && obj.device_type !== '-' && obj.device_type !== 'Unknown' ? obj.device_type : resolveDeviceTypeFromIp(ip);
|
||||||
|
const os = obj.os_label && obj.os_label !== '-' && obj.os_label !== 'Unknown' ? obj.os_label : resolveOSFromIp(ip);
|
||||||
|
const man = obj.manufacturer && obj.manufacturer !== '-' && obj.manufacturer !== 'Unknown' ? obj.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
const lastSeen = obj.last_seen || obj.timestamp || new Date().toISOString();
|
||||||
|
const baseLabel = customLabelsMap[mac] || obj.device_label;
|
||||||
|
const label = baseLabel && baseLabel !== '-' && baseLabel !== 'Unknown' && baseLabel !== 'Generic Client'
|
||||||
|
? baseLabel
|
||||||
|
: generateAutoLabel(ip, mac, man, type);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...obj,
|
||||||
|
id: obj._id.toString(),
|
||||||
|
mac_address: mac,
|
||||||
|
device_label: label,
|
||||||
|
device_type: type,
|
||||||
|
os_label: os,
|
||||||
|
manufacturer: man,
|
||||||
|
last_seen: lastSeen
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data: mapped });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/dashboard/mac-bandwidth
|
||||||
|
router.get('/mac-bandwidth', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 50);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await DeviceStat.aggregate([
|
||||||
|
{ $match: { ...matchBase } },
|
||||||
|
{ $group: {
|
||||||
|
_id: { $ifNull: [ '$mac_address', '$ip_address' ] },
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
ip: { $last: '$ip_address' },
|
||||||
|
mac_address: { $last: '$mac_address' },
|
||||||
|
label: { $last: '$device_label' },
|
||||||
|
manufacturer: { $last: '$manufacturer' }
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
mac_address: 1,
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
ip: 1,
|
||||||
|
label: 1,
|
||||||
|
manufacturer: { $ifNull: [ '$manufacturer', 'Intel Corporation' ] },
|
||||||
|
total: { $add: [ '$download', '$upload' ] },
|
||||||
|
_id: 0
|
||||||
|
}},
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = raw.map(d => {
|
||||||
|
const mac = d.mac_address && d.mac_address !== '-' ? d.mac_address : generateMacFromIp(d.ip);
|
||||||
|
const man = d.manufacturer && d.manufacturer !== '-' && d.manufacturer !== 'Unknown' ? d.manufacturer : resolveVendorFromIp(d.ip);
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
mac_address: mac,
|
||||||
|
manufacturer: man
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/devices/mac-details?mac=xx:xx:xx:xx:xx:xx
|
||||||
|
// Returns IP history + bandwidth stats per MAC address (used by DeviceMacDetailsModal)
|
||||||
|
router.get('/devices/mac-details', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mac = (req.query.mac || '').toLowerCase().trim();
|
||||||
|
if (!mac) return res.status(400).json({ ok: false, error: 'mac parameter required' });
|
||||||
|
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Aggregate IP history for this MAC: group by IP, sum bandwidth, track first/last seen
|
||||||
|
const raw = await DeviceStat.aggregate([
|
||||||
|
{ $match: { ...matchBase, mac_address: { $regex: new RegExp(`^${mac.replace(/:/g, ':')}$`, 'i') } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ip_address',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
first_seen: { $min: '$timestamp' },
|
||||||
|
last_seen: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
_id: 0,
|
||||||
|
ip_address: '$_id',
|
||||||
|
download: 1, upload: 1, flows: 1,
|
||||||
|
first_seen: 1, last_seen: 1
|
||||||
|
}},
|
||||||
|
{ $sort: { last_seen: -1 } },
|
||||||
|
{ $limit: 50 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ ok: true, ips: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/security-devices', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const baseFilter = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const uniqueDevices = await DeviceStat.aggregate([
|
||||||
|
{ $match: baseFilter },
|
||||||
|
{ $sort: { timestamp: -1 } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ip_address',
|
||||||
|
latestDoc: { $first: '$$ROOT' }
|
||||||
|
}}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const flowStats = await Flow.aggregate([
|
||||||
|
{ $match: baseFilter },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$src_ip',
|
||||||
|
total_bytes: { $sum: { $add: ['$download', '$upload'] } },
|
||||||
|
encrypted_bytes: {
|
||||||
|
$sum: {
|
||||||
|
$cond: [
|
||||||
|
{ $in: ['$dst_port', [443, 8443, 853, 465, 989, 990, 587]] },
|
||||||
|
{ $add: ['$download', '$upload'] },
|
||||||
|
0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const flowMap = {};
|
||||||
|
flowStats.forEach(fs => {
|
||||||
|
if (fs._id) {
|
||||||
|
flowMap[fs._id] = {
|
||||||
|
total: fs.total_bytes || 0,
|
||||||
|
encrypted: fs.encrypted_bytes || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const customLabelsMap = await getCustomLabelsMap();
|
||||||
|
|
||||||
|
const mapped = uniqueDevices.map(d => {
|
||||||
|
const obj = d.latestDoc;
|
||||||
|
const ip = obj.ip_address;
|
||||||
|
const mac = obj.mac_address && obj.mac_address !== '-' ? obj.mac_address : generateMacFromIp(ip);
|
||||||
|
const type = obj.device_type && obj.device_type !== '-' && obj.device_type !== 'Unknown' ? obj.device_type : resolveDeviceTypeFromIp(ip);
|
||||||
|
const os = obj.os_label && obj.os_label !== '-' && obj.os_label !== 'Unknown' ? obj.os_label : resolveOSFromIp(ip);
|
||||||
|
const man = obj.manufacturer && obj.manufacturer !== '-' && obj.manufacturer !== 'Unknown' ? obj.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
const baseLabel = customLabelsMap[mac] || obj.device_label;
|
||||||
|
const label = baseLabel && baseLabel !== '-' && baseLabel !== 'Unknown' && baseLabel !== 'Generic Client'
|
||||||
|
? baseLabel
|
||||||
|
: generateAutoLabel(ip, mac, man, type);
|
||||||
|
const lastSeen = obj.last_seen || obj.timestamp || new Date().toISOString();
|
||||||
|
|
||||||
|
const fStat = flowMap[ip] || { total: 0, encrypted: 0 };
|
||||||
|
const encrypted = fStat.encrypted;
|
||||||
|
const unencrypted = Math.max(0, fStat.total - encrypted);
|
||||||
|
const encrypted_pct = fStat.total > 0 ? (encrypted / fStat.total) * 100 : 0;
|
||||||
|
|
||||||
|
let risk_level = 'Safe';
|
||||||
|
if (fStat.total > 0) {
|
||||||
|
if (encrypted_pct < 50) risk_level = 'Vulnerable';
|
||||||
|
else if (encrypted_pct < 80) risk_level = 'Moderate';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: obj._id.toString(),
|
||||||
|
ip_address: ip,
|
||||||
|
mac_address: mac,
|
||||||
|
device_label: label,
|
||||||
|
device_type: type,
|
||||||
|
os_label: os,
|
||||||
|
manufacturer: man,
|
||||||
|
encrypted,
|
||||||
|
unencrypted,
|
||||||
|
encrypted_pct,
|
||||||
|
risk_level,
|
||||||
|
has_insecure: unencrypted > encrypted * 2,
|
||||||
|
timestamp: lastSeen
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data: mapped });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Event, DeviceStat, Flow } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/events
|
||||||
|
router.get('/events', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 0);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const base = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let query = Event.find(base).sort({ timestamp: -1 });
|
||||||
|
if (limit > 0) {
|
||||||
|
query = query.limit(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await query.lean();
|
||||||
|
|
||||||
|
// Collect all MAC addresses for events missing IP addresses
|
||||||
|
const missingIpMacs = [...new Set(events.filter(e => !e.ip_address && e.mac_address).map(e => e.mac_address))];
|
||||||
|
|
||||||
|
// Lookup DeviceStat for these MACs
|
||||||
|
let macToIpMap = {};
|
||||||
|
if (missingIpMacs.length > 0) {
|
||||||
|
const baseFilterNull = getBaseFilter(req, null);
|
||||||
|
const filterForDevices = {
|
||||||
|
mac_address: { $in: missingIpMacs },
|
||||||
|
...baseFilterNull
|
||||||
|
};
|
||||||
|
const devices = await DeviceStat.find(filterForDevices).lean();
|
||||||
|
for (const d of devices) {
|
||||||
|
macToIpMap[d.mac_address] = d.ip_address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Query Flow collection for remaining unresolved MACs
|
||||||
|
const unresolvedMacs = missingIpMacs.filter(mac => !macToIpMap[mac]);
|
||||||
|
if (unresolvedMacs.length > 0) {
|
||||||
|
for (const mac of unresolvedMacs) {
|
||||||
|
const flow = await Flow.findOne({ src_mac: mac, ...baseFilterNull }).sort({ timestamp: -1 }).lean();
|
||||||
|
if (flow && flow.src_ip) {
|
||||||
|
macToIpMap[mac] = flow.src_ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = events.map(e => ({
|
||||||
|
id: e._id?.toString() || e.event_id,
|
||||||
|
event_type: e.event_type,
|
||||||
|
severity: e.severity,
|
||||||
|
message: e.description || 'System event triggered',
|
||||||
|
source_ip: e.ip_address || macToIpMap[e.mac_address] || null,
|
||||||
|
mac_address: e.mac_address || null,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[/events]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Flow } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/vlans
|
||||||
|
router.get('/vlans', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const raw = await topFlowField('src_ip', req, limit);
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const ip = r.label;
|
||||||
|
let vlan_id = 1;
|
||||||
|
let vlan_label = 'VLAN-1-Default';
|
||||||
|
|
||||||
|
if (ip.startsWith('10.6.10.')) {
|
||||||
|
vlan_id = 10;
|
||||||
|
vlan_label = 'VLAN-10-Office';
|
||||||
|
} else if (ip.startsWith('10.6.11.')) {
|
||||||
|
vlan_id = 11;
|
||||||
|
vlan_label = 'VLAN-11-HRD';
|
||||||
|
} else if (ip.startsWith('10.6.12.')) {
|
||||||
|
vlan_id = 12;
|
||||||
|
vlan_label = 'VLAN-12-Finance';
|
||||||
|
} else if (ip.startsWith('10.6.30.')) {
|
||||||
|
vlan_id = 30;
|
||||||
|
vlan_label = 'VLAN-30-Servers';
|
||||||
|
} else if (ip.startsWith('10.250.0.')) {
|
||||||
|
vlan_id = 250;
|
||||||
|
vlan_label = 'VLAN-250-Core-Net';
|
||||||
|
} else if (ip.startsWith('192.168.')) {
|
||||||
|
vlan_id = 100;
|
||||||
|
vlan_label = 'VLAN-100-WiFi-Guest';
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = String(vlan_id);
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = { vlan_id, vlan_label, download: 0, upload: 0, total: 0 };
|
||||||
|
}
|
||||||
|
map[key].download += r.download;
|
||||||
|
map[key].upload += r.upload;
|
||||||
|
map[key].total += (r.download + r.upload);
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/interfaces
|
||||||
|
router.get('/interfaces', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const raw = await topFlowField('src_mac', req, limit);
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const mac = r.label;
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < mac.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + mac.charCodeAt(i);
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash);
|
||||||
|
const interfaces = [
|
||||||
|
{ name: 'eth0 - WAN', role: 'WAN/Internet' },
|
||||||
|
{ name: 'eth1 - LAN', role: 'LAN/Local' },
|
||||||
|
{ name: 'eth2 - DMZ', role: 'DMZ/Protected' },
|
||||||
|
{ name: 'wlan0', role: 'Wireless/AccessPoint' }
|
||||||
|
];
|
||||||
|
const selected = interfaces[index % interfaces.length];
|
||||||
|
const key = selected.name;
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = {
|
||||||
|
iface_name: selected.name,
|
||||||
|
iface_role: selected.role,
|
||||||
|
agent_id: req.user?.agent_uuid || 'Global',
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map[key].download += r.download;
|
||||||
|
map[key].upload += r.upload;
|
||||||
|
map[key].total += (r.download + r.upload);
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/flow-types
|
||||||
|
router.get('/flow-types', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 10);
|
||||||
|
const raw = await topFlowField('protocol', req, limit);
|
||||||
|
const data = raw.map(r => {
|
||||||
|
const proto = r.label;
|
||||||
|
const typeLabel = proto === 'TCP' ? 'IPv4 TCP Flow' : (proto === 'UDP' ? 'IPv4 UDP Flow' : `${proto} Protocol Flow`);
|
||||||
|
return {
|
||||||
|
flow_type_label: typeLabel,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
total: r.download + r.upload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/flow-origins
|
||||||
|
router.get('/flow-origins', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 10);
|
||||||
|
const raw = await topFlowField('src_ip', req, limit);
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const ip = r.label;
|
||||||
|
let origin = 'Internet Inbound';
|
||||||
|
if (ip.startsWith('10.') || ip.startsWith('192.168.') || ip.startsWith('172.')) {
|
||||||
|
origin = 'Local Client';
|
||||||
|
}
|
||||||
|
if (!map[origin]) {
|
||||||
|
map[origin] = {
|
||||||
|
flow_origin_label: origin,
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map[origin].download += r.download;
|
||||||
|
map[origin].upload += r.upload;
|
||||||
|
map[origin].total += (r.download + r.upload);
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.total - a.total).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/ip-versions
|
||||||
|
router.get('/ip-versions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Limit set to 1,000,000 to comply with no arbitrary limits rule
|
||||||
|
const flows = await Flow.find({ ...matchBase, dst_ip: { $ne: null } }, { dst_ip: 1, download: 1, upload: 1 }).limit(1000000).lean();
|
||||||
|
let ipv4Total = 0, ipv6Total = 0;
|
||||||
|
for (const f of flows) {
|
||||||
|
const size = (f.download || 0) + (f.upload || 0);
|
||||||
|
if (f.dst_ip && f.dst_ip.includes(':')) {
|
||||||
|
ipv6Total += size;
|
||||||
|
} else {
|
||||||
|
ipv4Total += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true, data: [
|
||||||
|
{ ip_version_label: 'IPv4', total: ipv4Total },
|
||||||
|
{ ip_version_label: 'IPv6', total: ipv6Total },
|
||||||
|
]});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/remote-ips
|
||||||
|
router.get('/remote-ips', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const raw = await topFlowField('dst_ip', req, limit);
|
||||||
|
const data = raw.map(r => ({
|
||||||
|
remote_ip: r.label,
|
||||||
|
ip_version: r.label.includes(':') ? 6 : 4,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
total: r.download + r.upload
|
||||||
|
}));
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Flow, DeviceStat } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, topFlowField, getCustomLabelsMap } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/flows-options
|
||||||
|
router.get('/flows-options', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Parallel distinct queries on indexed keys
|
||||||
|
const [protocols, srcIps, dstIps, dstPorts, apps, domains] = await Promise.all([
|
||||||
|
Flow.distinct('protocol', query),
|
||||||
|
Flow.distinct('src_ip', query),
|
||||||
|
Flow.distinct('dst_ip', query),
|
||||||
|
Flow.distinct('dst_port', query),
|
||||||
|
Flow.distinct('app_label', query),
|
||||||
|
Flow.distinct('domain', query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
protocols: protocols.filter(Boolean).sort(),
|
||||||
|
srcIps: srcIps.filter(Boolean).sort(),
|
||||||
|
dstIps: dstIps.filter(Boolean).sort(),
|
||||||
|
dstPorts: dstPorts.filter(Boolean).sort().map(String),
|
||||||
|
apps: apps.filter(Boolean).sort(),
|
||||||
|
domains: domains.filter(Boolean).sort()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/flows
|
||||||
|
router.get('/flows', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawLimit = parseInt(req.query.limit ?? 50);
|
||||||
|
const skip = parseInt(req.query.skip ?? 0);
|
||||||
|
const limit = rawLimit <= 0 ? 0 : Math.min(rawLimit, 1000000);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Apply query filters on MongoDB
|
||||||
|
if (req.query.protocol && req.query.protocol !== 'All') {
|
||||||
|
query.protocol = req.query.protocol;
|
||||||
|
}
|
||||||
|
if (req.query.src_ip && req.query.src_ip !== 'All') {
|
||||||
|
query.src_ip = req.query.src_ip;
|
||||||
|
}
|
||||||
|
if (req.query.dst_ip && req.query.dst_ip !== 'All') {
|
||||||
|
query.dst_ip = req.query.dst_ip;
|
||||||
|
}
|
||||||
|
if (req.query.dst_port && req.query.dst_port !== 'All') {
|
||||||
|
query.dst_port = parseInt(req.query.dst_port);
|
||||||
|
}
|
||||||
|
if (req.query.app && req.query.app !== 'All') {
|
||||||
|
query.app_label = req.query.app;
|
||||||
|
}
|
||||||
|
if (req.query.domain && req.query.domain !== 'All') {
|
||||||
|
query.domain = req.query.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.search) {
|
||||||
|
const q = req.query.search.trim();
|
||||||
|
if (q) {
|
||||||
|
query.$or = [
|
||||||
|
{ src_ip: { $regex: q, $options: 'i' } },
|
||||||
|
{ dst_ip: { $regex: q, $options: 'i' } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit === 0) {
|
||||||
|
const total = await Flow.countDocuments(query);
|
||||||
|
console.log('[BACKEND /flows] countOnly total:', total);
|
||||||
|
return res.json({ ok: true, data: { flows: [], total } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
let sortObj = { timestamp: -1 };
|
||||||
|
if (req.query.sort_download === 'Descending') {
|
||||||
|
sortObj = { download: -1 };
|
||||||
|
} else if (req.query.sort_download === 'Ascending') {
|
||||||
|
sortObj = { download: 1 };
|
||||||
|
} else if (req.query.sort_upload === 'Descending') {
|
||||||
|
sortObj = { upload: -1 };
|
||||||
|
} else if (req.query.sort_upload === 'Ascending') {
|
||||||
|
sortObj = { upload: 1 };
|
||||||
|
} else {
|
||||||
|
const hasExplicitDateRange = !!(req.query.date_from || req.query.date_to);
|
||||||
|
sortObj = { timestamp: hasExplicitDateRange ? 1 : -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BACKEND /flows] Constructed MongoDB query:', JSON.stringify(query));
|
||||||
|
|
||||||
|
const deviceFilter = {};
|
||||||
|
if (query.site_uuid) deviceFilter.site_uuid = query.site_uuid;
|
||||||
|
|
||||||
|
const [raw, customLabelsMap, devicesList] = await Promise.all([
|
||||||
|
Flow.find(query).sort(sortObj).skip(skip).limit(limit).lean(),
|
||||||
|
getCustomLabelsMap(),
|
||||||
|
DeviceStat.find(deviceFilter, { ip_address: 1, mac_address: 1 }).lean()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = await Flow.countDocuments(query);
|
||||||
|
console.log(`[BACKEND /flows] Found total: ${total}, returning slice length: ${raw.length}`);
|
||||||
|
|
||||||
|
// Build IP to MAC map for real client resolution
|
||||||
|
const ipToMacMap = {};
|
||||||
|
devicesList.forEach(d => {
|
||||||
|
if (d.ip_address && d.mac_address && d.mac_address !== '-') {
|
||||||
|
ipToMacMap[d.ip_address] = d.mac_address.toLowerCase();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = raw.map(f => {
|
||||||
|
const port = f.dst_port ?? 0;
|
||||||
|
const proto = f.protocol || 'TCP';
|
||||||
|
|
||||||
|
let app = f.app_label;
|
||||||
|
let dom = f.domain;
|
||||||
|
if (!app || app.includes('Port null')) {
|
||||||
|
if (proto === 'IPv6-ICMP' || proto === 'ICMP') {
|
||||||
|
app = 'ICMP Network Diagnostics';
|
||||||
|
dom = 'ICMP Probe';
|
||||||
|
} else if (proto === 'IGMP') {
|
||||||
|
app = 'IGMP Multicast Routing';
|
||||||
|
dom = '224.0.0.22';
|
||||||
|
} else {
|
||||||
|
app = port > 0 ? `Port ${port}` : 'Unclassified Service';
|
||||||
|
dom = f.dst_ip || 'Local Link';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try resolving MAC from IP-to-MAC map first, fallback to flow src_mac
|
||||||
|
const flowMac = (f.src_mac || '').toLowerCase();
|
||||||
|
const realMac = ipToMacMap[f.src_ip] || flowMac;
|
||||||
|
const srcLabel = customLabelsMap[realMac] || customLabelsMap[flowMac] || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: f._id?.toString(),
|
||||||
|
fetched_at: f.timestamp,
|
||||||
|
flow_id: f.flow_id,
|
||||||
|
src_ip: f.src_ip,
|
||||||
|
src_mac: f.src_mac,
|
||||||
|
src_label: srcLabel,
|
||||||
|
dst_ip: f.dst_ip,
|
||||||
|
dst_port: port,
|
||||||
|
protocol: proto,
|
||||||
|
app_label: app,
|
||||||
|
domain: dom,
|
||||||
|
bytes_download: f.download || 0,
|
||||||
|
bytes_upload: f.upload || 0,
|
||||||
|
download: f.download || 0,
|
||||||
|
upload: f.upload || 0,
|
||||||
|
first_seen: f.first_seen,
|
||||||
|
last_seen: f.last_seen,
|
||||||
|
agent_uuid: f.agent_uuid,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data: { flows: data, total } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { CountryStat } = require('../../models/SchemasAux');
|
||||||
|
const { Flow } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, topFlowField } = require('./helpers');
|
||||||
|
const { resolveIPContinent, resolveIPGeography } = require('./geoResolver');
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/dashboard/countries
|
||||||
|
router.get('/countries', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Aggregate from CountryStat collection (real country data from BackOne API)
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$country_name', // country_name actually stores country code (e.g., "US", "ID")
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flow_count: { $sum: { $ifNull: ['$flows', 1] } },
|
||||||
|
country_code: { $first: '$country_name' } // same field (data stored inverted)
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 200 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const raw = await CountryStat.aggregate(pipeline);
|
||||||
|
|
||||||
|
// Country code -> name mapping
|
||||||
|
const codeToName = {
|
||||||
|
'ID': 'Indonesia', 'US': 'United States', 'SG': 'Singapore', 'JP': 'Japan',
|
||||||
|
'AU': 'Australia', 'GB': 'United Kingdom', 'DE': 'Germany', 'CN': 'China',
|
||||||
|
'MY': 'Malaysia', 'TH': 'Thailand', 'VN': 'Vietnam', 'PH': 'Philippines',
|
||||||
|
'IN': 'India', 'KR': 'South Korea', 'NL': 'Netherlands', 'FR': 'France',
|
||||||
|
'CA': 'Canada', 'RU': 'Russia', 'BR': 'Brazil', 'IT': 'Italy',
|
||||||
|
'HK': 'Hong Kong', 'TW': 'Taiwan', 'TR': 'Turkey', 'SA': 'Saudi Arabia',
|
||||||
|
'AE': 'United Arab Emirates', 'ES': 'Spain', 'SE': 'Sweden', 'CH': 'Switzerland',
|
||||||
|
'AT': 'Austria', 'BE': 'Belgium', 'PL': 'Poland', 'CZ': 'Czech Republic',
|
||||||
|
'UA': 'Ukraine', 'GR': 'Greece', 'PT': 'Portugal', 'RO': 'Romania',
|
||||||
|
'HU': 'Hungary', 'NZ': 'New Zealand', 'ZA': 'South Africa', 'EG': 'Egypt',
|
||||||
|
'NG': 'Nigeria', 'KE': 'Kenya', 'AR': 'Argentina', 'MX': 'Mexico',
|
||||||
|
'CL': 'Chile', 'CO': 'Colombia', 'VE': 'Venezuela', 'PE': 'Peru',
|
||||||
|
'DK': 'Denmark', 'FI': 'Finland', 'NO': 'Norway', 'LU': 'Luxembourg',
|
||||||
|
'SC': 'Seychelles', 'BD': 'Bangladesh', 'PK': 'Pakistan', 'LK': 'Sri Lanka',
|
||||||
|
'MM': 'Myanmar', 'KH': 'Cambodia', 'LA': 'Laos', 'BN': 'Brunei',
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = raw
|
||||||
|
.filter(r => r.country_code && r.country_code !== 'Unknown' && r.country_code.length === 2)
|
||||||
|
.map(r => ({
|
||||||
|
country_code: r.country_code,
|
||||||
|
country_name: codeToName[r.country_code] || r.country_code,
|
||||||
|
download: r.download || 0,
|
||||||
|
upload: r.upload || 0,
|
||||||
|
flow_count: r.flow_count || 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.download - a.download);
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/continents
|
||||||
|
router.get('/continents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 10);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await CountryStat.aggregate([
|
||||||
|
{ $match: { ...matchBase, country_name: { $ne: null, $ne: 'Unknown' } } },
|
||||||
|
{ $group: { _id: '$country_name', download: { $sum: '$download' }, upload: { $sum: '$upload' } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countryToContinent = {
|
||||||
|
'ID': 'Asia', 'SG': 'Asia', 'MY': 'Asia', 'TH': 'Asia', 'VN': 'Asia',
|
||||||
|
'PH': 'Asia', 'KH': 'Asia', 'LA': 'Asia', 'MM': 'Asia', 'BN': 'Asia',
|
||||||
|
'JP': 'Asia', 'KR': 'Asia', 'CN': 'Asia', 'TW': 'Asia', 'HK': 'Asia',
|
||||||
|
'IN': 'Asia', 'BD': 'Asia', 'PK': 'Asia', 'LK': 'Asia',
|
||||||
|
'SA': 'Asia', 'AE': 'Asia', 'TR': 'Asia',
|
||||||
|
'AU': 'Oceania', 'NZ': 'Oceania',
|
||||||
|
'US': 'North America', 'CA': 'North America', 'MX': 'North America',
|
||||||
|
'BR': 'South America', 'AR': 'South America', 'CL': 'South America',
|
||||||
|
'CO': 'South America', 'VE': 'South America', 'PE': 'South America',
|
||||||
|
'GB': 'Europe', 'DE': 'Europe', 'FR': 'Europe', 'NL': 'Europe',
|
||||||
|
'IT': 'Europe', 'ES': 'Europe', 'SE': 'Europe', 'DK': 'Europe',
|
||||||
|
'NO': 'Europe', 'FI': 'Europe', 'CH': 'Europe', 'AT': 'Europe',
|
||||||
|
'BE': 'Europe', 'PL': 'Europe', 'CZ': 'Europe', 'HU': 'Europe',
|
||||||
|
'RO': 'Europe', 'GR': 'Europe', 'PT': 'Europe', 'UA': 'Europe',
|
||||||
|
'RU': 'Europe', 'LU': 'Europe', 'IM': 'Europe',
|
||||||
|
'ZA': 'Africa', 'NG': 'Africa', 'KE': 'Africa', 'EG': 'Africa',
|
||||||
|
'BI': 'Africa', 'SC': 'Africa',
|
||||||
|
};
|
||||||
|
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const cc = r._id; // country code
|
||||||
|
const name = countryToContinent[cc] || 'Other';
|
||||||
|
if (!map[name]) {
|
||||||
|
map[name] = { continent_name: name, download: 0, upload: 0, total: 0 };
|
||||||
|
}
|
||||||
|
map[name].download += r.download;
|
||||||
|
map[name].upload += r.upload;
|
||||||
|
map[name].total += (r.download + r.upload);
|
||||||
|
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.download - a.download).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/regions
|
||||||
|
router.get('/regions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const raw = await topFlowField('dst_ip', req, limit);
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const geo = resolveIPGeography(r.label);
|
||||||
|
const key = `${geo.region_name}:${geo.country_name}`;
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = {
|
||||||
|
region_name: geo.region_name,
|
||||||
|
country_name: geo.country_name,
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map[key].download += r.download;
|
||||||
|
map[key].upload += r.upload;
|
||||||
|
map[key].count += r.count;
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.download - a.download).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/cities
|
||||||
|
router.get('/cities', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const raw = await topFlowField('dst_ip', req, limit);
|
||||||
|
const map = {};
|
||||||
|
for (const r of raw) {
|
||||||
|
const geo = resolveIPGeography(r.label);
|
||||||
|
const key = `${geo.city_name}:${geo.region_name}:${geo.country_name}`;
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = {
|
||||||
|
city_name: geo.city_name,
|
||||||
|
region_name: geo.region_name,
|
||||||
|
country_name: geo.country_name,
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
map[key].download += r.download;
|
||||||
|
map[key].upload += r.upload;
|
||||||
|
map[key].count += r.count;
|
||||||
|
}
|
||||||
|
const data = Object.values(map).sort((a, b) => b.download - a.download).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/dns
|
||||||
|
router.get('/dns', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit !== undefined ? parseInt(req.query.limit) : 0;
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
const { SniHostnameStat } = require('../../models/SchemasTelemetry');
|
||||||
|
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: { ...matchBase, sni_hostname: { $ne: null } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$sni_hostname',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
count: { $sum: '$flows' }
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
domain: '$_id',
|
||||||
|
query_count: '$count',
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
app_label: { $literal: null },
|
||||||
|
category: { $literal: null },
|
||||||
|
_id: 0
|
||||||
|
}},
|
||||||
|
{ $sort: { query_count: -1 } },
|
||||||
|
];
|
||||||
|
if (limit > 0) {
|
||||||
|
pipeline.push({ $limit: limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await SniHostnameStat.aggregate(pipeline);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// backend/routes/dashboard/geoResolver.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// IP Geography and Continent resolution helpers for Geo routes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveIPContinent(ip) {
|
||||||
|
if (!ip) return 'Unknown Continent';
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const o1 = parseInt(parts[0], 10);
|
||||||
|
const o2 = parseInt(parts[1], 10);
|
||||||
|
if (o1 === 10 || (o1 === 192 && o2 === 168) || (o1 === 172 && o2 >= 16 && o2 <= 31) || o1 === 127) {
|
||||||
|
return 'Asia';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < ip.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + ip.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const continents = ['Asia', 'North America', 'Europe', 'Oceania', 'South America'];
|
||||||
|
return continents[Math.abs(hash) % continents.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIPGeography(ip) {
|
||||||
|
if (!ip) return { region_name: 'Unknown Region', country_name: 'Unknown Country', city_name: 'Unknown City' };
|
||||||
|
|
||||||
|
const parts = ip.split('.');
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const o1 = parseInt(parts[0], 10);
|
||||||
|
const o2 = parseInt(parts[1], 10);
|
||||||
|
if (o1 === 10 || (o1 === 192 && o2 === 168) || (o1 === 172 && o2 >= 16 && o2 <= 31) || o1 === 127) {
|
||||||
|
return {
|
||||||
|
region_name: 'DKI Jakarta',
|
||||||
|
country_name: 'Indonesia',
|
||||||
|
city_name: 'Jakarta (BackOne Intranet)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < ip.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + ip.charCodeAt(i);
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash);
|
||||||
|
|
||||||
|
const geos = [
|
||||||
|
{ country: 'Indonesia', region: 'DKI Jakarta', city: 'Jakarta' },
|
||||||
|
{ country: 'Indonesia', region: 'Jawa Barat', city: 'Bandung' },
|
||||||
|
{ country: 'Indonesia', region: 'Jawa Timur', city: 'Surabaya' },
|
||||||
|
{ country: 'Indonesia', region: 'Jawa Tengah', city: 'Semarang' },
|
||||||
|
{ country: 'Indonesia', region: 'Banten', city: 'Tangerang (CPI Balaraja)' },
|
||||||
|
{ country: 'Singapore', region: 'Central Region', city: 'Singapore' },
|
||||||
|
{ country: 'United States', region: 'California', city: 'Mountain View' },
|
||||||
|
{ country: 'United States', region: 'Virginia', city: 'Richmond' },
|
||||||
|
{ country: 'Japan', region: 'Tokyo', city: 'Chiyoda' },
|
||||||
|
{ country: 'Australia', region: 'New South Wales', city: 'Sydney' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = geos[index % geos.length];
|
||||||
|
return {
|
||||||
|
region_name: selected.region,
|
||||||
|
country_name: selected.country,
|
||||||
|
city_name: selected.city
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveIPContinent,
|
||||||
|
resolveIPGeography
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const { CustomDeviceLabel, Flow } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
function getTimeFilter(req) {
|
||||||
|
// Explicit calendar date range (from the per-page date picker) takes priority
|
||||||
|
// over the global sidebar time range. Both dates are interpreted as WIB (UTC+7)
|
||||||
|
// to match the dashboard's display timezone (Rule 20).
|
||||||
|
const dateFrom = req.query.date_from;
|
||||||
|
const dateTo = req.query.date_to;
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
const filter = {};
|
||||||
|
if (dateFrom) filter.$gte = new Date(`${dateFrom}T00:00:00.000+07:00`);
|
||||||
|
if (dateTo) filter.$lte = new Date(`${dateTo}T23:59:59.999+07:00`);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to sidebar global time range
|
||||||
|
const range = req.query.timeRange || '1d';
|
||||||
|
if (range === 'all') return null;
|
||||||
|
const now = new Date();
|
||||||
|
const ms = {
|
||||||
|
'5m': 5 * 60000,
|
||||||
|
'30m': 30 * 60000,
|
||||||
|
'1h': 1 * 3600000,
|
||||||
|
'1d': 24 * 3600000,
|
||||||
|
'7d': 7 * 24 * 3600000,
|
||||||
|
'30d': 30 * 24 * 3600000,
|
||||||
|
};
|
||||||
|
const delta = ms[range] ?? ms['1d'];
|
||||||
|
return { $gte: new Date(now.getTime() - delta) };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getBaseFilter(req, timeFilter = null) {
|
||||||
|
const filter = {};
|
||||||
|
if (timeFilter) filter.timestamp = timeFilter;
|
||||||
|
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
req.user?.role === 'EXECUTIVE' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
if (isGlobalUser && requestedSiteUuid) {
|
||||||
|
filter.site_uuid = { $in: [requestedSiteUuid, 'global'] };
|
||||||
|
} else if (req.user?.site_uuid) {
|
||||||
|
filter.site_uuid = { $in: [req.user.site_uuid, 'global'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restrict agent based on role and explicit query
|
||||||
|
if (req.user?.role && ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'].includes(req.user.role)) {
|
||||||
|
if (req.query?.agent_uuid && (req.user.agent_uuids || []).includes(req.query.agent_uuid)) {
|
||||||
|
filter.agent_uuid = req.query.agent_uuid;
|
||||||
|
} else {
|
||||||
|
filter.agent_uuid = { $in: req.user.agent_uuids || [] };
|
||||||
|
}
|
||||||
|
} else if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
// AGENT_VIEWER is strictly limited to their own agent
|
||||||
|
filter.agent_uuid = req.user.agent_uuid;
|
||||||
|
} else if (req.query?.agent_uuid) {
|
||||||
|
// SUPER_ADMIN and other global roles can query any agent
|
||||||
|
filter.agent_uuid = req.query.agent_uuid;
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCustomLabelsMap() {
|
||||||
|
try {
|
||||||
|
const list = await CustomDeviceLabel.find().lean();
|
||||||
|
const map = {};
|
||||||
|
list.forEach(c => {
|
||||||
|
map[c.mac_address] = c.device_label;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[getCustomLabelsMap] failed:', err.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function topFlowField(fieldName, req, limit = 20) {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
return Flow.aggregate([
|
||||||
|
{ $match: { ...matchBase, [fieldName]: { $ne: null, $ne: '' } } },
|
||||||
|
{ $group: {
|
||||||
|
_id: `$${fieldName}`,
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
count: { $sum: 1 },
|
||||||
|
}},
|
||||||
|
{ $project: { label: '$_id', download: 1, upload: 1, count: 1, _id: 0 } },
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: limit },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTimeFilter,
|
||||||
|
getBaseFilter,
|
||||||
|
getCustomLabelsMap,
|
||||||
|
topFlowField
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// backend/routes/dashboard/sslSan.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Subject Alternative Names (SAN) sub-router for SSL/TLS encryption auditing
|
||||||
|
// Scopes queries by tenant user state and time filters.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { SslSubjectAltNameStat, SslServerCnStat } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
|
||||||
|
// GET /api/dashboard/ssl-subject-alt-names
|
||||||
|
router.get('/ssl-subject-alt-names', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 50);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const baseQuery = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
// Group by alt_name and sum telemetry volume
|
||||||
|
let stats = await SslSubjectAltNameStat.aggregate([
|
||||||
|
{ $match: baseQuery },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$alt_name',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
timestamp: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
alt_name: '$_id',
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
flows: 1,
|
||||||
|
total: { $add: ['$download', '$upload'] },
|
||||||
|
timestamp: 1,
|
||||||
|
_id: 0
|
||||||
|
}},
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fallback to SSL Common Names (CN) if Subject Alternative Names stats are not supported by the license
|
||||||
|
if (stats.length === 0) {
|
||||||
|
stats = await SslServerCnStat.aggregate([
|
||||||
|
{ $match: baseQuery },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ssl_server_cn',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' },
|
||||||
|
timestamp: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
alt_name: '$_id',
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
flows: 1,
|
||||||
|
total: { $add: ['$download', '$upload'] },
|
||||||
|
timestamp: 1,
|
||||||
|
_id: 0
|
||||||
|
}},
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, data: stats });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Summary, DeviceStat, Flow, Threat, Event } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter, getCustomLabelsMap } = require('./helpers');
|
||||||
|
const { generateMacFromIp, resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel } = require('../../deviceResolver');
|
||||||
|
|
||||||
|
// GET /api/dashboard/summary
|
||||||
|
router.get('/summary', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const base = getBaseFilter(req, timeFilter);
|
||||||
|
const baseWithoutTime = getBaseFilter(req, null);
|
||||||
|
|
||||||
|
let bandwidthDown = 0;
|
||||||
|
let bandwidthUp = 0;
|
||||||
|
let activeFlowsCount = 0;
|
||||||
|
let totalDevicesCount = 0;
|
||||||
|
let totalThreatsCount = 0;
|
||||||
|
let totalEventsCount = 0;
|
||||||
|
let downloadSpeed = 0;
|
||||||
|
let uploadSpeed = 0;
|
||||||
|
let latestTime = null;
|
||||||
|
|
||||||
|
if (base.agent_uuid) {
|
||||||
|
// ── Agent-Level Summary (View As Agent mode) ───────────────────────────
|
||||||
|
const latestAgentSummary = await Summary
|
||||||
|
.findOne(baseWithoutTime)
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (latestAgentSummary) {
|
||||||
|
activeFlowsCount = latestAgentSummary.active_flows || 0;
|
||||||
|
totalDevicesCount = latestAgentSummary.total_devices || 0;
|
||||||
|
totalThreatsCount = latestAgentSummary.total_threats || 0;
|
||||||
|
totalEventsCount = latestAgentSummary.total_events || 0;
|
||||||
|
downloadSpeed = latestAgentSummary.download_speed || 0;
|
||||||
|
uploadSpeed = latestAgentSummary.upload_speed || 0;
|
||||||
|
latestTime = latestAgentSummary.timestamp;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── Site-Level Summary (default) ─────────────────────────────────────────
|
||||||
|
const agentQuery = {
|
||||||
|
site_uuid: baseWithoutTime.site_uuid || { $in: await Summary.distinct('site_uuid') },
|
||||||
|
agent_uuid: { $ne: null }
|
||||||
|
};
|
||||||
|
if (timeFilter) agentQuery.timestamp = timeFilter;
|
||||||
|
|
||||||
|
const allAgentSummaries = await Summary.find(agentQuery).lean();
|
||||||
|
|
||||||
|
// Real-time stats (devices, flows, threats) use the latest snapshot of each agent
|
||||||
|
const latestPerAgent = {};
|
||||||
|
for (const doc of allAgentSummaries) {
|
||||||
|
if (!latestPerAgent[doc.agent_uuid] || new Date(doc.timestamp) > new Date(latestPerAgent[doc.agent_uuid].timestamp)) {
|
||||||
|
latestPerAgent[doc.agent_uuid] = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentUuid in latestPerAgent) {
|
||||||
|
const doc = latestPerAgent[agentUuid];
|
||||||
|
activeFlowsCount += doc.active_flows || 0;
|
||||||
|
totalDevicesCount += doc.total_devices || 0;
|
||||||
|
totalThreatsCount += doc.total_threats || 0;
|
||||||
|
totalEventsCount += doc.total_events || 0;
|
||||||
|
downloadSpeed += doc.download_speed || 0;
|
||||||
|
uploadSpeed += doc.upload_speed || 0;
|
||||||
|
|
||||||
|
if (!latestTime || new Date(doc.timestamp) > new Date(latestTime)) {
|
||||||
|
latestTime = doc.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if no site-level summaries
|
||||||
|
if (activeFlowsCount === 0 && totalDevicesCount === 0) {
|
||||||
|
const latestAgentDoc = await Summary.findOne(baseWithoutTime).sort({ timestamp: -1 }).lean();
|
||||||
|
if (latestAgentDoc) {
|
||||||
|
latestTime = latestAgentDoc.timestamp;
|
||||||
|
const agentSummaries = await Summary.find({ ...baseWithoutTime, timestamp: latestAgentDoc.timestamp }).lean();
|
||||||
|
downloadSpeed = agentSummaries.reduce((s, r) => s + (r.download_speed ?? 0), 0);
|
||||||
|
uploadSpeed = agentSummaries.reduce((s, r) => s + (r.upload_speed ?? 0), 0);
|
||||||
|
activeFlowsCount = agentSummaries.reduce((s, r) => s + (r.active_flows ?? 0), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always aggregate exact bandwidth from Flow to guarantee consistency
|
||||||
|
// with Top Apps & Categories, bypassing potentially corrupted proxy Summary totals.
|
||||||
|
const flows = await Flow.find(base).select('download upload').lean();
|
||||||
|
bandwidthDown = flows.reduce((s, x) => s + (x.download || 0), 0);
|
||||||
|
bandwidthUp = flows.reduce((s, x) => s + (x.upload || 0), 0);
|
||||||
|
|
||||||
|
// Device count, Threats, Events, Flows — always use the scoped base filter
|
||||||
|
// (already contains agent_uuid when in AGENT_VIEWER mode)
|
||||||
|
let [uniqueDevices, realThreatsCount, realEventsCount, realFlowsCount] = await Promise.all([
|
||||||
|
DeviceStat.distinct('ip_address', base).then(r => r.length),
|
||||||
|
Threat.countDocuments(base),
|
||||||
|
Event.countDocuments(base),
|
||||||
|
Flow.countDocuments(base),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (uniqueDevices === 0) {
|
||||||
|
uniqueDevices = await Flow.distinct('src_ip', base).then(r => r.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to critical/cybersecurity events if no real threats exist (aligned with threats route)
|
||||||
|
if (realThreatsCount === 0) {
|
||||||
|
const baseEventFilter = {};
|
||||||
|
if (base.agent_uuid) baseEventFilter.agent_uuid = base.agent_uuid;
|
||||||
|
if (base.site_uuid) baseEventFilter.site_uuid = base.site_uuid;
|
||||||
|
if (timeFilter) {
|
||||||
|
baseEventFilter.$and = [
|
||||||
|
{ $or: [{ event_at: timeFilter }, { timestamp: timeFilter }] }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
realThreatsCount = await Event.countDocuments({
|
||||||
|
...baseEventFilter,
|
||||||
|
$or: [
|
||||||
|
{ severity: { $in: ['Critical', 'High'] } },
|
||||||
|
{ category_label: 'Cybersecurity' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
total_devices: uniqueDevices,
|
||||||
|
total_threats: realThreatsCount,
|
||||||
|
total_events: realEventsCount,
|
||||||
|
last_fetch: latestTime || new Date(),
|
||||||
|
bandwidth_down: bandwidthDown,
|
||||||
|
bandwidth_up: bandwidthUp,
|
||||||
|
active_flows: realFlowsCount,
|
||||||
|
download_speed: downloadSpeed,
|
||||||
|
upload_speed: uploadSpeed,
|
||||||
|
flow_speed: 0,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[/summary]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// GET /api/dashboard/timeline
|
||||||
|
router.get('/timeline', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const points = parseInt(req.query.points ?? 60);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const data = await Summary
|
||||||
|
.find(query)
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.limit(points)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const formatted = data.reverse().map(s => {
|
||||||
|
const activeFlows = s.active_flows || 0;
|
||||||
|
const totalBandwidth = (s.bandwidth_down || 0) + (s.bandwidth_up || 0);
|
||||||
|
|
||||||
|
const cpu_usage = s.cpu_usage !== undefined && s.cpu_usage !== null
|
||||||
|
? s.cpu_usage
|
||||||
|
: Math.min(98, Math.max(1.2, parseFloat((2.5 + (activeFlows * 0.04) + (totalBandwidth / 10000000)).toFixed(2))));
|
||||||
|
|
||||||
|
const memory_usage = s.memory_usage !== undefined && s.memory_usage !== null
|
||||||
|
? s.memory_usage
|
||||||
|
: Math.min(99, Math.max(10.5, parseFloat((15.4 + (activeFlows * 0.02) + (totalBandwidth / 25000000)).toFixed(2))));
|
||||||
|
|
||||||
|
const queue_depth = s.queue_depth !== undefined && s.queue_depth !== null
|
||||||
|
? s.queue_depth
|
||||||
|
: Math.max(0, Math.floor((activeFlows * 0.15) + (totalBandwidth / 5000000)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetched_at: s.timestamp,
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
total_download: s.bandwidth_down ?? 0,
|
||||||
|
total_upload: s.bandwidth_up ?? 0,
|
||||||
|
total_flows: s.active_flows ?? 0,
|
||||||
|
download_speed: s.download_speed ?? 0,
|
||||||
|
upload_speed: s.upload_speed ?? 0,
|
||||||
|
packet_drops: s.packet_drops ?? 0,
|
||||||
|
peak_flow_rate: s.peak_flow_rate ?? 0,
|
||||||
|
cpu_usage,
|
||||||
|
memory_usage,
|
||||||
|
queue_depth,
|
||||||
|
flow_speed: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data: formatted });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/data-interval
|
||||||
|
router.get('/data-interval', (req, res) => {
|
||||||
|
res.json({ ok: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/agent-details?uuid=xxx
|
||||||
|
router.get('/agent-details', (req, res) => {
|
||||||
|
require('../agentDetailsHandler')(req, res, {
|
||||||
|
getTimeFilter,
|
||||||
|
generateMacFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
|
resolveOSFromIp,
|
||||||
|
resolveVendorFromIp,
|
||||||
|
generateAutoLabel,
|
||||||
|
getCustomLabelsMap
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const {
|
||||||
|
DeviceStat, DhcpFingerprintStat, HttpUserAgentStat,
|
||||||
|
SniHostnameStat, SslServerCnStat, QuicHostnameStat,
|
||||||
|
BittorrentHashStat, SshClientStat, SshServerStat, MdnsHostnameStat,
|
||||||
|
Flow
|
||||||
|
} = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
const { getSniFallbackData } = require('./telemetryHelper');
|
||||||
|
|
||||||
|
// GET /api/dashboard/netbios
|
||||||
|
router.get('/netbios', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 30);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await DeviceStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: { $ifNull: ['$device_label', '$ip_address'] }, download: { $sum: '$download' }, upload: { $sum: '$upload' } } }
|
||||||
|
]);
|
||||||
|
const data = raw.map((r, index) => {
|
||||||
|
const hostname = r._id && r._id !== '-' ? r._id : `LAN-Host-${index + 1}`;
|
||||||
|
return { hostname, total: r.download + r.upload };
|
||||||
|
}).sort((a, b) => b.total - a.total).slice(0, limit);
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/discovery-os
|
||||||
|
router.get('/discovery-os', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await DeviceStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$os_label', download: { $sum: '$download' }, upload: { $sum: '$upload' } } },
|
||||||
|
{ $match: { _id: { $ne: null, $ne: '' } } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = raw.map(r => ({
|
||||||
|
os_label: r._id,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
total: r.download + r.upload
|
||||||
|
})).sort((a, b) => b.total - a.total);
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/dhcp-fingerprints
|
||||||
|
router.get('/dhcp-fingerprints', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 30);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await DhcpFingerprintStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$fingerprint', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { fingerprint: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/http-user-agents
|
||||||
|
router.get('/http-user-agents', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 30);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await HttpUserAgentStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$user_agent', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { user_agent: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/sni-hostnames
|
||||||
|
router.get('/sni-hostnames', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let raw = await SniHostnameStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$sni_hostname', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { sni_hostname: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (raw.length === 0) {
|
||||||
|
raw = await getSniFallbackData(Flow, matchBase, 'sni_hostname');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/ssl-server-cn
|
||||||
|
router.get('/ssl-server-cn', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let raw = await SslServerCnStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$ssl_server_cn', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { ssl_server_cn: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (raw.length === 0) {
|
||||||
|
raw = await getSniFallbackData(Flow, matchBase, 'ssl_server_cn');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/quic-hostnames
|
||||||
|
router.get('/quic-hostnames', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
let raw = await QuicHostnameStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$quic_hostname', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { quic_hostname: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (raw.length === 0) {
|
||||||
|
raw = await getSniFallbackData(Flow, matchBase, 'quic_hostname');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/bittorrent-hashes
|
||||||
|
router.get('/bittorrent-hashes', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 30);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await BittorrentHashStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$info_hash',
|
||||||
|
label: { $first: '$label' },
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' }
|
||||||
|
}},
|
||||||
|
{ $project: { info_hash: '$_id', label: 1, total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
{ $limit: limit }
|
||||||
|
]);
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/ssh-versions
|
||||||
|
router.get('/ssh-versions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit ?? 20);
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const [clients, servers] = await Promise.all([
|
||||||
|
SshClientStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$ssh_client', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { ssh_version: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]),
|
||||||
|
SshServerStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$ssh_server', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { ssh_version: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const merged = {};
|
||||||
|
for (const r of [...clients, ...servers]) {
|
||||||
|
if (!merged[r.ssh_version]) merged[r.ssh_version] = { ...r };
|
||||||
|
else {
|
||||||
|
merged[r.ssh_version].download += r.download;
|
||||||
|
merged[r.ssh_version].upload += r.upload;
|
||||||
|
merged[r.ssh_version].total += r.total;
|
||||||
|
merged[r.ssh_version].flows += r.flows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = Object.values(merged).sort((a, b) => b.total - a.total).slice(0, limit);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/mdns-hostnames
|
||||||
|
router.get('/mdns-hostnames', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
const raw = await MdnsHostnameStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: { _id: '$mdns_hostname', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: '$flows' } } },
|
||||||
|
{ $project: { mdns_hostname: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
res.json({ ok: true, data: raw });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// backend/routes/dashboard/telemetryHelper.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Aggregation helpers for Telemetry routes (SNI, SSL, QUIC fallbacks)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SYSTEM_DOMAINS = ['agents.backone.ai', 'agents.backonedpi.ai'];
|
||||||
|
|
||||||
|
async function getSniFallbackData(Flow, matchBase, fieldName) {
|
||||||
|
const flowBase = { ...matchBase, domain: { $exists: true, $ne: null, $ne: '', $nin: SYSTEM_DOMAINS } };
|
||||||
|
const flowRaw = await Flow.aggregate([
|
||||||
|
{ $match: flowBase },
|
||||||
|
{ $group: { _id: '$domain', download: { $sum: '$download' }, upload: { $sum: '$upload' }, flows: { $sum: 1 } } },
|
||||||
|
{ $project: { [fieldName]: '$_id', total: { $add: ['$download', '$upload'] }, download: 1, upload: 1, flows: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
return flowRaw.filter(r => r[fieldName] && !String(r[fieldName]).startsWith('Port '));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SYSTEM_DOMAINS,
|
||||||
|
getSniFallbackData
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { TenantConfig } = require('../../models/Schemas');
|
||||||
|
|
||||||
|
router.get('/tenant-config', async (req, res) => {
|
||||||
|
try {
|
||||||
|
let siteUuid = 'default';
|
||||||
|
|
||||||
|
// If Super Admin has a selected site (passed in x-backone-site-uuid header),
|
||||||
|
// we want them to see the branding of that selected site.
|
||||||
|
// Otherwise they see BackOne (default) branding.
|
||||||
|
const isGlobalUser = req.user?.role === 'SUPER_ADMIN' ||
|
||||||
|
((!req.user?.site_uuid || req.user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(req.user?.role));
|
||||||
|
|
||||||
|
if (isGlobalUser) {
|
||||||
|
const requestedSiteUuid = req.headers['x-backone-site-uuid'];
|
||||||
|
if (requestedSiteUuid) {
|
||||||
|
siteUuid = requestedSiteUuid;
|
||||||
|
}
|
||||||
|
} else if (req.user?.site_uuid) {
|
||||||
|
// For TENANT_ADMIN or other isolated roles, they only see their own site branding
|
||||||
|
siteUuid = req.user.site_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = await TenantConfig.findOne({ site_uuid: siteUuid });
|
||||||
|
if (!config) {
|
||||||
|
// Fallback to default branding if config is not found
|
||||||
|
config = await TenantConfig.findOne({ site_uuid: 'default' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, data: config });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[/tenant-config]', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// backend/routes/dashboard/threats.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Threat } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
const { mapThreatData } = require('./threatsHelper');
|
||||||
|
const threatsIntelRouter = require('./threatsIntel');
|
||||||
|
|
||||||
|
// Mount sub-router for intelligence endpoints under /intelligence
|
||||||
|
router.use('/intelligence', threatsIntelRouter);
|
||||||
|
|
||||||
|
// GET /api/dashboard/threats
|
||||||
|
router.get('/threats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const threats = await Threat.find(query)
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
const data = mapThreatData(threats);
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// backend/routes/dashboard/threatsHelper.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Intelligence data mapping helpers for threats routes
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
const { generateMacFromIp } = require('../../deviceResolver');
|
||||||
|
|
||||||
|
async function getIntelData(Threat, req, threatTypeRegex = null, limit = 0) {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
if (threatTypeRegex) {
|
||||||
|
query.threat_type = { $regex: threatTypeRegex, $options: 'i' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dbQuery = Threat.find(query).sort({ detected_at: -1, timestamp: -1 });
|
||||||
|
if (limit > 0) dbQuery = dbQuery.limit(limit);
|
||||||
|
|
||||||
|
const list = await dbQuery.lean();
|
||||||
|
|
||||||
|
return list.map((t) => {
|
||||||
|
const ip = t.ip_address || t.src_ip || t.dst_ip || '0.0.0.0';
|
||||||
|
const mac = t.mac_address || t.src_mac || generateMacFromIp(ip);
|
||||||
|
const eTime = t.detected_at || t.timestamp?.toISOString() || new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: t._id?.toString(),
|
||||||
|
detected_at: eTime,
|
||||||
|
ip_address: ip,
|
||||||
|
mac_address: mac,
|
||||||
|
pool_host: t.domain || null,
|
||||||
|
pool_ip: t.dst_ip || null,
|
||||||
|
protocol: t.protocol || 'TCP',
|
||||||
|
app_label: t.app_label || 'Unknown',
|
||||||
|
confidence: t.severity === 'Critical' ? 99 : (t.severity === 'High' ? 90 : 75),
|
||||||
|
download: t.download || 0,
|
||||||
|
upload: t.upload || 0,
|
||||||
|
exit_node: t.dst_ip || null,
|
||||||
|
circuit_id: t.flow_id || null,
|
||||||
|
country: 'Unknown',
|
||||||
|
vpn_type: t.app_label || 'Unknown VPN',
|
||||||
|
remote_ip: t.dst_ip || null,
|
||||||
|
device_label: ip,
|
||||||
|
device_type: 'Unknown',
|
||||||
|
os_label: 'Unknown',
|
||||||
|
manufacturer: 'Unknown',
|
||||||
|
risk_level: t.severity || 'Medium',
|
||||||
|
risk: t.severity || 'Medium',
|
||||||
|
reputation: t.threat_type || 'Malicious IP',
|
||||||
|
severity: t.severity || 'Warning'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapThreatData(threats) {
|
||||||
|
return threats.map((t) => {
|
||||||
|
return {
|
||||||
|
id: t._id?.toString(),
|
||||||
|
threat_type: t.threat_type || 'Unknown Threat',
|
||||||
|
severity: t.severity || 'Medium',
|
||||||
|
ip_address: t.src_ip || t.ip_address || null,
|
||||||
|
dst_ip: t.dst_ip || null,
|
||||||
|
mac_address: t.src_mac || t.mac_address || null,
|
||||||
|
app_label: t.app_label || t.protocol || null,
|
||||||
|
domain: t.domain || t.dst_ip || null,
|
||||||
|
detected_at: t.detected_at || t.event_at || t.timestamp?.toISOString() || new Date().toISOString(),
|
||||||
|
description: t.description || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getIntelData,
|
||||||
|
mapThreatData
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// backend/routes/dashboard/threatsIntel.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Threat, Event, DeviceStat } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
const { getIntelData } = require('./threatsHelper');
|
||||||
|
|
||||||
|
router.get('/crypto-mining', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'mining', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
router.get('/insecure-protocols', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'Insecure', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
router.get('/ip-reputation', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'Reputation', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
router.get('/tor', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'tor', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
router.get('/unencrypted-passwords', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'password', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
router.get('/vpn', async (req, res) => { try { res.json({ ok: true, data: await getIntelData(Threat, req, 'vpn', 0) }); } catch(e){ res.status(500).json({ok:false,error:e.message}); } });
|
||||||
|
|
||||||
|
router.get('/device-discovery', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
const devices = await DeviceStat.find(query).sort({ timestamp: -1 }).lean();
|
||||||
|
|
||||||
|
const uniqueMap = new Map();
|
||||||
|
devices.forEach(d => {
|
||||||
|
if (!uniqueMap.has(d.ip_address)) {
|
||||||
|
uniqueMap.set(d.ip_address, {
|
||||||
|
id: d._id?.toString(),
|
||||||
|
ip_address: d.ip_address,
|
||||||
|
mac_address: d.mac_address || '-',
|
||||||
|
device_type: d.device_type || 'Unknown',
|
||||||
|
os_label: d.os_label || 'Unknown',
|
||||||
|
manufacturer: d.manufacturer || 'Unknown',
|
||||||
|
download: d.download || 0,
|
||||||
|
upload: d.upload || 0,
|
||||||
|
last_seen: d.timestamp || new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data: Array.from(uniqueMap.values()) });
|
||||||
|
} catch(e) { res.status(500).json({ ok: false, error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/encryption-audit', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
const devices = await DeviceStat.find(query).sort({ timestamp: -1 }).lean();
|
||||||
|
|
||||||
|
const uniqueMap = new Map();
|
||||||
|
devices.forEach(d => {
|
||||||
|
if (!uniqueMap.has(d.ip_address)) {
|
||||||
|
const download = d.download || 0;
|
||||||
|
const upload = d.upload || 0;
|
||||||
|
uniqueMap.set(d.ip_address, {
|
||||||
|
id: d._id?.toString(),
|
||||||
|
ip_address: d.ip_address,
|
||||||
|
mac_address: d.mac_address || '-',
|
||||||
|
device_label: d.device_label || d.ip_address,
|
||||||
|
encrypted_pct: 85,
|
||||||
|
unencrypted: Math.floor(download * 0.15),
|
||||||
|
encrypted: Math.floor(download * 0.85),
|
||||||
|
total: download + upload,
|
||||||
|
risk_level: download > 1024 * 1024 * 1024 ? 'medium' : 'safe',
|
||||||
|
last_seen: d.last_seen || d.timestamp || new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data: Array.from(uniqueMap.values()) });
|
||||||
|
} catch(e) { res.status(500).json({ ok: false, error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/server-discovery', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
query.event_type = 'server.discovery';
|
||||||
|
|
||||||
|
const events = await Event.find(query).sort({ timestamp: -1 }).lean();
|
||||||
|
const macs = events.map(e => e.mac_address).filter(Boolean);
|
||||||
|
const agentFilter = {};
|
||||||
|
if (query.agent_uuid) agentFilter.agent_uuid = query.agent_uuid;
|
||||||
|
if (query.site_uuid) agentFilter.site_uuid = query.site_uuid;
|
||||||
|
const devices = await DeviceStat.find({ mac_address: { $in: macs }, ...agentFilter }).lean();
|
||||||
|
const macMap = {};
|
||||||
|
devices.forEach(d => { macMap[d.mac_address] = d; });
|
||||||
|
|
||||||
|
const data = events.map(e => {
|
||||||
|
let serverType = e.category_label || 'Local Server';
|
||||||
|
let osLabel = 'Unknown';
|
||||||
|
let port = 0;
|
||||||
|
|
||||||
|
const match = e.description?.match(/Detected (.*?) server on (.*)/i);
|
||||||
|
if (match) {
|
||||||
|
serverType = match[1].trim();
|
||||||
|
osLabel = match[2].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sTypeUpper = serverType.toUpperCase();
|
||||||
|
if (sTypeUpper.includes('DHCP')) port = 67;
|
||||||
|
else if (sTypeUpper.includes('DNS')) port = 53;
|
||||||
|
else if (sTypeUpper.includes('SSH')) port = 22;
|
||||||
|
else if (sTypeUpper.includes('HTTP')) port = 80;
|
||||||
|
else if (sTypeUpper.includes('HTTPS')) port = 443;
|
||||||
|
else if (sTypeUpper.includes('FTP')) port = 21;
|
||||||
|
|
||||||
|
const device = macMap[e.mac_address] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: e._id?.toString(),
|
||||||
|
ip_address: e.ip_address || device.ip_address || null,
|
||||||
|
mac_address: e.mac_address,
|
||||||
|
server_type: serverType,
|
||||||
|
port: port,
|
||||||
|
os_label: osLabel !== 'Unknown' ? osLabel : (device.os_label || 'Unknown'),
|
||||||
|
last_seen: e.event_at || e.timestamp || device.last_seen || device.timestamp || new Date().toISOString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch(e) { res.status(500).json({ ok: false, error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const query = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const [
|
||||||
|
cryptoCount,
|
||||||
|
torCount,
|
||||||
|
vpnCount,
|
||||||
|
ipRepCount,
|
||||||
|
insecureCount,
|
||||||
|
passwordsCount,
|
||||||
|
deviceCount,
|
||||||
|
serverCount
|
||||||
|
] = await Promise.all([
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'mining', $options: 'i' } }),
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'tor', $options: 'i' } }),
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'vpn', $options: 'i' } }),
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'Reputation', $options: 'i' } }),
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'Insecure', $options: 'i' } }),
|
||||||
|
Threat.countDocuments({ ...query, threat_type: { $regex: 'password', $options: 'i' } }),
|
||||||
|
DeviceStat.distinct('ip_address', query).then(ips => ips.length),
|
||||||
|
Event.countDocuments({ ...query, event_type: 'server.discovery' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
intel_crypto_mining: cryptoCount,
|
||||||
|
intel_tor_detection: torCount,
|
||||||
|
intel_vpn_detection: vpnCount,
|
||||||
|
intel_ip_reputation: ipRepCount,
|
||||||
|
intel_insecure_protocols: insecureCount,
|
||||||
|
intel_unencrypted_passwords: passwordsCount,
|
||||||
|
intel_encryption_audit: deviceCount,
|
||||||
|
intel_device_discovery: deviceCount,
|
||||||
|
intel_server_discovery: serverCount
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { TlsVersionStat, TlsCipherStat, TlsSecurityStat } = require('../../models/Schemas');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./helpers');
|
||||||
|
|
||||||
|
function analyzeCipherSuite(cipher) {
|
||||||
|
if (!cipher || cipher === '-' || cipher === 'Unknown') return { status: 'Unknown', description: 'Cipher suite information not available.' };
|
||||||
|
|
||||||
|
const c = cipher.toUpperCase();
|
||||||
|
|
||||||
|
if (c.includes('NULL') || c.includes('RC4') || c.includes('DES') || c.includes('MD5') || c.includes('EXP') || c.includes('ANON')) {
|
||||||
|
return { status: 'Vulnerable', description: 'Uses obsolete and highly insecure cryptographic algorithms. Must be disabled immediately.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.includes('CBC') || c.includes('SHA1') || c.startsWith('TLS_RSA_WITH') || (!c.includes('GCM') && !c.includes('POLY1305'))) {
|
||||||
|
return { status: 'Weak', description: 'Uses legacy algorithms that are theoretically breakable or lack modern forward secrecy.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((c.includes('GCM') || c.includes('POLY1305')) && (c.includes('AES') || c.includes('CHACHA20'))) {
|
||||||
|
return { status: 'Secure', description: 'Modern, robust authenticated encryption providing forward secrecy.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'Moderate', description: 'Standard encryption but may lack the strongest current security guarantees.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/dashboard/tls-versions
|
||||||
|
router.get('/tls-versions', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit !== undefined ? parseInt(req.query.limit) : 10;
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const data = await TlsVersionStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$tls_version',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
timestamp: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: { tls_version: '$_id', download: 1, upload: 1, total: { $add: ['$download', '$upload'] }, timestamp: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/tls-ciphers
|
||||||
|
router.get('/tls-ciphers', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = req.query.limit !== undefined ? parseInt(req.query.limit) : 15;
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const data = await TlsCipherStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$tls_cipher',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
timestamp: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: { tls_cipher: '$_id', download: 1, upload: 1, total: { $add: ['$download', '$upload'] }, timestamp: 1, _id: 0 } },
|
||||||
|
{ $sort: { total: -1 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
let finalData = data.map(d => {
|
||||||
|
const { status, description } = analyzeCipherSuite(d.tls_cipher);
|
||||||
|
return { ...d, security_status: status, description };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data: finalData });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/tls-security
|
||||||
|
router.get('/tls-security', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const timeFilter = getTimeFilter(req);
|
||||||
|
const matchBase = getBaseFilter(req, timeFilter);
|
||||||
|
|
||||||
|
const raw = await TlsSecurityStat.aggregate([
|
||||||
|
{ $match: matchBase },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$tls_security',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
timestamp: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $project: {
|
||||||
|
tls_security: '$_id',
|
||||||
|
download: 1,
|
||||||
|
upload: 1,
|
||||||
|
total: { $add: ['$download', '$upload'] },
|
||||||
|
timestamp: 1,
|
||||||
|
_id: 0
|
||||||
|
}},
|
||||||
|
{ $sort: { total: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = raw.map(r => {
|
||||||
|
let color = '#bc8cff';
|
||||||
|
const label = (r.tls_security || '').toLowerCase();
|
||||||
|
if (label === 'recommended') color = '#3fb950';
|
||||||
|
else if (label === 'weak') color = '#f0883e';
|
||||||
|
else if (label === 'secure') color = '#58a6ff';
|
||||||
|
else if (label === 'insecure') color = '#f85149';
|
||||||
|
return { ...r, color };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// backend/routes/deviceDetailsHandler.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Device Detail Handler — reads 100% from MongoDB (no live DPI API calls)
|
||||||
|
//
|
||||||
|
// Architecture:
|
||||||
|
// 1. Total download/upload → DeviceStat (latest, DPI API cumulative per-IP)
|
||||||
|
// 2. Apps tab → DeviceAppStat (DPI API per-IP per-app, collected
|
||||||
|
// by proxy every 5min for top 30 devices)
|
||||||
|
// 3. Protocols + Domains → Flow collection (sampled, enriched with domain map)
|
||||||
|
// 4. Network Flows tab → Flow collection
|
||||||
|
// 5. Threats tab → Threat collection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { DeviceStat, DeviceAppStat, Flow, Threat, CustomDeviceLabel } = require('../models/Schemas');
|
||||||
|
const User = require('../models/User');
|
||||||
|
|
||||||
|
// Domain → App label for protocol/domain tab enrichment only (NOT for apps tab)
|
||||||
|
const DOMAIN_APP_MAP = {
|
||||||
|
'youtube.com': 'YouTube', 'googlevideo.com': 'YouTube', 'yt.be': 'YouTube',
|
||||||
|
'facebook.com': 'Facebook', 'fbcdn.net': 'Facebook', 'instagram.com': 'Instagram',
|
||||||
|
'whatsapp.com': 'WhatsApp', 'wa.me': 'WhatsApp',
|
||||||
|
'tiktok.com': 'TikTok', 'tiktokv.com': 'TikTok',
|
||||||
|
'cloudflare.com': 'Cloudflare', 'cloudflare-dns.com': 'Cloudflare',
|
||||||
|
'google.com': 'Google', 'googleapis.com': 'Google', 'gstatic.com': 'Google',
|
||||||
|
'microsoft.com': 'Microsoft', 'microsoftonline.com': 'Microsoft', 'windows.com': 'Microsoft',
|
||||||
|
'office.com': 'Microsoft', 'live.com': 'Microsoft', 'azure.com': 'Microsoft',
|
||||||
|
'netflix.com': 'Netflix', 'nflximg.net': 'Netflix',
|
||||||
|
'twitter.com': 'X (Twitter)', 'twimg.com': 'X (Twitter)',
|
||||||
|
'telegram.org': 'Telegram', 'telegram.me': 'Telegram',
|
||||||
|
'zoom.us': 'Zoom', 'zoomgov.com': 'Zoom',
|
||||||
|
'amazon.com': 'Amazon', 'amazonaws.com': 'Amazon AWS',
|
||||||
|
'apple.com': 'Apple', 'icloud.com': 'iCloud',
|
||||||
|
'spotify.com': 'Spotify', 'wazuh.com': 'Wazuh (Security)',
|
||||||
|
'adobe.com': 'Adobe', 'dropbox.com': 'Dropbox',
|
||||||
|
};
|
||||||
|
|
||||||
|
function inferAppFromDomain(domain) {
|
||||||
|
if (!domain) return null;
|
||||||
|
const lower = domain.toLowerCase().replace(/^www\./, '');
|
||||||
|
if (DOMAIN_APP_MAP[lower]) return DOMAIN_APP_MAP[lower];
|
||||||
|
for (const [key, app] of Object.entries(DOMAIN_APP_MAP)) {
|
||||||
|
if (lower.endsWith('.' + key) || lower === key) return app;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async function deviceDetailsHandler(req, res, helpers) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
getTimeFilter, getBaseFilter, generateMacFromIp,
|
||||||
|
resolveDeviceTypeFromIp, resolveOSFromIp, resolveVendorFromIp, generateAutoLabel
|
||||||
|
} = helpers;
|
||||||
|
|
||||||
|
const baseFilter = getBaseFilter(req);
|
||||||
|
|
||||||
|
let ip = String(req.query.ip ?? '');
|
||||||
|
const mac = String(req.query.mac ?? '');
|
||||||
|
|
||||||
|
if (!ip && mac) {
|
||||||
|
const dev = await DeviceStat.findOne({ mac_address: mac, ...baseFilter }).sort({ timestamp: -1 }).lean();
|
||||||
|
if (dev) {
|
||||||
|
ip = dev.ip_address;
|
||||||
|
} else {
|
||||||
|
const flow = await Flow.findOne({ src_mac: mac, ...baseFilter }).sort({ timestamp: -1 }).lean();
|
||||||
|
if (flow) ip = flow.src_ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ip && !mac) return res.status(400).json({ ok: false, message: 'ip or mac required' });
|
||||||
|
|
||||||
|
// Find device stats by ip if set, else by mac
|
||||||
|
const deviceQuery = ip ? { ip_address: ip } : { mac_address: mac };
|
||||||
|
const device = await DeviceStat.findOne({ ...deviceQuery, ...baseFilter }).sort({ timestamp: -1 }).lean();
|
||||||
|
if (!ip && device?.ip_address) {
|
||||||
|
ip = device.ip_address;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentUuid = baseFilter.agent_uuid || device?.agent_uuid || null;
|
||||||
|
|
||||||
|
// ── PRIMARY bandwidth source ─────────────────────────────────────────────
|
||||||
|
const totalDownload = device?.download || 0;
|
||||||
|
const totalUpload = device?.upload || 0;
|
||||||
|
|
||||||
|
// ── Flow filter ──────────────────────────────────────────────────────────
|
||||||
|
const flowFilter = {};
|
||||||
|
if (agentUuid) flowFilter.agent_uuid = agentUuid;
|
||||||
|
if (req.user?.site_uuid) flowFilter.site_uuid = req.user.site_uuid;
|
||||||
|
|
||||||
|
const rawTimeRange = String(req.query.timeRange ?? 'all');
|
||||||
|
if (rawTimeRange !== 'all') {
|
||||||
|
const tf = getTimeFilter(req);
|
||||||
|
if (tf) flowFilter.timestamp = tf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parallel queries ─────────────────────────────────────────────────────
|
||||||
|
const flowQueryConditions = [];
|
||||||
|
if (ip) {
|
||||||
|
flowQueryConditions.push({ src_ip: ip }, { dst_ip: ip });
|
||||||
|
}
|
||||||
|
if (mac) {
|
||||||
|
flowQueryConditions.push({ src_mac: mac }, { dst_mac: mac });
|
||||||
|
}
|
||||||
|
|
||||||
|
const threatQuery = {
|
||||||
|
...(agentUuid ? { agent_uuid: agentUuid } : {})
|
||||||
|
};
|
||||||
|
if (ip && mac) {
|
||||||
|
threatQuery.$or = [{ ip_address: ip }, { mac_address: mac }, { src_mac: mac }];
|
||||||
|
} else if (ip) {
|
||||||
|
threatQuery.ip_address = ip;
|
||||||
|
} else if (mac) {
|
||||||
|
threatQuery.$or = [{ mac_address: mac }, { src_mac: mac }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const appFilter = agentUuid ? { agent_uuid: agentUuid, ip_address: ip } : { ip_address: ip };
|
||||||
|
if (req.user?.site_uuid) appFilter.site_uuid = req.user.site_uuid;
|
||||||
|
|
||||||
|
const [deviceAppStats, flowsQuery, rawThreats] = await Promise.all([
|
||||||
|
// Only query DeviceAppStat if we have an IP
|
||||||
|
ip ? DeviceAppStat.find(appFilter).sort({ timestamp: -1 }).lean() : [],
|
||||||
|
flowQueryConditions.length > 0
|
||||||
|
? Flow.find({ ...flowFilter, $or: flowQueryConditions }).sort({ timestamp: -1 }).limit(5000).lean()
|
||||||
|
: [],
|
||||||
|
Threat.find(threatQuery).sort({ detected_at: -1 }).lean(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Aggregate by app_label and sum download and upload
|
||||||
|
const appLatest = {};
|
||||||
|
for (const a of deviceAppStats) {
|
||||||
|
const key = a.app_label;
|
||||||
|
if (!appLatest[key]) {
|
||||||
|
appLatest[key] = {
|
||||||
|
app_label: a.app_label,
|
||||||
|
download: 0,
|
||||||
|
upload: 0,
|
||||||
|
flows: 0,
|
||||||
|
first_seen: a.created_at || a.timestamp,
|
||||||
|
last_seen: a.updated_at || a.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
appLatest[key].download += a.download || 0;
|
||||||
|
appLatest[key].upload += a.upload || 0;
|
||||||
|
appLatest[key].flows += a.flows || 0;
|
||||||
|
if (new Date(a.timestamp) > new Date(appLatest[key].last_seen)) {
|
||||||
|
appLatest[key].last_seen = a.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const apps = Object.values(appLatest)
|
||||||
|
.filter(a => (a.download || 0) + (a.upload || 0) > 0)
|
||||||
|
.sort((a, b) => (b.download || 0) - (a.download || 0));
|
||||||
|
|
||||||
|
// ── Protocol / Domain tabs — from Flow collection ────────────────────────
|
||||||
|
const protocolsMap = {}, domainsMap = {}, destinationsMap = {};
|
||||||
|
const bump = (map, key, down, up, ls) => {
|
||||||
|
if (!map[key]) map[key] = { app_label: key, download: 0, upload: 0, last_seen: ls };
|
||||||
|
else if (new Date(ls) > new Date(map[key].last_seen)) map[key].last_seen = ls;
|
||||||
|
map[key].download += down;
|
||||||
|
map[key].upload += up;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const f of flowsQuery) {
|
||||||
|
const isOutbound = ip ? (f.src_ip === ip) : (mac ? (f.src_mac === mac) : false);
|
||||||
|
if (!isOutbound) continue; // outbound only
|
||||||
|
const down = f.download || 0;
|
||||||
|
const up = f.upload || 0;
|
||||||
|
const ls = f.last_seen || (f.timestamp ? new Date(f.timestamp).toISOString() : new Date().toISOString());
|
||||||
|
|
||||||
|
if (f.app_label) bump(protocolsMap, f.app_label, down, up, ls);
|
||||||
|
else if (f.protocol) bump(protocolsMap, f.protocol, down, up, ls);
|
||||||
|
|
||||||
|
const domainVal = f.sni_hostname || f.domain;
|
||||||
|
if (domainVal) bump(domainsMap, domainVal, down, up, ls);
|
||||||
|
if (f.dst_ip) bump(destinationsMap, f.dst_ip, down, up, ls);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Device metadata ───────────────────────────────────────────────────────
|
||||||
|
const targetMac = device?.mac_address || mac || generateMacFromIp(ip);
|
||||||
|
const type = (device?.device_type && !['−', 'Unknown', '-'].includes(device.device_type)) ? device.device_type : resolveDeviceTypeFromIp(ip);
|
||||||
|
const man = (device?.manufacturer && !['−', 'Unknown', '-'].includes(device.manufacturer)) ? device.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
const os = (device?.os_label && !['−', 'Unknown', '-'].includes(device.os_label)) ? device.os_label : resolveOSFromIp(ip);
|
||||||
|
const lastSeen = device?.last_seen || device?.timestamp || new Date().toISOString();
|
||||||
|
|
||||||
|
const customLabelDoc = await CustomDeviceLabel.findOne({ mac_address: targetMac }).lean();
|
||||||
|
const baseLabel = customLabelDoc?.device_label || device?.device_label;
|
||||||
|
const finalLabel = baseLabel && !['−', 'Unknown', 'Generic Client', '-'].includes(baseLabel)
|
||||||
|
? baseLabel : generateAutoLabel(ip, targetMac, man, type);
|
||||||
|
|
||||||
|
let agent_label = agentUuid;
|
||||||
|
if (agentUuid) {
|
||||||
|
const agentUser = await User.findOne({ agent_uuid: agentUuid, role: 'AGENT_VIEWER' }).lean();
|
||||||
|
if (agentUser?.account_name) agent_label = agentUser.account_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const threats = rawThreats.map(t => ({
|
||||||
|
id: t._id?.toString(),
|
||||||
|
threat_type: t.threat_type,
|
||||||
|
severity: t.severity,
|
||||||
|
ip_address: t.ip_address || t.src_ip,
|
||||||
|
dst_ip: t.dst_ip,
|
||||||
|
mac_address: t.mac_address || t.src_mac || null,
|
||||||
|
app_label: t.app_label || null,
|
||||||
|
domain: t.domain || null,
|
||||||
|
detected_at: t.detected_at || t.timestamp,
|
||||||
|
description: t.description || `Suspicious activity detected from ${t.ip_address || t.src_ip}`,
|
||||||
|
agent_uuid: t.agent_uuid,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const flows = flowsQuery
|
||||||
|
.filter(f => ip ? (f.src_ip === ip) : (mac ? (f.src_mac === mac) : false))
|
||||||
|
.map(f => ({
|
||||||
|
flow_id: f.flow_id || f._id.toString(),
|
||||||
|
src_ip: f.src_ip,
|
||||||
|
dst_ip: f.dst_ip,
|
||||||
|
dst_port: f.dst_port,
|
||||||
|
protocol: f.protocol,
|
||||||
|
app_label: inferAppFromDomain(f.sni_hostname || f.domain) || f.app_label || 'Other',
|
||||||
|
domain: f.sni_hostname || f.domain || null,
|
||||||
|
download: f.download || 0,
|
||||||
|
upload: f.upload || 0,
|
||||||
|
last_seen: f.last_seen || (f.timestamp ? new Date(f.timestamp).toISOString() : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
console.log(`[DeviceDetails] ip=${ip} agent=${agentUuid} appsFromDB=${apps.length} flows=${flowsQuery.length} dl=${(totalDownload/1e9).toFixed(2)}GB time=${elapsed}ms`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
ip_address: ip,
|
||||||
|
mac_address: targetMac,
|
||||||
|
device_label: finalLabel,
|
||||||
|
device_type: type,
|
||||||
|
os_label: os,
|
||||||
|
manufacturer: man,
|
||||||
|
last_seen: lastSeen,
|
||||||
|
total_download: totalDownload,
|
||||||
|
total_upload: totalUpload,
|
||||||
|
agent_uuid: agentUuid,
|
||||||
|
agent_label,
|
||||||
|
flows,
|
||||||
|
apps,
|
||||||
|
protocols: Object.values(protocolsMap).sort((a, b) => b.download - a.download),
|
||||||
|
domains: Object.values(domainsMap).sort((a, b) => b.download - a.download),
|
||||||
|
destinations: Object.values(destinationsMap).sort((a, b) => b.download - a.download),
|
||||||
|
threats,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DeviceDetailsHandler] Error:', err);
|
||||||
|
return res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// backend/routes/metadataDetail.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Row-level detail endpoints for the BackOne Metadata page.
|
||||||
|
// Each endpoint returns the real MongoDB breakdown for a clicked row.
|
||||||
|
// GET /api/dashboard/metadata-detail?type=<type>&value=<value>
|
||||||
|
//
|
||||||
|
// Supported types:
|
||||||
|
// sni_hostname, ssl_server_cn, quic_hostname → Flow collection (domain field)
|
||||||
|
// netbios_hostname, os_label → DeviceStat collection
|
||||||
|
// dhcp_fingerprint → DhcpFingerprintStat collection
|
||||||
|
// http_useragent → HttpUserAgentStat collection
|
||||||
|
// ssh_version → SshClientStat + SshServerStat
|
||||||
|
// bittorrent_hash → BittorrentHashStat collection
|
||||||
|
// mdns_hostname → MdnsHostnameStat collection
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const { Flow, DeviceStat } = require('../models/Schemas');
|
||||||
|
const {
|
||||||
|
DhcpFingerprintStat, HttpUserAgentStat, BittorrentHashStat,
|
||||||
|
SniHostnameStat, SslServerCnStat, QuicHostnameStat,
|
||||||
|
SshClientStat, SshServerStat, MdnsHostnameStat,
|
||||||
|
} = require('../models/SchemasTelemetry');
|
||||||
|
|
||||||
|
// ─── Helper: build base filter from request user/time ──────────────────────────
|
||||||
|
function buildBaseFilter(req) {
|
||||||
|
const range = req.query.timeRange || 'all';
|
||||||
|
const filter = {};
|
||||||
|
if (req.user?.site_uuid) filter.site_uuid = req.user.site_uuid;
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
filter.agent_uuid = req.user.agent_uuid;
|
||||||
|
}
|
||||||
|
if (range !== 'all') {
|
||||||
|
const ms = { '5m': 300000, '30m': 1800000, '1h': 3600000, '1d': 86400000, '7d': 604800000 };
|
||||||
|
const delta = ms[range];
|
||||||
|
if (delta) filter.timestamp = { $gte: new Date(Date.now() - delta) };
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper: get per-device breakdown from Flow using a domain value ────────────
|
||||||
|
async function deviceBreakdownByDomain(type, value, base) {
|
||||||
|
let matchQuery = { ...base };
|
||||||
|
|
||||||
|
if (type === 'sni_hostname') {
|
||||||
|
// Exact match for sni_hostname, with a fallback OR condition
|
||||||
|
// just in case old data doesn't have sni_hostname but domain matches it closely
|
||||||
|
const parts = value.split('.');
|
||||||
|
const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : value;
|
||||||
|
const baseDomain2 = parts.length > 3 ? parts.slice(-3).join('.') : value; // For co.uk etc
|
||||||
|
|
||||||
|
matchQuery.$or = [
|
||||||
|
{ sni_hostname: value },
|
||||||
|
{ domain: value },
|
||||||
|
{ domain: baseDomain },
|
||||||
|
{ domain: baseDomain2 }
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
matchQuery.domain = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Flow.aggregate([
|
||||||
|
{ $match: matchQuery },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$src_ip',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: 1 },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' },
|
||||||
|
last_seen: { $max: '$timestamp' },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 200 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper: enrich IP rows with DeviceStat info ───────────────────────────────
|
||||||
|
async function enrichWithDeviceStat(ipRows, agentFilter) {
|
||||||
|
const { generateMacFromIp, resolveVendorFromIp, resolveOSFromIp, generateAutoLabel } = require('../deviceResolver');
|
||||||
|
const ips = ipRows.map(r => r._id).filter(Boolean);
|
||||||
|
const devices = await DeviceStat.find({ ip_address: { $in: ips }, ...agentFilter }).lean();
|
||||||
|
const deviceMap = {};
|
||||||
|
for (const d of devices) deviceMap[d.ip_address] = d;
|
||||||
|
return ipRows.map(r => {
|
||||||
|
const ip = r._id;
|
||||||
|
const d = deviceMap[ip];
|
||||||
|
const mac = d?.mac_address && d.mac_address !== '-' ? d.mac_address : generateMacFromIp(ip);
|
||||||
|
const manufacturer = d?.manufacturer && d.manufacturer !== '-' && d.manufacturer !== 'Unknown' ? d.manufacturer : resolveVendorFromIp(ip);
|
||||||
|
const os = d?.os_label && d.os_label !== '-' && d.os_label !== 'Unknown' ? d.os_label : resolveOSFromIp(ip);
|
||||||
|
const label = d?.device_label && d.device_label !== '-' && d.device_label !== ip ? d.device_label : generateAutoLabel(ip, mac, manufacturer, 'Workstation');
|
||||||
|
return {
|
||||||
|
src_ip: ip,
|
||||||
|
device_label: label,
|
||||||
|
mac_address: mac,
|
||||||
|
manufacturer: manufacturer,
|
||||||
|
os_label: os,
|
||||||
|
download: r.download,
|
||||||
|
upload: r.upload,
|
||||||
|
flows: r.flows,
|
||||||
|
agent_uuid: r.agent_uuid,
|
||||||
|
last_seen: r.last_seen,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/dashboard/metadata-detail ───────────────────────────────────────
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { type, value } = req.query;
|
||||||
|
if (!type || !value) return res.status(400).json({ ok: false, error: 'type and value are required' });
|
||||||
|
|
||||||
|
const base = buildBaseFilter(req);
|
||||||
|
const agentFilter = {};
|
||||||
|
if (base.agent_uuid) agentFilter.agent_uuid = base.agent_uuid;
|
||||||
|
if (base.site_uuid) agentFilter.site_uuid = base.site_uuid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = [];
|
||||||
|
|
||||||
|
// ── Domain-based types: cross-reference with Flow.domain ──────────────────
|
||||||
|
if (['sni_hostname', 'ssl_server_cn', 'quic_hostname'].includes(type)) {
|
||||||
|
const ipRows = await deviceBreakdownByDomain(type, value, base);
|
||||||
|
data = await enrichWithDeviceStat(ipRows, agentFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NetBIOS / OS: query DeviceStat directly ────────────────────────────────
|
||||||
|
else if (type === 'netbios_hostname') {
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: { device_label: value, ...agentFilter } },
|
||||||
|
{ $sort: { timestamp: -1 } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ip_address',
|
||||||
|
mac_address: { $first: '$mac_address' },
|
||||||
|
device_label: { $first: '$device_label' },
|
||||||
|
device_type: { $first: '$device_type' },
|
||||||
|
os_label: { $first: '$os_label' },
|
||||||
|
manufacturer: { $first: '$manufacturer' },
|
||||||
|
download: { $max: '$download' },
|
||||||
|
upload: { $max: '$upload' },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' },
|
||||||
|
last_seen: { $first: '$last_seen' },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
];
|
||||||
|
const rows = await DeviceStat.aggregate(pipeline);
|
||||||
|
data = rows.map(d => ({
|
||||||
|
ip_address: d._id,
|
||||||
|
mac_address: d.mac_address || '—',
|
||||||
|
device_label: d.device_label || '—',
|
||||||
|
device_type: d.device_type || '—',
|
||||||
|
os_label: d.os_label || '—',
|
||||||
|
manufacturer: d.manufacturer || '—',
|
||||||
|
download: d.download || 0,
|
||||||
|
upload: d.upload || 0,
|
||||||
|
agent_uuid: d.agent_uuid,
|
||||||
|
last_seen: d.last_seen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (type === 'os_label') {
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: { os_label: value, ...agentFilter } },
|
||||||
|
{ $sort: { timestamp: -1 } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$ip_address',
|
||||||
|
mac_address: { $first: '$mac_address' },
|
||||||
|
device_label: { $first: '$device_label' },
|
||||||
|
device_type: { $first: '$device_type' },
|
||||||
|
manufacturer: { $first: '$manufacturer' },
|
||||||
|
download: { $max: '$download' },
|
||||||
|
upload: { $max: '$upload' },
|
||||||
|
agent_uuid: { $first: '$agent_uuid' },
|
||||||
|
last_seen: { $first: '$last_seen' },
|
||||||
|
}},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
];
|
||||||
|
const rows = await DeviceStat.aggregate(pipeline);
|
||||||
|
data = rows.map(d => ({
|
||||||
|
ip_address: d._id,
|
||||||
|
mac_address: d.mac_address || '—',
|
||||||
|
device_label: d.device_label || d._id,
|
||||||
|
device_type: d.device_type || '—',
|
||||||
|
manufacturer: d.manufacturer || '—',
|
||||||
|
download: d.download || 0,
|
||||||
|
upload: d.upload || 0,
|
||||||
|
agent_uuid: d.agent_uuid,
|
||||||
|
last_seen: d.last_seen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Property-based types: query specific telemetry collection ──────────────
|
||||||
|
else if (type === 'dhcp_fingerprint') {
|
||||||
|
data = await DhcpFingerprintStat.find({ fingerprint: value, ...agentFilter })
|
||||||
|
.sort({ download: -1 }).limit(1000000).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (type === 'http_useragent') {
|
||||||
|
data = await HttpUserAgentStat.find({ user_agent: value, ...agentFilter })
|
||||||
|
.sort({ download: -1 }).limit(1000000).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (type === 'bittorrent_hash') {
|
||||||
|
data = await BittorrentHashStat.find({ info_hash: value, ...agentFilter })
|
||||||
|
.sort({ download: -1 }).limit(1000000).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (type === 'ssh_version') {
|
||||||
|
const [clients, servers] = await Promise.all([
|
||||||
|
SshClientStat.find({ ssh_client: value, ...agentFilter }).sort({ download: -1 }).limit(1000000).lean(),
|
||||||
|
SshServerStat.find({ ssh_server: value, ...agentFilter }).sort({ download: -1 }).limit(1000000).lean(),
|
||||||
|
]);
|
||||||
|
// Merge clients + servers, label each with role
|
||||||
|
data = [
|
||||||
|
...clients.map(r => ({ ...r, role: 'Client' })),
|
||||||
|
...servers.map(r => ({ ...r, role: 'Server' })),
|
||||||
|
].sort((a, b) => (b.download || 0) - (a.download || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (type === 'mdns_hostname') {
|
||||||
|
data = await MdnsHostnameStat.find({ mdns_hostname: value, ...agentFilter })
|
||||||
|
.sort({ download: -1 }).limit(1000000).lean();
|
||||||
|
}
|
||||||
|
|
||||||
|
else {
|
||||||
|
return res.status(400).json({ ok: false, error: `Unknown detail type: ${type}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true, type, value, count: data.length, data });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MetadataDetail] Error:', err.message);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// backend/routes/remoteIpDetailsHandler.js
|
||||||
|
const { Flow, Threat } = require('../models/Schemas');
|
||||||
|
|
||||||
|
module.exports = async function remoteIpDetailsHandler(req, res, helpers) {
|
||||||
|
try {
|
||||||
|
const { getTimeFilter } = helpers;
|
||||||
|
const ip = String(req.query.ip ?? '');
|
||||||
|
if (!ip) return res.status(400).json({ ok: false, message: 'ip required' });
|
||||||
|
|
||||||
|
const flowFilter = {};
|
||||||
|
if (req.user?.site_uuid) flowFilter.site_uuid = req.user.site_uuid;
|
||||||
|
|
||||||
|
// Agent scope if viewer or query param
|
||||||
|
if (req.user?.role === 'AGENT_VIEWER' && req.user?.agent_uuid) {
|
||||||
|
flowFilter.agent_uuid = req.user.agent_uuid;
|
||||||
|
} else if (req.query?.agent_uuid) {
|
||||||
|
flowFilter.agent_uuid = req.query.agent_uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTimeRange = String(req.query.timeRange ?? 'all');
|
||||||
|
if (rawTimeRange !== 'all') {
|
||||||
|
const tf = getTimeFilter(req);
|
||||||
|
if (tf) flowFilter.timestamp = tf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parallel queries
|
||||||
|
const [flowsQuery, rawThreats] = await Promise.all([
|
||||||
|
Flow.find({ ...flowFilter, $or: [{ src_ip: ip }, { dst_ip: ip }] }).sort({ timestamp: -1 }).limit(1000000).lean(),
|
||||||
|
Threat.find({ ip_address: ip, ...flowFilter }).sort({ detected_at: -1 }).lean(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Data maps
|
||||||
|
const protocolsMap = {};
|
||||||
|
const domainsMap = {};
|
||||||
|
const localDevicesMap = {};
|
||||||
|
|
||||||
|
let totalDownload = 0;
|
||||||
|
let totalUpload = 0;
|
||||||
|
let lastSeen = null;
|
||||||
|
let firstSeen = null;
|
||||||
|
|
||||||
|
const bump = (map, key, down, up, ls) => {
|
||||||
|
if (!map[key]) map[key] = { app_label: key, download: 0, upload: 0, last_seen: ls, first_seen: ls };
|
||||||
|
else {
|
||||||
|
if (new Date(ls) > new Date(map[key].last_seen)) map[key].last_seen = ls;
|
||||||
|
if (new Date(ls) < new Date(map[key].first_seen)) map[key].first_seen = ls;
|
||||||
|
}
|
||||||
|
map[key].download += down;
|
||||||
|
map[key].upload += up;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const f of flowsQuery) {
|
||||||
|
let localIp = '';
|
||||||
|
let down = f.download || 0;
|
||||||
|
let up = f.upload || 0;
|
||||||
|
let remoteDown = 0;
|
||||||
|
let remoteUp = 0;
|
||||||
|
|
||||||
|
if (f.dst_ip === ip) {
|
||||||
|
localIp = f.src_ip;
|
||||||
|
remoteDown = up; // Remote received what local sent
|
||||||
|
remoteUp = down; // Remote sent what local received
|
||||||
|
} else if (f.src_ip === ip) {
|
||||||
|
localIp = f.dst_ip;
|
||||||
|
remoteDown = down;
|
||||||
|
remoteUp = up;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDownload += remoteDown;
|
||||||
|
totalUpload += remoteUp;
|
||||||
|
|
||||||
|
const ls = f.last_seen || (f.timestamp ? new Date(f.timestamp).toISOString() : new Date().toISOString());
|
||||||
|
|
||||||
|
if (!lastSeen || new Date(ls) > new Date(lastSeen)) lastSeen = ls;
|
||||||
|
if (!firstSeen || new Date(ls) < new Date(firstSeen)) firstSeen = ls;
|
||||||
|
|
||||||
|
if (localIp) bump(localDevicesMap, localIp, remoteDown, remoteUp, ls);
|
||||||
|
|
||||||
|
if (f.app_label) bump(protocolsMap, f.app_label, remoteDown, remoteUp, ls);
|
||||||
|
else if (f.protocol) bump(protocolsMap, f.protocol, remoteDown, remoteUp, ls);
|
||||||
|
|
||||||
|
const domainVal = f.sni_hostname || f.domain;
|
||||||
|
if (domainVal) bump(domainsMap, domainVal, remoteDown, remoteUp, ls);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
ip_address: ip,
|
||||||
|
ip_version: ip.includes(':') ? 6 : 4,
|
||||||
|
total_download: totalDownload,
|
||||||
|
total_upload: totalUpload,
|
||||||
|
last_seen: lastSeen,
|
||||||
|
first_seen: firstSeen,
|
||||||
|
protocols: Object.values(protocolsMap).sort((a, b) => b.download + b.upload - (a.download + a.upload)),
|
||||||
|
domains: Object.values(domainsMap).sort((a, b) => b.download + b.upload - (a.download + a.upload)),
|
||||||
|
local_devices: Object.values(localDevicesMap).sort((a, b) => b.download + b.upload - (a.download + a.upload)),
|
||||||
|
flows: flowsQuery.slice(0, 100), // top 100 recent flows
|
||||||
|
threats: rawThreats
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remote IP Details Error:', err);
|
||||||
|
res.status(500).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
require('dotenv').config({ path: 'd:/Kuliah/DPI/netify-dashboard/.env.local' });
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function testFilter() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
console.log("Connected to MongoDB.");
|
||||||
|
|
||||||
|
// Using the exact collection name mongoose uses for DeviceStat
|
||||||
|
const DeviceStat = mongoose.connection.collection('devicestats');
|
||||||
|
|
||||||
|
const kantorUUID = 'F6-2V-DT-8A';
|
||||||
|
const kantorDevices = await DeviceStat.find({ agent_uuid: kantorUUID }).toArray();
|
||||||
|
|
||||||
|
console.log(`\nTotal device records saved in Database specifically for Agent Kantor (${kantorUUID}): ${kantorDevices.length}`);
|
||||||
|
|
||||||
|
const contaminated = kantorDevices.filter(d => d.ip_address && d.ip_address.startsWith('10.6.'));
|
||||||
|
|
||||||
|
console.log(`\nOUT OF THOSE, how many have Balaraja IP (10.6.x.x) despite being tagged as Kantor? ${contaminated.length}`);
|
||||||
|
|
||||||
|
if (contaminated.length > 0) {
|
||||||
|
console.log("Example contaminated IPs in DB under Kantor's UUID:");
|
||||||
|
console.log(contaminated.slice(0, 5).map(d => `- ${d.ip_address}`).join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
testFilter();
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// backend/scripts/seed_device_labels.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Batch Random Device Label Seeder
|
||||||
|
// Generates and assigns realistic custom device labels to all unlabelled MAC
|
||||||
|
// addresses in MongoDB without overwriting existing manual labels.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
// Load environment configuration
|
||||||
|
const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local';
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '../../', envFile) });
|
||||||
|
|
||||||
|
const connectDB = require('../db/mongoose');
|
||||||
|
const { DeviceStat, Flow, CustomDeviceLabel } = require('../models/Schemas');
|
||||||
|
|
||||||
|
// Rich pool of People's Names (70% weight)
|
||||||
|
const PEOPLE_NAMES_POOL = [
|
||||||
|
// Personal Owner Names & Laptops
|
||||||
|
"Laptop Budi", "PC Andi", "Laptop Maya", "PC Danu", "Laptop Rizky",
|
||||||
|
"iPhone Sarah", "iPad Doni", "Laptop Fajar", "MacBook Siti", "Laptop Eko",
|
||||||
|
"ThinkPad Herman", "Dell Nina", "Laptop Dewi", "PC Agus", "Laptop Dimas",
|
||||||
|
"PC Tri", "Laptop Nur", "iPhone Sari", "MacBook Bayu", "Laptop Hendra",
|
||||||
|
"PC Rini", "Laptop Yulia", "Laptop Irfan", "PC Farhan", "Laptop Nabila",
|
||||||
|
"PC Ari", "Laptop Kevin", "PC Clarissa", "Laptop Tari", "PC Wahyu",
|
||||||
|
"Laptop Gilang", "PC Putu", "Laptop Made", "PC Wayan", "Laptop Rian",
|
||||||
|
"PC Anton", "Laptop Bella", "PC Diana", "Laptop Erlangga", "PC Fitri",
|
||||||
|
|
||||||
|
// Full Personal Names
|
||||||
|
"Budi Prasetyo", "Andi Wijaya", "Maya Srikandi", "Danu Kusuma", "Rizky Pratama",
|
||||||
|
"Sarah Amelia", "Doni Setiawan", "Fajar Ramadhan", "Siti Rahmawati", "Eko Susilo",
|
||||||
|
"Herman Santoso", "Nina Kartika", "Dewi Anggraini", "Agus Kurniawan", "Dimas Saputra",
|
||||||
|
"Tri Utami", "Nur Hidayah", "Bayu Perdana", "Hendra Gunawan", "Rini Astuti",
|
||||||
|
"Yulia Lestari", "Irfan Maulana", "Farhan Hidayat", "Nabila Putri", "Ari Wibowo",
|
||||||
|
"Kevin Sanjaya", "Clarissa Amanda", "Tari Wulandari", "Wahyu Hidayat", "Gilang Ramadhan",
|
||||||
|
"Rian Ardianto", "Anton Sujarwo", "Bella Safitri", "Diana Novita", "Erlangga Putra"
|
||||||
|
];
|
||||||
|
|
||||||
|
// Secondary pool of Device/Department/Workstation Labels (30% weight)
|
||||||
|
const DEVICE_WORKSTATION_POOL = [
|
||||||
|
"MacBook Pro - Sales", "ThinkPad - IT Support", "Dell Latitude - Finance",
|
||||||
|
"Asus ROG - DevTeam", "HP EliteBook - Executive", "MacBook Air - Design",
|
||||||
|
"Lenovo Legion - SOC Analyst", "Surface Pro - Operations", "Dell XPS - Management",
|
||||||
|
"Acer Swift - Legal", "iPad Pro - Marketing", "Samsung Galaxy Tab - HR",
|
||||||
|
"Workstation 01", "Workstation 02", "Meeting Room Display",
|
||||||
|
"Guest Device - VIP", "Lobby Kiosk", "Printer Admin Floor 2",
|
||||||
|
"Reception Desk PC", "Lab Test Server", "Security Camera Hub", "IoT Gateway"
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRandomLabel() {
|
||||||
|
// 70% probability for People's Names, 30% for Device/Workstation
|
||||||
|
const isPerson = Math.random() < 0.7;
|
||||||
|
if (isPerson) {
|
||||||
|
const idx = Math.floor(Math.random() * PEOPLE_NAMES_POOL.length);
|
||||||
|
return PEOPLE_NAMES_POOL[idx];
|
||||||
|
} else {
|
||||||
|
const idx = Math.floor(Math.random() * DEVICE_WORKSTATION_POOL.length);
|
||||||
|
return DEVICE_WORKSTATION_POOL[idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedRandomDeviceLabels() {
|
||||||
|
console.log("=== Starting Batch Random Device Label Seeder ===");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectDB();
|
||||||
|
|
||||||
|
// 1. Fetch distinct MAC addresses from DeviceStat collection
|
||||||
|
const deviceStatMacs = await DeviceStat.distinct("mac_address", {
|
||||||
|
mac_address: { $exists: true, $ne: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fetch distinct MAC addresses from Flow collection
|
||||||
|
const flowMacs = await Flow.distinct("src_mac", {
|
||||||
|
src_mac: { $exists: true, $ne: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Merge and normalize MAC addresses
|
||||||
|
const allMacs = new Set();
|
||||||
|
[...deviceStatMacs, ...flowMacs].forEach(mac => {
|
||||||
|
if (!mac) return;
|
||||||
|
const cleanMac = String(mac).trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
cleanMac &&
|
||||||
|
cleanMac !== '-' &&
|
||||||
|
cleanMac !== 'unknown' &&
|
||||||
|
cleanMac !== '00:00:00:00:00:00'
|
||||||
|
) {
|
||||||
|
allMacs.add(cleanMac);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Info] Found ${allMacs.size} total unique MAC addresses across database.`);
|
||||||
|
|
||||||
|
if (allMacs.size === 0) {
|
||||||
|
console.log("[Info] No MAC addresses found. Exiting.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build bulk operations to set/update labels with 70% people names distribution
|
||||||
|
const macList = Array.from(allMacs);
|
||||||
|
const bulkOps = macList.map(mac => ({
|
||||||
|
updateOne: {
|
||||||
|
filter: { mac_address: mac },
|
||||||
|
update: { $set: { device_label: getRandomLabel() } },
|
||||||
|
upsert: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bulkResult = await CustomDeviceLabel.bulkWrite(bulkOps);
|
||||||
|
|
||||||
|
console.log("✓ Successfully seeded batch random device labels (70% People Names weight)!");
|
||||||
|
console.log(` - Total MACs Processed: ${macList.length}`);
|
||||||
|
console.log(` - Upserted: ${bulkResult.upsertedCount}`);
|
||||||
|
console.log(` - Modified: ${bulkResult.modifiedCount}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("✗ Error seeding device labels:", err);
|
||||||
|
} finally {
|
||||||
|
await mongoose.connection.close();
|
||||||
|
console.log("=== Seeding complete. Connection closed. ===");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedRandomDeviceLabels();
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// backend/server.js
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Polyfill global crypto for Node 18 compatibility (required by mongodb driver)
|
||||||
|
if (typeof globalThis.crypto === 'undefined') {
|
||||||
|
globalThis.crypto = require('crypto');
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackOne Backend API Server
|
||||||
|
//
|
||||||
|
// Tanggung jawab backend ini adalah READ-ONLY dari MongoDB.
|
||||||
|
// Semua data collection (ingestion) dilakukan oleh Proxy Server (port 4000).
|
||||||
|
// Backend TIDAK memanggil DPI API secara langsung.
|
||||||
|
//
|
||||||
|
// Environment Variables:
|
||||||
|
// MONGODB_URI - MongoDB connection string
|
||||||
|
// BACKEND_PORT - Port server ini (default: 3001)
|
||||||
|
// JWT_SECRET - Secret untuk JWT auth
|
||||||
|
// ALLOWED_ORIGINS- Comma-separated allowed CORS origins
|
||||||
|
// PROXY_URL - URL proxy server (untuk trigger manual refresh)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const envFile = process.env.NODE_ENV === 'production' ? '.env.production' : '.env.local';
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '..', envFile) });
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const connectDB = require('./db/mongoose');
|
||||||
|
|
||||||
|
// ─── Connect to MongoDB (read-only mode) ──────────────────────────────────────
|
||||||
|
connectDB();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.BACKEND_PORT || 3001;
|
||||||
|
|
||||||
|
// ─── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
|
||||||
|
? process.env.ALLOWED_ORIGINS.split(',')
|
||||||
|
: ['http://localhost:3000', 'http://127.0.0.1:3000'];
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
if (ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
callback(new Error('Blocked by CORS policy (Unauthorized Origin)'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.originalUrl && req.originalUrl.includes('/api/dashboard')) {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const logPath = path.join(__dirname, '../scratch/http_requests.log');
|
||||||
|
const logLine = `[${new Date().toISOString()}] ${req.method} ${req.originalUrl} - Query: ${JSON.stringify(req.query)}\n`;
|
||||||
|
fs.appendFileSync(logPath, logLine);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Logger error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Public Routes ────────────────────────────────────────────────────────────
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const { getUploadsDir } = require('./routes/auth/helpers');
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/uploads', express.static(getUploadsDir()));
|
||||||
|
|
||||||
|
// ─── Auth Middleware ──────────────────────────────────────────────────────────
|
||||||
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
const { getTimeFilter, getBaseFilter } = require('./routes/dashboard/helpers');
|
||||||
|
const {
|
||||||
|
generateMacFromIp,
|
||||||
|
resolveVendorFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
|
resolveOSFromIp,
|
||||||
|
generateAutoLabel
|
||||||
|
} = require('./deviceResolver');
|
||||||
|
|
||||||
|
// ─── Protected Dashboard Routes ───────────────────────────────────────────────
|
||||||
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
|
|
||||||
|
// Override /api/dashboard/app-details to show real-time device mapping per application
|
||||||
|
app.get('/api/dashboard/app-details', requireAuth, (req, res) => {
|
||||||
|
require('./routes/appDetailsHandler')(req, res, {
|
||||||
|
getTimeFilter,
|
||||||
|
getBaseFilter
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Override /api/dashboard/device-details to map real-time classifications (Facebook, YouTube, etc.)
|
||||||
|
app.get('/api/dashboard/device-details', requireAuth, (req, res) => {
|
||||||
|
require('./routes/deviceDetailsHandler')(req, res, {
|
||||||
|
getTimeFilter,
|
||||||
|
getBaseFilter,
|
||||||
|
generateMacFromIp,
|
||||||
|
resolveDeviceTypeFromIp,
|
||||||
|
resolveOSFromIp,
|
||||||
|
resolveVendorFromIp,
|
||||||
|
generateAutoLabel
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/dashboard/remote-ip-details', requireAuth, async (req, res) => {
|
||||||
|
require('./routes/remoteIpDetailsHandler')(req, res, {
|
||||||
|
getTimeFilter
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadataDetailRoutes = require('./routes/metadataDetail');
|
||||||
|
app.use('/api/dashboard/metadata-detail', requireAuth, metadataDetailRoutes);
|
||||||
|
|
||||||
|
const categoryDetailRoutes = require('./routes/categoryDetail');
|
||||||
|
app.use('/api/dashboard/category-detail', requireAuth, categoryDetailRoutes);
|
||||||
|
|
||||||
|
app.use('/api/dashboard', requireAuth, dashboardRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Health Check ─────────────────────────────────────────────────────────────
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
message: 'BackOne Backend berjalan (MongoDB read-only mode)',
|
||||||
|
time: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Global JSON Error Handler ────────────────────────────────────────────────
|
||||||
|
// Menangkap semua error yang tidak di-handle (termasuk multer, mongoose, dll.)
|
||||||
|
// dan memastikan response selalu JSON, BUKAN HTML default Express.
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error('[Global Error Handler]', err.message || err);
|
||||||
|
const status = err.status || err.statusCode || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message || 'Internal server error',
|
||||||
|
code: err.code || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Start Server ─────────────────────────────────────────────────────────────
|
||||||
|
// Bind to 127.0.0.1 in production to prevent direct external access to port 3001.
|
||||||
|
// All external traffic must go through the reverse proxy (Apache/Nginx) at port 80/443.
|
||||||
|
const BIND_HOST = process.env.NODE_ENV === 'production' ? '127.0.0.1' : '0.0.0.0';
|
||||||
|
app.listen(PORT, BIND_HOST, () => {
|
||||||
|
console.log(`\n🚀 BackOne API Server berjalan di http://${BIND_HOST}:${PORT}`);
|
||||||
|
console.log(`🔌 API Health : http://${BIND_HOST}:${PORT}/api/health`);
|
||||||
|
console.log(`📡 Mode : READ-ONLY dari MongoDB (data dikirim oleh Proxy Server)`);
|
||||||
|
console.log(`🔒 Security : Bound to ${BIND_HOST} (internal only in production)\n`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function testApps() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
|
||||||
|
const { AppStat } = require('./models/Schemas');
|
||||||
|
|
||||||
|
const pipeline = [
|
||||||
|
{ $match: { site_uuid: '1959bb55_045b_47c7_bbdd_f33b7db197b9' } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 10 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await AppStat.aggregate(pipeline);
|
||||||
|
console.log('Result for Site A:', result);
|
||||||
|
|
||||||
|
const pipelineB = [
|
||||||
|
{ $match: { site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e' } },
|
||||||
|
{ $group: {
|
||||||
|
_id: '$app_label',
|
||||||
|
download: { $sum: '$download' },
|
||||||
|
upload: { $sum: '$upload' },
|
||||||
|
flows: { $sum: '$flows' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { download: -1 } },
|
||||||
|
{ $limit: 10 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultB = await AppStat.aggregate(pipelineB);
|
||||||
|
console.log('Result for Site B:', resultB);
|
||||||
|
|
||||||
|
mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
testApps();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function testSummary() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
|
||||||
|
const { Summary } = require('./models/Schemas');
|
||||||
|
|
||||||
|
const siteIds = await Summary.distinct('site_uuid', { agent_uuid: null });
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const delta = 24 * 3600000;
|
||||||
|
const timeFilter = { $gte: new Date(now.getTime() - delta) };
|
||||||
|
|
||||||
|
console.log('Querying with timeFilter:', timeFilter);
|
||||||
|
|
||||||
|
const siteSummaries = await Summary.find({
|
||||||
|
site_uuid: { $in: siteIds },
|
||||||
|
agent_uuid: null,
|
||||||
|
timestamp: timeFilter
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
let bandwidthDown = 0;
|
||||||
|
let bandwidthUp = 0;
|
||||||
|
|
||||||
|
const validSite = siteSummaries.filter(x => x.bandwidth_down > 0 || x.bandwidth_up > 0);
|
||||||
|
if (validSite.length > 0) {
|
||||||
|
validSite.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||||
|
bandwidthDown = validSite[0].bandwidth_down || 0;
|
||||||
|
bandwidthUp = validSite[0].bandwidth_up || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Test validSite length:', validSite.length);
|
||||||
|
console.log('Resulting Bandwidth:', bandwidthDown, bandwidthUp);
|
||||||
|
|
||||||
|
mongoose.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
testSummary();
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
async function updateSubnets() {
|
||||||
|
await mongoose.connect('mongodb://backone_inspect:backone_inspect@mongodb.prod.proit.id:27017/backone_inspect_0');
|
||||||
|
const db = mongoose.connection.db;
|
||||||
|
|
||||||
|
await db.collection('agent_registry').updateOne(
|
||||||
|
{ uuid: 'F6-2V-DT-8A' },
|
||||||
|
{ $set: { allowed_subnets: ['10.21', '192.168'] } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.collection('agent_registry').updateOne(
|
||||||
|
{ uuid: '8A-V3-PB-85' },
|
||||||
|
{ $set: { allowed_subnets: ['10.6'] } }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Subnets updated successfully.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSubnets().catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 429 KiB |