feat: implement password changes, agent headers, MAC routing tags, GeoIP caching lookups, port descriptions, and network infra agent isolation

This commit is contained in:
vanne committed 2026-07-01 14:02:27 +07:00
1 parent 72ded25e4d
commit 93d4002b27
10 files changed
+1206 -139

No files matched your search

+582 -53
View File
@@ -6,6 +6,40 @@ const bcrypt = require('bcryptjs');
const DB_PATH = path.join(__dirname, 'netify_data.db'); const DB_PATH = path.join(__dirname, 'netify_data.db');
let db; let db;
// Agent UUID mappings to MAC addresses and numeric interface IDs
const AGENT_MAC_MAP = {
'2F-TF-1D-GK': ['60:be:b4:1f:05:96'],
'8A-V3-PB-85': ['04:f4:1c:ce:c2:e6'],
'F6-2V-DT-8A': [
'2c:7b:a0:d8:86:91', '16:11:ac:73:34:1d', 'bc:45:5b:ca:d5:be',
'de:ed:cc:57:58:34', 'f4:6d:3f:ef:01:a0', '60:be:b4:29:d3:36',
'70:85:6c:81:50:d4', '70:85:6c:6d:f7:17', '60:be:b4:29:d3:33',
'60:be:b4:26:4c:d6', '60:be:b4:29:d3:32'
],
};
const AGENT_NUMERIC_IDS = {
'2F-TF-1D-GK': ['4894730147'],
'8A-V3-PB-85': ['4895530456'],
'F6-2V-DT-8A': ['4895853843', '4897042839'],
};
function getAgentTrafficRatio(agentUuid) {
const d = getDB();
const macs = AGENT_MAC_MAP[agentUuid];
if (!macs || macs.length === 0) return 0;
const latest = d.prepare("SELECT MAX(fetched_at) as t FROM mac_bandwidth").get();
if (!latest?.t) return 0;
const siteTotal = d.prepare("SELECT SUM(total) as val FROM mac_bandwidth WHERE fetched_at = ?").get(latest.t)?.val || 1;
const placeholders = macs.map(() => '?').join(',');
const agentTotal = d.prepare(`SELECT SUM(total) as val FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${placeholders})`).get(latest.t, ...macs)?.val || 0;
return siteTotal > 0 ? (agentTotal / siteTotal) : 0;
}
function getDB() { function getDB() {
if (!db) { if (!db) {
db = new Database(DB_PATH); db = new Database(DB_PATH);
@@ -26,9 +60,17 @@ function initSchema() {
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'AGENT_VIEWER', role TEXT NOT NULL DEFAULT 'AGENT_VIEWER',
site_uuid TEXT DEFAULT NULL, site_uuid TEXT DEFAULT NULL,
agent_uuid TEXT DEFAULT NULL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP created_at TEXT DEFAULT CURRENT_TIMESTAMP
)`); )`);
// Alter users table to add agent_uuid if it was created on an older schema
try {
d.exec("ALTER TABLE users ADD COLUMN agent_uuid TEXT DEFAULT NULL");
} catch (e) {
// Column already exists, safe to ignore
}
const adminExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'admin'").get(); const adminExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'admin'").get();
if (adminExists.count === 0) { if (adminExists.count === 0) {
const hash = bcrypt.hashSync('admin123', 10); const hash = bcrypt.hashSync('admin123', 10);
@@ -39,11 +81,32 @@ function initSchema() {
const agentExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent1'").get(); const agentExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent1'").get();
if (agentExists.count === 0) { if (agentExists.count === 0) {
const hash = bcrypt.hashSync('agent123', 10); const hash = bcrypt.hashSync('agent123', 10);
const site_uuid = process.env.NETIFY_SITE_UUID || 'dummy_site_uuid'; const site_uuid = process.env.NETIFY_SITE_UUID || '6681452d_9cae_4ff4_8ae8_0d504774265e';
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid) VALUES (?, ?, ?, ?)").run('agent1', hash, 'AGENT_VIEWER', site_uuid); d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent1', hash, 'AGENT_VIEWER', site_uuid, '8A-V3-PB-85');
console.log('[DB] Created default agent user (agent1 / agent123)'); console.log('[DB] Created default agent user (agent1 / agent123)');
} }
// Seed specific agent accounts for testing isolation
const agentJrpExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_jrp'").get();
if (agentJrpExists.count === 0) {
const hash = bcrypt.hashSync('agent123', 10);
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_jrp', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', '2F-TF-1D-GK');
console.log('[DB] Created JRP Cibubur agent user (agent_jrp / agent123)');
}
const agentIfgExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_ifg'").get();
if (agentIfgExists.count === 0) {
const hash = bcrypt.hashSync('agent123', 10);
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_ifg', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', '8A-V3-PB-85');
console.log('[DB] Created IFG LT.18 agent user (agent_ifg / agent123)');
}
const agentCpiExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'agent_cpi'").get();
if (agentCpiExists.count === 0) {
const hash = bcrypt.hashSync('agent123', 10);
d.prepare("INSERT INTO users (username, password_hash, role, site_uuid, agent_uuid) VALUES (?, ?, ?, ?, ?)").run('agent_cpi', hash, 'AGENT_VIEWER', '6681452d_9cae_4ff4_8ae8_0d504774265e', 'F6-2V-DT-8A');
console.log('[DB] Created CPI Balaraja agent user (agent_cpi / agent123)');
}
d.exec(`CREATE TABLE IF NOT EXISTS tls_versions ( d.exec(`CREATE TABLE IF NOT EXISTS tls_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -490,6 +553,15 @@ function initSchema() {
total_flows INTEGER DEFAULT 0, active_devices INTEGER DEFAULT 0 total_flows INTEGER DEFAULT 0, active_devices INTEGER DEFAULT 0
)`); )`);
d.exec(`CREATE TABLE IF NOT EXISTS geoip_cache (
ip_address TEXT PRIMARY KEY,
isp TEXT,
country TEXT,
city TEXT,
as_org TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)`);
console.log('[DB] Schema siap.'); console.log('[DB] Schema siap.');
} }
@@ -698,86 +770,441 @@ function insertBandwidthTimeline(summary, fetchedAt, siteUuid) {
// ─── QUERY FUNCTIONS ────────────────────────────────────────────────────────── // ─── QUERY FUNCTIONS ──────────────────────────────────────────────────────────
function getLatestBandwidthApps(limit = 20, siteUuid = null) { function getLatestBandwidthApps(limit = 20, siteUuid = null, agentUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_apps`).get();
if (!latest?.t) return [];
return d.prepare(`
SELECT * FROM bandwidth_apps WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid });
}
function getLatestDevices(limit = 100, siteUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM devices`).get();
if (!latest?.t) return [];
return d.prepare(`
SELECT * FROM devices WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid });
}
function getLatestFlows(limit = 100, siteUuid = null) {
const d = getDB(); const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get(); const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
if (!latest?.t) return []; if (!latest?.t) return [];
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT app_label, SUM(bytes_download) AS download, SUM(bytes_upload) AS upload,
(SUM(bytes_download) + SUM(bytes_upload)) AS total, COUNT(*) AS flow_count
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ? AND app_label IS NOT NULL
GROUP BY app_label
ORDER BY download DESC
LIMIT ?
`).all(...macs, latest.t, limit);
}
const appsLatest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_apps`).get();
if (!appsLatest?.t) return [];
return d.prepare(`
SELECT * FROM bandwidth_apps WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: appsLatest.t, limit, siteUuid });
}
function resolveDeviceMetadata(ip, mac, dbLabel, dbManufacturer, dbType) {
let label = dbLabel || ip;
let manufacturer = dbManufacturer || 'Unknown';
let type = dbType || 'Generic Client';
let os = 'Unknown';
if (manufacturer.includes('Routerboard') || manufacturer.includes('MikroTik')) {
manufacturer = 'MikroTik';
type = 'Router/Network';
os = 'RouterOS';
} else if (manufacturer.includes('Fortinet')) {
manufacturer = 'Fortinet';
type = 'Firewall/Network';
os = 'FortiOS';
} else if (manufacturer.includes('WatchGuard')) {
manufacturer = 'WatchGuard';
type = 'Firewall/Network';
os = 'Fireware';
} else if (manufacturer.includes('Juniper')) {
manufacturer = 'Juniper';
type = 'Switch/Network';
os = 'Junos';
} else if (manufacturer.includes('Apple')) {
manufacturer = 'Apple';
type = 'Smart Device';
os = 'iOS/macOS';
} else if (manufacturer.includes('Samsung')) {
manufacturer = 'Samsung';
type = 'Smart TV';
os = 'Tizen OS';
} else if (manufacturer.includes('LCFC') || manufacturer.includes('Lenovo')) {
manufacturer = 'Lenovo';
type = 'Workstation';
os = 'Windows/Linux';
} else if (manufacturer.includes('Huawei')) {
manufacturer = 'Huawei';
type = 'Mobile';
os = 'Android';
} else if (manufacturer.includes('Dahua')) {
manufacturer = 'Dahua';
type = 'IP Camera';
os = 'Embedded OS';
}
const labelLower = label.toLowerCase();
if (labelLower.includes('windows') || labelLower.includes('microsoft')) {
os = 'Windows';
type = 'Workstation';
manufacturer = manufacturer === 'Unknown' ? 'Microsoft' : manufacturer;
} else if (labelLower.includes('apple') || labelLower.includes('iphone') || labelLower.includes('ipad') || labelLower.includes('mac')) {
os = labelLower.includes('mac') ? 'macOS' : 'Apple iOS';
type = labelLower.includes('mac') ? 'Workstation' : 'Mobile';
manufacturer = 'Apple';
} else if (labelLower.includes('android') || labelLower.includes('oppo') || labelLower.includes('samsung phone')) {
os = 'Android';
type = 'Mobile';
if (labelLower.includes('oppo')) manufacturer = 'Oppo';
if (labelLower.includes('samsung')) manufacturer = 'Samsung';
} else if (labelLower.includes('samsung tv') || labelLower.includes('tizen')) {
os = 'Tizen OS';
type = 'Smart TV';
manufacturer = 'Samsung';
} else if (labelLower.includes('agent device')) {
os = 'Linux';
type = 'Security Agent';
manufacturer = 'S-Bluetech';
}
const isRouterIP = ['10.6.50.25', '10.6.12.242', '192.168.9.1', '10.6.11.208', '10.6.10.4'].includes(ip);
if (type === 'Router/Network' && !isRouterIP) {
type = 'LAN Client';
manufacturer = 'Unknown';
os = 'Windows/Linux';
}
return { label, manufacturer, type, os };
}
function getLatestDevices(limit = 100, siteUuid = null, agentUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM devices`).get();
if (!latest?.t) return [];
// Load intelligence tables to assist in type resolving
const intelList = d.prepare("SELECT * FROM intel_device_discovery").all();
const intelMap = new Map(intelList.map(i => [i.ip_address, i]));
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
// Find latest fetched_at in flows
const latestFlowFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
// Get unique flow client IPs with bandwidth aggregation
let flowClients = [];
if (latestFlowFetch?.t) {
flowClients = d.prepare(`
SELECT src_ip, src_mac, SUM(bytes_download) as flow_download, SUM(bytes_upload) as flow_upload
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
GROUP BY src_ip
ORDER BY flow_download DESC
`).all(...macs, latestFlowFetch.t);
}
// Get all devices in latest snapshot
const devices = d.prepare(`SELECT * FROM devices WHERE fetched_at = ?`).all(latest.t);
const devicesMap = new Map(devices.map(dev => [dev.ip_address, dev]));
const resolved = [];
const seenIps = new Set();
// First, add all devices that are explicitly in flows (active)
for (const fc of flowClients) {
if (!fc.src_ip) continue;
seenIps.add(fc.src_ip);
const devInfo = devicesMap.get(fc.src_ip);
const intelInfo = intelMap.get(fc.src_ip);
const dbLabel = devInfo ? devInfo.device_label : (intelInfo ? intelInfo.device_label : null);
const dbMan = devInfo ? devInfo.manufacturer : (intelInfo ? intelInfo.manufacturer : null);
const dbType = intelInfo ? intelInfo.device_type : null;
const meta = resolveDeviceMetadata(fc.src_ip, fc.src_mac, dbLabel, dbMan, dbType);
const isRouted = fc.src_mac === '04:f4:1c:ce:c2:e6' && fc.src_ip !== '10.6.50.25' && fc.src_ip !== '10.6.12.242';
resolved.push({
id: devInfo ? devInfo.id : null,
site_uuid: devInfo ? devInfo.site_uuid : siteUuid,
fetched_at: latest.t,
mac_address: fc.src_mac,
ip_address: fc.src_ip,
device_label: meta.label,
device_type: meta.type,
os_label: meta.os,
manufacturer: meta.manufacturer,
download: fc.flow_download,
upload: fc.flow_upload,
total: fc.flow_download + fc.flow_upload,
is_gateway_routed: isRouted ? 1 : 0
});
}
// Next, add other devices in devices table belonging to the agent's subnet (e.g. inactive/idle)
for (const dev of devices) {
if (seenIps.has(dev.ip_address)) continue;
// Check if this device belongs to the agent
let isAgentDevice = macs.includes(dev.mac_address);
if (!isAgentDevice && dev.ip_address) {
const devOctets = dev.ip_address.split('.');
if (devOctets.length === 4) {
const prefix3 = devOctets.slice(0, 3).join('.');
isAgentDevice = flowClients.some(fc => fc.src_ip && fc.src_ip.startsWith(prefix3));
}
}
if (isAgentDevice) {
const intelInfo = intelMap.get(dev.ip_address);
const meta = resolveDeviceMetadata(dev.ip_address, dev.mac_address, dev.device_label, dev.manufacturer, intelInfo ? intelInfo.device_type : null);
const isRouted = dev.mac_address === '04:f4:1c:ce:c2:e6' && dev.ip_address !== '10.6.50.25' && dev.ip_address !== '10.6.12.242';
resolved.push({
...dev,
device_label: meta.label,
device_type: meta.type,
os_label: meta.os,
manufacturer: meta.manufacturer,
download: dev.download || 0,
upload: dev.upload || 0,
total: dev.total || 0,
is_gateway_routed: isRouted ? 1 : 0
});
}
}
// Sort by download desc
resolved.sort((a, b) => b.download - a.download);
return resolved.slice(0, limit);
}
// Admin view: apply metadata parsing and gateway routed check for all devices
const rows = d.prepare(`
SELECT * FROM devices WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid });
return rows.map(dev => {
const intelInfo = intelMap.get(dev.ip_address);
const meta = resolveDeviceMetadata(dev.ip_address, dev.mac_address, dev.device_label, dev.manufacturer, intelInfo ? intelInfo.device_type : null);
const isRouted = dev.mac_address === '04:f4:1c:ce:c2:e6' && dev.ip_address !== '10.6.50.25' && dev.ip_address !== '10.6.12.242';
return {
...dev,
mac_address: dev.mac_address,
device_label: meta.label,
device_type: meta.type,
os_label: meta.os,
manufacturer: meta.manufacturer,
is_gateway_routed: isRouted ? 1 : 0
};
});
}
function getLatestFlows(limit = 100, siteUuid = null, agentUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
if (!latest?.t) return [];
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT * FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
ORDER BY bytes_download DESC
LIMIT ?
`).all(...macs, latest.t, limit);
}
return d.prepare(` return d.prepare(`
SELECT * FROM flows WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY bytes_download DESC LIMIT @limit SELECT * FROM flows WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY bytes_download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid }); `).all({ fetched_at: latest.t, limit, siteUuid });
} }
function getLatestThreats(limit = 50, siteUuid = null) { function getLatestThreats(limit = 50, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT * FROM threats
WHERE mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
ORDER BY fetched_at DESC
LIMIT ?
`).all(...macs, ...macs, limit);
}
return d.prepare(`SELECT * FROM threats WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid }); return d.prepare(`SELECT * FROM threats WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid });
} }
function getLatestProtocols(limit = 20, siteUuid = null) { function getLatestProtocols(limit = 20, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_protocols`).get(); const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
if (!latest?.t) return []; if (!latest?.t) return [];
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT protocol AS protocol_label, SUM(bytes_download) AS download, SUM(bytes_upload) AS upload, COUNT(*) AS flow_count
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
GROUP BY protocol
ORDER BY download DESC
LIMIT ?
`).all(...macs, latest.t, limit);
}
const protoLatest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_protocols`).get();
if (!protoLatest?.t) return [];
return d.prepare(` return d.prepare(`
SELECT * FROM bandwidth_protocols WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit SELECT * FROM bandwidth_protocols WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid }); `).all({ fetched_at: protoLatest.t, limit, siteUuid });
}
function getLatestCountries(limit = 15, siteUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_countries`).get();
if (!latest?.t) return [];
return d.prepare(`
SELECT * FROM bandwidth_countries WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid });
}
function getLatestDNS(limit = 20, siteUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM dns_queries`).get();
if (!latest?.t) return [];
return d.prepare(`
SELECT * FROM dns_queries WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY query_count DESC LIMIT @limit
`).all({ fetched_at: latest.t, limit, siteUuid });
} }
function getLatestEvents(limit = 50, siteUuid = null) { function getLatestCountries(limit = 15, siteUuid = null, agentUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM intel_ip_reputation`).get();
if (!latest?.t) return [];
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT country AS country_name, SUM(download) AS download, SUM(upload) AS upload, COUNT(*) AS flow_count
FROM intel_ip_reputation
WHERE (mac_address IN (${placeholders}) OR local_ip IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders})))
AND country IS NOT NULL AND fetched_at = ?
GROUP BY country
ORDER BY download DESC
LIMIT ?
`).all(...macs, ...macs, latest.t, limit);
}
const countryLatest = d.prepare(`SELECT MAX(fetched_at) as t FROM bandwidth_countries`).get();
if (!countryLatest?.t) return [];
return d.prepare(`
SELECT * FROM bandwidth_countries WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY download DESC LIMIT @limit
`).all({ fetched_at: countryLatest.t, limit, siteUuid });
}
function getLatestDNS(limit = 20, siteUuid = null, agentUuid = null) {
const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
if (!latest?.t) return [];
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT domain, COUNT(*) AS query_count, app_label, 'Web' AS category
FROM flows
WHERE src_mac IN (${placeholders}) AND domain IS NOT NULL AND fetched_at = ?
GROUP BY domain
ORDER BY query_count DESC
LIMIT ?
`).all(...macs, latest.t, limit);
}
const dnsLatest = d.prepare(`SELECT MAX(fetched_at) as t FROM dns_queries`).get();
if (!dnsLatest?.t) return [];
return d.prepare(`
SELECT * FROM dns_queries WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY query_count DESC LIMIT @limit
`).all({ fetched_at: dnsLatest.t, limit, siteUuid });
}
function getLatestEvents(limit = 50, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT * FROM events
WHERE mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
ORDER BY fetched_at DESC
LIMIT ?
`).all(...macs, ...macs, limit);
}
return d.prepare(`SELECT * FROM events WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid }); return d.prepare(`SELECT * FROM events WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid });
} }
function getBandwidthTimeline(points = 60, siteUuid = null) { function getBandwidthTimeline(points = 60, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
return d.prepare(`
SELECT fetched_at, SUM(download) AS total_download, SUM(upload) AS total_upload,
0 AS total_flows, 0 AS active_devices
FROM mac_bandwidth
WHERE mac_address IN (${placeholders})
GROUP BY fetched_at
ORDER BY fetched_at DESC
LIMIT ?
`).all(...macs, points).reverse();
}
return d.prepare(` return d.prepare(`
SELECT * FROM bandwidth_timeline WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit SELECT * FROM bandwidth_timeline WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit
`).all({ limit: points, siteUuid }).reverse(); `).all({ limit: points, siteUuid }).reverse();
} }
function getStats(siteUuid = null) { console.log('getStats called with siteUuid:', siteUuid, typeof siteUuid);
function getStats(siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
// Hitung devices aktif dari polling terakhir (bukan DISTINCT mac karena null) if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
// Count active devices for this agent
const totalDevices = getLatestDevices(1000, siteUuid, agentUuid).length;
// Count active flows for this agent
const latestFlowFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
const activeFlows = latestFlowFetch?.t
? (d.prepare(`
SELECT COUNT(*) as n FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
`).get(...macs, latestFlowFetch.t)?.n ?? 0)
: 0;
// Count threats for this agent
const totalThreats = d.prepare(`
SELECT COUNT(*) as n FROM threats
WHERE mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
`).get(...macs, ...macs)?.n ?? 0;
// Count events for this agent
const totalEvents = d.prepare(`
SELECT COUNT(*) as n FROM events
WHERE mac_address IN (${placeholders}) OR ip_address IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
`).get(...macs, ...macs)?.n ?? 0;
const lastFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get()?.t ?? null;
// Construct latest bandwidth timeline summary for this agent
const latestBw = latestFlowFetch?.t
? d.prepare(`
SELECT SUM(bytes_download) AS total_download, SUM(bytes_upload) AS total_upload,
COUNT(*) AS total_flows, COUNT(DISTINCT src_ip) AS active_devices, ? as fetched_at
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
`).get(latestFlowFetch.t, ...macs, latestFlowFetch.t)
: {};
return { totalDevices, activeFlows, totalThreats, totalEvents, lastFetch, latestBw };
}
// Fallback to site-wide stats if no agentUuid
const latestDevFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM devices`).get(); const latestDevFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM devices`).get();
console.log('latestDevFetch:', latestDevFetch); const totalDevices = latestDevFetch?.t const totalDevices = latestDevFetch?.t
? (d.prepare(`SELECT COUNT(*) as n FROM devices WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at`).get({ fetched_at: latestDevFetch.t, siteUuid })?.n ?? 0) ? (d.prepare(`SELECT COUNT(*) as n FROM devices WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at`).get({ fetched_at: latestDevFetch.t, siteUuid })?.n ?? 0)
: 0; : 0;
// Hitung flows aktif dari polling terakhir
const latestFlowFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get(); const latestFlowFetch = d.prepare(`SELECT MAX(fetched_at) as t FROM flows`).get();
console.log('latestFlowFetch:', latestFlowFetch); const activeFlows = latestFlowFetch?.t const activeFlows = latestFlowFetch?.t
? (d.prepare(`SELECT COUNT(*) as n FROM flows WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at`).get({ fetched_at: latestFlowFetch.t, siteUuid })?.n ?? 0) ? (d.prepare(`SELECT COUNT(*) as n FROM flows WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at`).get({ fetched_at: latestFlowFetch.t, siteUuid })?.n ?? 0)
: 0; : 0;
@@ -890,11 +1317,82 @@ function insertMACBandwidth(rows, fetchedAt, siteUuid) {
} }
// ─── QUERY FITUR BARU ───────────────────────────────────────────────────────── // ─── QUERY FITUR BARU ─────────────────────────────────────────────────────────
function getLatest(table, orderBy = 'download', limit = 50, siteUuid = null) { function getLatest(table, orderBy = 'download', limit = 50, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM ${table}`).get(); const latest = d.prepare(`SELECT MAX(fetched_at) as t FROM ${table}`).get();
if (!latest?.t) return []; if (!latest?.t) return [];
return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY ${orderBy} DESC LIMIT @limit`).all({ fetched_at: latest.t, limit, siteUuid });
let rows = [];
// If agentUuid is provided, handle direct MAC or interface filtering
const macs = agentUuid ? AGENT_MAC_MAP[agentUuid] : null;
if (agentUuid && macs) {
if (table === 'mac_bandwidth') {
const placeholders = macs.map(() => '?').join(',');
rows = d.prepare(`SELECT * FROM mac_bandwidth WHERE fetched_at = ? AND mac_address IN (${placeholders}) ORDER BY ${orderBy} DESC LIMIT ?`).all(latest.t, ...macs, limit);
return rows;
}
if (table === 'interfaces') {
const numericIds = AGENT_NUMERIC_IDS[agentUuid] || [];
if (numericIds.length > 0) {
const placeholders = numericIds.map(() => '?').join(',');
rows = d.prepare(`SELECT * FROM interfaces WHERE fetched_at = ? AND agent_id IN (${placeholders}) ORDER BY ${orderBy} DESC LIMIT ?`).all(latest.t, ...numericIds, limit);
return rows;
}
}
if (table === 'remote_ips') {
const latestFlowsFetch = d.prepare("SELECT MAX(fetched_at) as t FROM flows").get();
if (latestFlowsFetch?.t) {
const placeholders = macs.map(() => '?').join(',');
rows = d.prepare(`
SELECT dst_ip AS remote_ip,
CASE WHEN dst_ip LIKE '%:%' THEN 6 ELSE 4 END AS ip_version,
SUM(bytes_download) AS download,
SUM(bytes_upload) AS upload,
SUM(bytes_download + bytes_upload) AS total
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ?
GROUP BY dst_ip
ORDER BY download DESC
LIMIT ?
`).all(...macs, latestFlowsFetch.t, limit);
return rows;
}
}
if (table === 'dns_queries') {
const latestFlowsFetch = d.prepare("SELECT MAX(fetched_at) as t FROM flows").get();
if (latestFlowsFetch?.t) {
const placeholders = macs.map(() => '?').join(',');
rows = d.prepare(`
SELECT domain, COUNT(*) AS query_count
FROM flows
WHERE src_mac IN (${placeholders}) AND fetched_at = ? AND domain IS NOT NULL
GROUP BY domain
ORDER BY query_count DESC
LIMIT ?
`).all(...macs, latestFlowsFetch.t, limit);
return rows;
}
}
}
// Fallback to standard site-wide select
rows = d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) AND fetched_at = @fetched_at ORDER BY ${orderBy} DESC LIMIT @limit`).all({ fetched_at: latest.t, limit, siteUuid });
// If agentUuid is provided, scale down numerical values by traffic ratio to match the agent's footprint
if (agentUuid && !['mac_bandwidth', 'interfaces'].includes(table)) {
const ratio = getAgentTrafficRatio(agentUuid);
return rows.map(r => ({
...r,
download: Math.round((r.download || 0) * ratio),
upload: Math.round((r.upload || 0) * ratio),
total: Math.round((r.total || 0) * ratio),
query_count: Math.round((r.query_count || 0) * ratio),
flow_count: Math.round((r.flow_count || 0) * ratio),
}));
}
return rows;
} }
// DPI Fields // DPI Fields
@@ -1172,12 +1670,26 @@ function insertVPNDetection(rows, fetchedAt, siteUuid) {
// ─── QUERY Intelligence — ambil semua row tanpa batasan fetched_at ──────────── // ─── QUERY Intelligence — ambil semua row tanpa batasan fetched_at ────────────
// (karena event ini tidak diposting ulang tiap menit, simpan kumulatif) // (karena event ini tidak diposting ulang tiap menit, simpan kumulatif)
function getIntelData(table, limit = 100, siteUuid = null) { function getIntelData(table, limit = 100, siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
if (agentUuid && AGENT_MAC_MAP[agentUuid]) {
const macs = AGENT_MAC_MAP[agentUuid];
const placeholders = macs.map(() => '?').join(',');
// For reputation, the column is local_ip, not ip_address
const ipField = table === 'intel_ip_reputation' ? 'local_ip' : 'ip_address';
return d.prepare(`
SELECT * FROM ${table}
WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
ORDER BY fetched_at DESC
LIMIT ?
`).all(...macs, ...macs, limit);
}
return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid }); return d.prepare(`SELECT * FROM ${table} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid) ORDER BY fetched_at DESC LIMIT @limit`).all({ limit, siteUuid });
} }
function getIntelStats(siteUuid = null) { function getIntelStats(siteUuid = null, agentUuid = null) {
const d = getDB(); const d = getDB();
const tables = [ const tables = [
'intel_crypto_mining', 'intel_device_discovery', 'intel_encryption_audit', 'intel_crypto_mining', 'intel_device_discovery', 'intel_encryption_audit',
@@ -1185,9 +1697,21 @@ function getIntelStats(siteUuid = null) {
'intel_tor_detection', 'intel_unencrypted_passwords', 'intel_vpn_detection', 'intel_tor_detection', 'intel_unencrypted_passwords', 'intel_vpn_detection',
]; ];
const counts = {}; const counts = {};
const macs = agentUuid ? AGENT_MAC_MAP[agentUuid] : null;
const placeholders = macs ? macs.map(() => '?').join(',') : '';
for (const t of tables) { for (const t of tables) {
try { try {
counts[t] = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid)`).get({ siteUuid })?.n ?? 0; if (agentUuid && macs) {
const ipField = t === 'intel_ip_reputation' ? 'local_ip' : 'ip_address';
counts[t] = d.prepare(`
SELECT COUNT(*) as n FROM ${t}
WHERE mac_address IN (${placeholders}) OR ${ipField} IN (SELECT DISTINCT src_ip FROM flows WHERE src_mac IN (${placeholders}))
`).get(...macs, ...macs)?.n ?? 0;
} else {
counts[t] = d.prepare(`SELECT COUNT(*) as n FROM ${t} WHERE (@siteUuid IS NULL OR site_uuid = @siteUuid)`).get({ siteUuid })?.n ?? 0;
}
} catch { counts[t] = 0; } } catch { counts[t] = 0; }
} }
return counts; return counts;
@@ -1195,6 +1719,7 @@ function getIntelStats(siteUuid = null) {
module.exports = { module.exports = {
getUserByUsername, getUserByUsername,
updateUserPassword,
getDB, getDB,
insertBandwidthApps, insertDevices, insertFlows, insertThreats, insertBandwidthApps, insertDevices, insertFlows, insertThreats,
insertProtocols, insertCountries, insertDNS, insertEvents, insertProtocols, insertCountries, insertDNS, insertEvents,
@@ -1224,3 +1749,7 @@ module.exports = {
function getUserByUsername(username) { function getUserByUsername(username) {
return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username); return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username);
} }
function updateUserPassword(userId, newPasswordHash) {
return getDB().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newPasswordHash, userId);
}
+132 -3
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { getUserByUsername } = require('../database'); const { getUserByUsername, updateUserPassword, getDB } = require('../database');
const router = express.Router(); const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key'; const JWT_SECRET = process.env.JWT_SECRET || 'super-secret-backone-key';
@@ -23,7 +23,7 @@ router.post('/login', (req, res) => {
} }
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, site_uuid: user.site_uuid }, { id: user.id, username: user.username, role: user.role, site_uuid: user.site_uuid, agent_uuid: user.agent_uuid },
JWT_SECRET, JWT_SECRET,
{ expiresIn: '1d' } { expiresIn: '1d' }
); );
@@ -38,7 +38,7 @@ router.post('/login', (req, res) => {
res.json({ res.json({
message: 'Login successful', message: 'Login successful',
user: { id: user.id, username: user.username, role: user.role, site_uuid: user.site_uuid } user: { id: user.id, username: user.username, role: user.role, site_uuid: user.site_uuid, agent_uuid: user.agent_uuid }
}); });
}); });
@@ -56,9 +56,138 @@ router.get('/me', (req, res) => {
} }
}); });
router.post('/change-password', (req, res) => {
const token = req.cookies?.token;
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current password and new password are required' });
}
// 1. Get user details from database
const user = getUserByUsername(decoded.username);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// 2. Verify current password
const isCurrentValid = bcrypt.compareSync(currentPassword, user.password_hash);
if (!isCurrentValid) {
return res.status(400).json({ error: 'Password saat ini salah' });
}
// 3. Validate new password strength
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*(),.?":{}|<>]).{8,}$/;
if (!passwordRegex.test(newPassword)) {
return res.status(400).json({
error: 'Password baru tidak memenuhi kriteria: minimal 8 karakter, serta mengandung huruf besar, huruf kecil, angka, dan karakter spesial.'
});
}
// 4. Hash new password and save to DB
const newHash = bcrypt.hashSync(newPassword, 10);
updateUserPassword(user.id, newHash);
return res.json({ ok: true, message: 'Password berhasil diubah!' });
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
});
router.post('/logout', (req, res) => { router.post('/logout', (req, res) => {
res.clearCookie('token'); res.clearCookie('token');
res.json({ message: 'Logged out successfully' }); res.json({ message: 'Logged out successfully' });
}); });
router.get('/geoip', async (req, res) => {
const ip = req.query.ip;
if (!ip) {
return res.status(400).json({ error: 'IP is required' });
}
const d = getDB();
try {
// 1. Check local cache
let cached = d.prepare("SELECT * FROM geoip_cache WHERE ip_address = ?").get(ip);
if (cached) {
return res.json(cached);
}
// 2. Check if private IP (IPv4 and IPv6 link local)
const parts = ip.split('.');
let isPrivate = false;
if (parts.length === 4) {
const o1 = parseInt(parts[0], 10);
const o2 = parseInt(parts[1], 10);
if (o1 === 10) isPrivate = true;
else if (o1 === 192 && o2 === 168) isPrivate = true;
else if (o1 === 172 && (o2 >= 16 && o2 <= 31)) isPrivate = true;
else if (o1 === 127) isPrivate = true;
else if (o1 === 169 && o2 === 254) isPrivate = true;
} else if (ip.startsWith('fe80:') || ip === '::1' || ip.startsWith('fd')) {
isPrivate = true;
}
if (isPrivate) {
const privateInfo = {
ip_address: ip,
isp: 'Intranet / Private Network',
country: 'Local',
city: 'Local',
as_org: 'RFC 1918 Private Range'
};
d.prepare("INSERT OR IGNORE INTO geoip_cache (ip_address, isp, country, city, as_org) VALUES (?, ?, ?, ?, ?)").run(
privateInfo.ip_address, privateInfo.isp, privateInfo.country, privateInfo.city, privateInfo.as_org
);
return res.json(privateInfo);
}
// 3. Query public GeoIP API (ip-api.com) with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3-second timeout
const response = await fetch(`http://ip-api.com/json/${ip}`, { signal: controller.signal });
clearTimeout(timeoutId);
const geo = await response.json();
if (geo && geo.status === 'success') {
const publicInfo = {
ip_address: ip,
isp: geo.isp || 'Unknown ISP',
country: geo.country || 'Unknown Country',
city: geo.city || 'Unknown City',
as_org: geo.as || geo.org || 'Data Center'
};
d.prepare("INSERT OR IGNORE INTO geoip_cache (ip_address, isp, country, city, as_org) VALUES (?, ?, ?, ?, ?)").run(
publicInfo.ip_address, publicInfo.isp, publicInfo.country, publicInfo.city, publicInfo.as_org
);
return res.json(publicInfo);
} else {
// Return temporary/fallback details for lookup failures without caching
return res.json({
ip_address: ip,
isp: 'Public IP',
country: 'Remote',
city: 'Remote',
as_org: 'Public Network'
});
}
} catch (err) {
return res.json({
ip_address: ip,
isp: 'Public IP',
country: 'Remote',
city: 'Remote',
as_org: 'Public Network'
});
}
});
module.exports = router; module.exports = router;
+45 -45
View File
@@ -6,8 +6,8 @@ const { runPoll } = require('../scheduler');
// GET /api/dashboard/summary — kartu ringkasan (top of page) // GET /api/dashboard/summary — kartu ringkasan (top of page)
router.get('/summary', (req, res) => { router.get('/summary', (req, res) => {
const stats = db.getStats(req.user?.site_uuid); const stats = db.getStats(req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
const timeline = db.getBandwidthTimeline(1, req.user?.site_uuid); const timeline = db.getBandwidthTimeline(1, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
const latest = timeline[0] ?? {}; const latest = timeline[0] ?? {};
res.json({ res.json({
@@ -27,54 +27,54 @@ router.get('/summary', (req, res) => {
// GET /api/dashboard/apps — top aplikasi // GET /api/dashboard/apps — top aplikasi
router.get('/apps', (req, res) => { router.get('/apps', (req, res) => {
const limit = parseInt(req.query.limit ?? 10); const limit = parseInt(req.query.limit ?? 10);
const data = db.getLatestBandwidthApps(limit, req.user?.site_uuid); const data = db.getLatestBandwidthApps(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/devices — daftar device // GET /api/dashboard/devices — daftar device
router.get('/devices', (req, res) => { router.get('/devices', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
const data = db.getLatestDevices(limit, req.user?.site_uuid); const data = db.getLatestDevices(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/flows — flow aktif // GET /api/dashboard/flows — flow aktif
router.get('/flows', (req, res) => { router.get('/flows', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
const data = db.getLatestFlows(limit, req.user?.site_uuid); const data = db.getLatestFlows(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/threats — ancaman keamanan // GET /api/dashboard/threats — ancaman keamanan
router.get('/threats', (req, res) => { router.get('/threats', (req, res) => {
const limit = parseInt(req.query.limit ?? 20); const limit = parseInt(req.query.limit ?? 20);
const data = db.getLatestThreats(limit, req.user?.site_uuid); const data = db.getLatestThreats(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/protocols — top protokol // GET /api/dashboard/protocols — top protokol
router.get('/protocols', (req, res) => { router.get('/protocols', (req, res) => {
const data = db.getLatestProtocols(20, req.user?.site_uuid); const data = db.getLatestProtocols(20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/countries — top negara // GET /api/dashboard/countries — top negara
router.get('/countries', (req, res) => { router.get('/countries', (req, res) => {
const data = db.getLatestCountries(15, req.user?.site_uuid); const data = db.getLatestCountries(15, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/dns — top DNS queries // GET /api/dashboard/dns — top DNS queries
router.get('/dns', (req, res) => { router.get('/dns', (req, res) => {
const limit = parseInt(req.query.limit ?? 20); const limit = parseInt(req.query.limit ?? 20);
const data = db.getLatestDNS(limit, req.user?.site_uuid); const data = db.getLatestDNS(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
// GET /api/dashboard/events — events terbaru // GET /api/dashboard/events — events terbaru
router.get('/events', (req, res) => { router.get('/events', (req, res) => {
const limit = parseInt(req.query.limit ?? 20); const limit = parseInt(req.query.limit ?? 20);
const rawData = db.getLatestEvents(limit, req.user?.site_uuid); const rawData = db.getLatestEvents(limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
const mappedData = rawData.map(r => ({ const mappedData = rawData.map(r => ({
id: r.id, id: r.id,
event_id: r.event_id, event_id: r.event_id,
@@ -90,7 +90,7 @@ router.get('/events', (req, res) => {
// GET /api/dashboard/timeline — bandwidth timeline (grafik) // GET /api/dashboard/timeline — bandwidth timeline (grafik)
router.get('/timeline', (req, res) => { router.get('/timeline', (req, res) => {
const points = parseInt(req.query.points ?? 60); const points = parseInt(req.query.points ?? 60);
const data = db.getBandwidthTimeline(points, req.user?.site_uuid); const data = db.getBandwidthTimeline(points, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null);
res.json({ ok: true, data }); res.json({ ok: true, data });
}); });
@@ -102,67 +102,67 @@ router.post('/refresh', async (req, res) => {
// ─── ROUTES FITUR BARU 1-11 ─────────────────────────────────────────────────── // ─── ROUTES FITUR BARU 1-11 ───────────────────────────────────────────────────
router.get('/app-categories', (req, res) => { router.get('/app-categories', (req, res) => {
res.json({ ok: true, data: db.getLatest('app_categories', 'download', 15, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('app_categories', 'download', 15, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/continents', (req, res) => { router.get('/continents', (req, res) => {
res.json({ ok: true, data: db.getLatest('continents', 'download', 10, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('continents', 'download', 10, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/regions', (req, res) => { router.get('/regions', (req, res) => {
res.json({ ok: true, data: db.getLatest('regions', 'download', 20, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('regions', 'download', 20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/cities', (req, res) => { router.get('/cities', (req, res) => {
res.json({ ok: true, data: db.getLatest('cities', 'download', 20, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('cities', 'download', 20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/vlans', (req, res) => { router.get('/vlans', (req, res) => {
res.json({ ok: true, data: db.getLatest('vlans', 'download', 20, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('vlans', 'download', 20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/interfaces', (req, res) => { router.get('/interfaces', (req, res) => {
res.json({ ok: true, data: db.getLatest('interfaces', 'download', 20, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('interfaces', 'download', 20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/flow-types', (req, res) => { router.get('/flow-types', (req, res) => {
res.json({ ok: true, data: db.getLatest('flow_types', 'download', 10, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('flow_types', 'download', 10, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/flow-origins', (req, res) => { router.get('/flow-origins', (req, res) => {
res.json({ ok: true, data: db.getLatest('flow_origins', 'download', 10, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('flow_origins', 'download', 10, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/ip-versions', (req, res) => { router.get('/ip-versions', (req, res) => {
res.json({ ok: true, data: db.getLatest('ip_versions', 'download', 5, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('ip_versions', 'download', 5, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/remote-ips', (req, res) => { router.get('/remote-ips', (req, res) => {
const limit = parseInt(req.query.limit ?? 20); const limit = parseInt(req.query.limit ?? 20);
res.json({ ok: true, data: db.getLatest('remote_ips', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('remote_ips', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
router.get('/mac-bandwidth', (req, res) => { router.get('/mac-bandwidth', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getLatest('mac_bandwidth', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('mac_bandwidth', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// ─── FIX: ROUTES YANG SEBELUMNYA HILANG ────────────────────────────────────── // ─── FIX: ROUTES YANG SEBELUMNYA HILANG ──────────────────────────────────────
// TLS Versions // TLS Versions
router.get('/tls-versions', (req, res) => { router.get('/tls-versions', (req, res) => {
res.json({ ok: true, data: db.getLatest('tls_versions', 'download', 10, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('tls_versions', 'download', 10, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// TLS Ciphers // TLS Ciphers
router.get('/tls-ciphers', (req, res) => { router.get('/tls-ciphers', (req, res) => {
res.json({ ok: true, data: db.getLatest('tls_ciphers', 'download', 15, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('tls_ciphers', 'download', 15, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// TLS Security Level // TLS Security Level
router.get('/tls-security', (req, res) => { router.get('/tls-security', (req, res) => {
res.json({ ok: true, data: db.getLatest('tls_security', 'download', 10, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('tls_security', 'download', 10, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// NetBIOS Hostnames (Windows devices) // NetBIOS Hostnames (Windows devices)
router.get('/netbios', (req, res) => { router.get('/netbios', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('netbios_hostnames', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('netbios_hostnames', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// Discovery OS (sistem operasi yang terdeteksi) // Discovery OS (sistem operasi yang terdeteksi)
router.get('/discovery-os', (req, res) => { router.get('/discovery-os', (req, res) => {
res.json({ ok: true, data: db.getLatest('discovery_os', 'download', 20, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('discovery_os', 'download', 20, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// ─── ROUTES DPI 12-21 ───────────────────────────────────────────────────────── // ─── ROUTES DPI 12-21 ─────────────────────────────────────────────────────────
@@ -170,110 +170,110 @@ router.get('/discovery-os', (req, res) => {
// 12. DHCP Class Fingerprint // 12. DHCP Class Fingerprint
router.get('/dhcp-fingerprints', (req, res) => { router.get('/dhcp-fingerprints', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('dhcp_fingerprints', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('dhcp_fingerprints', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 13. HTTP User-Agent // 13. HTTP User-Agent
router.get('/http-user-agents', (req, res) => { router.get('/http-user-agents', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('http_user_agents', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('http_user_agents', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 14. HTTPS SNI Hostname // 14. HTTPS SNI Hostname
router.get('/sni-hostnames', (req, res) => { router.get('/sni-hostnames', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('sni_hostnames', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('sni_hostnames', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 15. SSL Server Common Name // 15. SSL Server Common Name
router.get('/ssl-server-cn', (req, res) => { router.get('/ssl-server-cn', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('ssl_server_cn', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('ssl_server_cn', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 17. QUIC Hostname // 17. QUIC Hostname
router.get('/quic-hostnames', (req, res) => { router.get('/quic-hostnames', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('quic_hostnames', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('quic_hostnames', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 18. BitTorrent Info Hash // 18. BitTorrent Info Hash
router.get('/bittorrent-hashes', (req, res) => { router.get('/bittorrent-hashes', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('bittorrent_hashes', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('bittorrent_hashes', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 19. SSH Version // 19. SSH Version
router.get('/ssh-versions', (req, res) => { router.get('/ssh-versions', (req, res) => {
const limit = parseInt(req.query.limit ?? 20); const limit = parseInt(req.query.limit ?? 20);
res.json({ ok: true, data: db.getLatest('ssh_versions', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('ssh_versions', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 21. mDNS Hostname (Chromecast, Apple TV, etc.) // 21. mDNS Hostname (Chromecast, Apple TV, etc.)
router.get('/mdns-hostnames', (req, res) => { router.get('/mdns-hostnames', (req, res) => {
const limit = parseInt(req.query.limit ?? 30); const limit = parseInt(req.query.limit ?? 30);
res.json({ ok: true, data: db.getLatest('mdns_hostnames', 'download', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getLatest('mdns_hostnames', 'download', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// ─── INTELLIGENCE ROUTES 22-30 ──────────────────────────────────────────────── // ─── INTELLIGENCE ROUTES 22-30 ────────────────────────────────────────────────
// Stats ringkasan semua intelligence (untuk badge count di tab) // Stats ringkasan semua intelligence (untuk badge count di tab)
router.get('/intelligence/stats', (req, res) => { router.get('/intelligence/stats', (req, res) => {
res.json({ ok: true, data: db.getIntelStats(req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelStats(req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 22. Cryptocurrency Mining // 22. Cryptocurrency Mining
router.get('/intelligence/crypto-mining', (req, res) => { router.get('/intelligence/crypto-mining', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_crypto_mining', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_crypto_mining', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 23. Device Discovery // 23. Device Discovery
router.get('/intelligence/device-discovery', (req, res) => { router.get('/intelligence/device-discovery', (req, res) => {
const limit = parseInt(req.query.limit ?? 100); const limit = parseInt(req.query.limit ?? 100);
res.json({ ok: true, data: db.getIntelData('intel_device_discovery', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_device_discovery', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 24. Encryption Audit // 24. Encryption Audit
router.get('/intelligence/encryption-audit', (req, res) => { router.get('/intelligence/encryption-audit', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_encryption_audit', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_encryption_audit', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 25. Insecure Protocols // 25. Insecure Protocols
router.get('/intelligence/insecure-protocols', (req, res) => { router.get('/intelligence/insecure-protocols', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_insecure_protocols', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_insecure_protocols', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 26. IP Reputation // 26. IP Reputation
router.get('/intelligence/ip-reputation', (req, res) => { router.get('/intelligence/ip-reputation', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_ip_reputation', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_ip_reputation', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 27. Server Discovery // 27. Server Discovery
router.get('/intelligence/server-discovery', (req, res) => { router.get('/intelligence/server-discovery', (req, res) => {
const limit = parseInt(req.query.limit ?? 100); const limit = parseInt(req.query.limit ?? 100);
res.json({ ok: true, data: db.getIntelData('intel_server_discovery', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_server_discovery', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 28. Tor Detection // 28. Tor Detection
router.get('/intelligence/tor', (req, res) => { router.get('/intelligence/tor', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_tor_detection', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_tor_detection', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 29. Unencrypted Passwords // 29. Unencrypted Passwords
router.get('/intelligence/unencrypted-passwords', (req, res) => { router.get('/intelligence/unencrypted-passwords', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_unencrypted_passwords', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_unencrypted_passwords', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// 30. VPN Detection // 30. VPN Detection
router.get('/intelligence/vpn', (req, res) => { router.get('/intelligence/vpn', (req, res) => {
const limit = parseInt(req.query.limit ?? 50); const limit = parseInt(req.query.limit ?? 50);
res.json({ ok: true, data: db.getIntelData('intel_vpn_detection', limit, req.user?.site_uuid) }); res.json({ ok: true, data: db.getIntelData('intel_vpn_detection', limit, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null) });
}); });
// ─── LOOKUP ROUTES ──────────────────────────────────────────────────────────── // ─── LOOKUP ROUTES ────────────────────────────────────────────────────────────
+22 -2
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getAgents, createAgent, updateAgent, deleteAgent, Agent } from "@/lib/actions/agents"; import { getAgents, createAgent, updateAgent, deleteAgent, Agent } from "@/lib/actions/agents";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable"; import { DataTable, Column } from "@/components/ui/DataTable";
@@ -10,6 +11,8 @@ import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react"; import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
export default function AgentsPage() { export default function AgentsPage() {
const router = useRouter();
const [role, setRole] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -46,8 +49,25 @@ export default function AgentsPage() {
}; };
useEffect(() => { useEffect(() => {
loadAgents(); fetch('/api/auth/me')
}, []); .then(res => res.json())
.then(data => {
if (data.user) {
setRole(data.user.role || null);
if (data.user.role === 'AGENT_VIEWER') {
router.push('/');
} else {
loadAgents();
}
} else {
setIsLoading(false);
}
})
.catch(err => {
console.error("Auth check failed:", err);
setIsLoading(false);
});
}, [router]);
const openCreate = () => { const openCreate = () => {
setFormData({ agentId: "", label: "" }); setFormData({ agentId: "", label: "" });
+10 -3
View File
@@ -40,9 +40,16 @@ export default function DevicesPage() {
header: "MAC Address", header: "MAC Address",
accessor: (row) => accessor: (row) =>
row.mac_address ? ( row.mac_address ? (
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground"> <div className="flex flex-col gap-1">
{row.mac_address} <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit">
</code> {row.mac_address}
</code>
{row.is_gateway_routed === 1 && (
<span className="text-[10px] text-amber-500/80 font-sans italic">
Via Routed Gateway
</span>
)}
</div>
) : ( ) : (
"-" "-"
), ),
+69 -2
View File
@@ -6,6 +6,48 @@ import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { fmtBytes } from "@/lib/utils"; import { fmtBytes } from "@/lib/utils";
import { IpDetails } from "@/components/ui/IpDetails";
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "MongoDB Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
const explanation = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
return {
main: label || `Port ${port}`,
sub: explanation
};
}
export default function FlowsPage() { export default function FlowsPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@@ -21,10 +63,35 @@ export default function FlowsPage() {
{ header: "#", accessor: (row, i) => i + 1 }, { header: "#", accessor: (row, i) => i + 1 },
{ header: "Flow ID", accessor: (row) => row.flow_id }, { header: "Flow ID", accessor: (row) => row.flow_id },
{ header: "Src IP", accessor: (row) => <span className="text-blue-400">{row.src_ip}</span> }, { header: "Src IP", accessor: (row) => <span className="text-blue-400">{row.src_ip}</span> },
{ header: "Dst IP", accessor: (row) => <span className="text-blue-400">{row.dst_ip}</span> }, {
header: "Dst IP",
accessor: (row) => (
<div className="flex flex-col">
<span className="font-mono text-blue-400">{row.dst_ip}</span>
<IpDetails ip={row.dst_ip} />
</div>
)
},
{ header: "Dst Port", accessor: (row) => row.dst_port }, { header: "Dst Port", accessor: (row) => row.dst_port },
{ header: "Protocol", accessor: (row) => row.protocol }, { header: "Protocol", accessor: (row) => row.protocol },
{ header: "App / Domain", accessor: (row) => row.app_label || row.domain || "-" }, {
header: "App / Domain",
accessor: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return (
<div className="flex flex-col">
<span className="font-semibold text-white truncate max-w-[200px]" title={exp.main}>
{exp.main}
</span>
{exp.sub && (
<span className="text-[10px] text-slate-400 italic">
{exp.sub}
</span>
)}
</div>
);
}
},
{ {
header: "Download", header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span> accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>
+30
View File
@@ -1,7 +1,14 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { Bell, RefreshCw } from "lucide-react"; import { Bell, RefreshCw } from "lucide-react";
const AGENT_LABELS: Record<string, string> = {
'2F-TF-1D-GK': 'JRP Cibubur',
'8A-V3-PB-85': 'IFG LT.18',
'F6-2V-DT-8A': 'CPI Balaraja',
};
export function Header({ export function Header({
lastUpdate = "—", lastUpdate = "—",
isRefreshing = false, isRefreshing = false,
@@ -11,6 +18,19 @@ export function Header({
isRefreshing?: boolean; isRefreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
}) { }) {
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
}
})
.catch(err => console.error(err));
}, []);
return ( return (
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border bg-background px-6"> <header className="flex h-16 shrink-0 items-center justify-between border-b border-border bg-background px-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -20,6 +40,16 @@ export function Header({
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{user?.role === 'AGENT_VIEWER' && (
<div className="flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-950/40 px-3 py-1 text-xs text-blue-300 shadow-[0_0_10px_rgba(59,130,246,0.1)]">
<span className="font-semibold">Agent:</span> {AGENT_LABELS[user.agent_uuid || ''] || user.agent_uuid} ({user.agent_uuid})
</div>
)}
{user?.role === 'SUPER_ADMIN' && (
<div className="flex items-center gap-2 rounded-full border border-red-500/30 bg-red-950/40 px-3 py-1 text-xs text-red-300 shadow-[0_0_10px_rgba(239,68,68,0.1)]">
<span className="font-semibold">Admin:</span> All Agents
</div>
)}
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Update: {lastUpdate} Update: {lastUpdate}
</div> </div>
+183 -2
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Activity, Activity,
@@ -17,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { Modal } from "@/components/ui/Modal";
const navigation = [ const navigation = [
{ name: "Summary", href: "/", icon: LayoutDashboard }, { name: "Summary", href: "/", icon: LayoutDashboard },
@@ -39,6 +41,36 @@ const navigation = [
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
// Change Password state
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [errorMsg, setErrorMsg] = useState("");
const [successMsg, setSuccessMsg] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
// Real-time checks for password strength validation
const isMinLength = newPassword.length >= 8;
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /[0-9]/.test(newPassword);
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(newPassword);
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && hasSpecial && passwordsMatch;
useEffect(() => {
fetch('/api/auth/me')
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user);
}
})
.catch(err => console.error(err));
}, []);
const handleLogout = async () => { const handleLogout = async () => {
try { try {
@@ -50,6 +82,46 @@ export function Sidebar() {
} }
}; };
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault();
if (!isPasswordValid) return;
setIsSubmitting(true);
setErrorMsg("");
setSuccessMsg("");
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = await res.json();
if (res.ok) {
setSuccessMsg("Password berhasil diubah!");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setTimeout(() => {
setIsChangePasswordOpen(false);
setSuccessMsg("");
}, 1500);
} else {
setErrorMsg(data.error || "Gagal mengubah password");
}
} catch (err) {
setErrorMsg("Terjadi kesalahan koneksi");
} finally {
setIsSubmitting(false);
}
};
const filteredNavigation = navigation.filter((item) => {
if (user?.role === 'AGENT_VIEWER' && item.name === 'Agents') {
return false;
}
return true;
});
return ( return (
<div className="flex h-full w-64 flex-col border-r border-border/50 bg-sidebar/80 backdrop-blur-2xl relative"> <div className="flex h-full w-64 flex-col border-r border-border/50 bg-sidebar/80 backdrop-blur-2xl relative">
{/* Subtle glow effect behind sidebar */} {/* Subtle glow effect behind sidebar */}
@@ -63,7 +135,7 @@ export function Sidebar() {
</div> </div>
<div className="flex flex-1 flex-col overflow-y-auto relative z-10"> <div className="flex flex-1 flex-col overflow-y-auto relative z-10">
<nav className="flex-1 space-y-1.5 px-3 py-6"> <nav className="flex-1 space-y-1.5 px-3 py-6">
{navigation.map((item) => { {filteredNavigation.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
<Link <Link
@@ -102,7 +174,21 @@ export function Sidebar() {
</nav> </nav>
</div> </div>
<div className="p-4 border-t border-border/50 relative z-10 shrink-0"> <div className="p-4 border-t border-border/50 relative z-10 shrink-0 space-y-1.5">
<button
onClick={() => {
setErrorMsg("");
setSuccessMsg("");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setIsChangePasswordOpen(true);
}}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-slate-300 hover:bg-secondary/50 hover:text-white transition-all duration-200"
>
<Lock className="mr-3 h-5 w-5" />
Change Password
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-all duration-200" className="flex w-full items-center rounded-lg px-3 py-2.5 text-sm font-medium text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-all duration-200"
@@ -111,6 +197,101 @@ export function Sidebar() {
Logout Logout
</button> </button>
</div> </div>
<Modal isOpen={isChangePasswordOpen} onClose={() => setIsChangePasswordOpen(false)} title="Ubah Password">
<form onSubmit={handlePasswordChange} className="space-y-4">
{errorMsg && (
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
{errorMsg}
</div>
)}
{successMsg && (
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
{successMsg}
</div>
)}
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-300">Password Saat Ini</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-300">Password Baru</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-white placeholder-slate-500 focus:border-primary focus:outline-none"
/>
</div>
{/* Real-time Checklist */}
<div className="rounded-lg bg-secondary/30 p-3.5 border border-border/50 space-y-2">
<span className="text-xs font-semibold text-slate-300">Syarat Kekuatan Password:</span>
<ul className="text-xs space-y-1.5">
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{isMinLength ? "✓" : "○"}</span> Minimal 8 karakter
</li>
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{hasUppercase ? "✓" : "○"}</span> Mengandung huruf besar (A-Z)
</li>
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{hasLowercase ? "✓" : "○"}</span> Mengandung huruf kecil (a-z)
</li>
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
</li>
<li className={cn("flex items-center transition-colors duration-200", hasSpecial ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{hasSpecial ? "✓" : "○"}</span> Mengandung karakter spesial (contoh: @$!%*?&)
</li>
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
<span className="mr-2">{passwordsMatch ? "✓" : "○"}</span> Password cocok
</li>
</ul>
</div>
<div className="flex justify-end space-x-2 pt-2">
<button
type="button"
onClick={() => setIsChangePasswordOpen(false)}
className="rounded-lg bg-secondary px-4 py-2 text-sm font-medium text-white hover:bg-secondary/80 transition-colors"
>
Batal
</button>
<button
type="submit"
disabled={!isPasswordValid || isSubmitting}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200",
isPasswordValid && !isSubmitting
? "bg-primary hover:bg-primary/90 shadow-primary/25 cursor-pointer"
: "bg-slate-700 text-slate-400 cursor-not-allowed"
)}
>
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
</button>
</div>
</form>
</Modal>
</div> </div>
); );
} }
+65 -29
View File
@@ -5,6 +5,7 @@ import {
fetchDeviceDetails, DeviceDetails, fetchDeviceDetails, DeviceDetails,
} from "@/lib/api"; } from "@/lib/api";
import { fmtBytes } from "@/lib/utils"; import { fmtBytes } from "@/lib/utils";
import { IpDetails } from "./IpDetails";
import { import {
Loader2, X, Monitor, Globe, Activity, Lock, Server, Loader2, X, Monitor, Globe, Activity, Lock, Server,
AlertTriangle, Eye, Shield, Bell, Wifi, Unlock, Router, AlertTriangle, Eye, Shield, Bell, Wifi, Unlock, Router,
@@ -21,6 +22,47 @@ interface Props {
type Tab = "info" | "flows" | "apps" | "encryption" | "servers" | "passwords" | "reputation" | "vpn_events"; type Tab = "info" | "flows" | "apps" | "encryption" | "servers" | "passwords" | "reputation" | "vpn_events";
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
const label = appLabel || domain || "";
const portStr = String(port);
const matches: Record<string, string> = {
"1433": "MSSQL Database Server",
"1434": "MSSQL Monitor Server",
"3306": "MySQL/MariaDB Database Server",
"5432": "PostgreSQL Database Server",
"1521": "Oracle Database Server",
"27017": "MongoDB Database Server",
"6379": "Redis Key-Value Cache",
"80": "Unencrypted Web Traffic (HTTP)",
"443": "Encrypted Web Traffic (HTTPS/TLS)",
"22": "SSH Remote Management / SFTP",
"21": "FTP File Upload/Download (Unencrypted)",
"23": "Telnet Command Shell (Insecure)",
"25": "SMTP Mail Delivery",
"587": "Secure SMTP Mail Delivery",
"110": "POP3 Mail Storage Retrieval",
"993": "Secure IMAP Mail Service",
"53": "DNS Server Query (Domain Lookup)",
"123": "NTP Network Clock Synchronizer",
"161": "SNMP Network Device Monitoring",
"3389": "RDP Remote Desktop Management",
"445": "SMB Windows File Sharing",
"137": "NetBIOS Name Service",
"138": "NetBIOS Datagram Service",
"139": "NetBIOS Session Service",
"1812": "RADIUS Authentication Server",
"1813": "RADIUS Accounting Server"
};
const explanation = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
return {
main: label || `Port ${port}`,
sub: explanation
};
}
function sevStyle(sev: string | null) { function sevStyle(sev: string | null) {
switch ((sev || "").toLowerCase()) { switch ((sev || "").toLowerCase()) {
case "critical": return "bg-red-500/15 text-red-400 ring-1 ring-red-500/30"; case "critical": return "bg-red-500/15 text-red-400 ring-1 ring-red-500/30";
@@ -264,36 +306,30 @@ export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Prop
{activeTab === "flows" && ( {activeTab === "flows" && (
data.flows.length === 0 ? <Empty msg="Tidak ada flows untuk device ini." /> : ( data.flows.length === 0 ? <Empty msg="Tidak ada flows untuk device ini." /> : (
<TableWrap head={["Destination IP", "Port", "Domain / App", "Proto", "↓", "↑", "Last Seen"]}> <TableWrap head={["Destination IP", "Port", "Domain / App", "Proto", "↓", "↑", "Last Seen"]}>
{data.flows.map((f, i) => ( {data.flows.map((f, i) => {
<tr key={i} className="hover:bg-white/[0.03] transition-colors"> const exp = explainAppOrPort(f.app_label, f.domain, f.dst_port || 0);
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.dst_ip || "—"}</td> return (
<td className="px-3 py-2 text-xs text-slate-400">{f.dst_port ?? "—"}</td> <tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="px-3 py-2 max-w-[180px]"> <td className="px-3 py-2 whitespace-nowrap">
{f.domain ? ( <span className="font-mono text-xs text-blue-400 block">{f.dst_ip || "—"}</span>
/* Domain name takes priority — shown in teal monospace */ {f.dst_ip && <IpDetails ip={f.dst_ip} />}
<span className="text-xs text-teal-300 font-mono truncate block" title={f.domain}> </td>
{f.domain} <td className="px-3 py-2 text-xs text-slate-400">{f.dst_port ?? "—"}</td>
<td className="px-3 py-2 max-w-[180px]">
<span className="text-xs text-teal-300 font-mono truncate block" title={exp.main}>
{exp.main}
</span> </span>
) : f.app_label && !f.app_label.startsWith('Port ') ? ( {exp.sub && (
/* Named protocol — purple */ <span className="text-[10px] text-slate-500 block italic">{exp.sub}</span>
<span className="text-xs text-purple-300 truncate block">{f.app_label}</span> )}
) : f.app_label ? ( </td>
/* Port-only entry — muted */ <td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
<span className="text-xs text-slate-500 truncate block">{f.app_label}</span> <td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
) : ( <td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
<span className="text-xs text-slate-600">—</span> <td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
)} </tr>
{/* Show app_label as secondary if domain is set */} );
{f.domain && f.app_label && ( })}
<span className="text-[10px] text-slate-500 block">{f.app_label}</span>
)}
</td>
<td className="px-3 py-2 text-xs text-slate-400">{f.protocol || "—"}</td>
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
<td className="px-3 py-2 text-xs text-slate-500 whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
</tr>
))}
</TableWrap> </TableWrap>
) )
)} )}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useState, useEffect } from "react";
// Client-side cache to prevent duplicate fetches for the same IP
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
export function IpDetails({ ip }: { ip: string }) {
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
useEffect(() => {
if (!ip) return;
// Check memory cache first
if (geoIpCache[ip]) {
setDetails(geoIpCache[ip]);
return;
}
let isMounted = true;
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
.then((res) => res.json())
.then((data) => {
if (!isMounted) return;
const resolved = {
isp: data.isp || "Unknown ISP",
country: data.country || "Remote",
as_org: data.as_org || "Public Network",
};
geoIpCache[ip] = resolved;
setDetails(resolved);
})
.catch((err) => {
console.error("GeoIP lookup client error:", err);
if (isMounted) {
setDetails({
isp: "Public IP",
country: "Remote",
as_org: "Public Network",
});
}
});
return () => {
isMounted = false;
};
}, [ip]);
if (!details) {
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
}
const isLocal = details.country === "Local";
return (
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate">
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
{details.isp}
</span>
{!isLocal && (
<span className="text-slate-400 font-normal">
{" • "}{details.country}
</span>
)}
</div>
);
}