Show device identity details and resolve hostnames
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 12:39:27 +03:00
parent 501c498edf
commit 3566f4bc0b
10 changed files with 256 additions and 82 deletions
+62 -2
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { lookupService } from 'node:dns/promises';
import fs from 'node:fs';
import net from 'node:net';
import { HarborError } from '../../shared/errors.js';
@@ -163,6 +164,9 @@ const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const TRAFFIC_HISTORY_LIMIT = 120;
const HOSTNAME_LOOKUP_LIMIT = 8;
const HOSTNAME_LOOKUP_TIMEOUT_MS = 800;
const HOSTNAME_RETRY_MS = 5 * 60 * 1000;
const COUNTER_PATTERN = /^\d+$/;
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
@@ -247,6 +251,31 @@ const validTimestamp = (value: unknown): value is string => (
typeof value === 'string' && Number.isFinite(Date.parse(value))
);
function normalizeHostname(value: unknown, ip: string) {
const hostname = typeof value === 'string' ? value.trim().replace(/\.$/, '') : '';
return hostname && hostname !== ip && /^[a-z0-9_](?:[a-z0-9_.-]{0,251}[a-z0-9_])?$/i.test(hostname)
? hostname
: null;
}
async function resolveDeviceHostname(ip: string) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
lookupService(ip, 0),
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), HOSTNAME_LOOKUP_TIMEOUT_MS);
timer.unref();
}),
]);
return normalizeHostname(result?.hostname, ip);
} catch {
return null;
} finally {
if (timer) clearTimeout(timer);
}
}
function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
const device = record(value);
const mac = normalizeMac(device.mac);
@@ -267,7 +296,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
alias: typeof device.alias === 'string' ? device.alias : '',
pinned: device.pinned === true,
deprioritized: device.deprioritized === true && device.pinned !== true,
hostname: typeof device.hostname === 'string' ? device.hostname : null,
hostname: normalizeHostname(device.hostname, ip),
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
mac,
ip,
@@ -643,6 +672,7 @@ export function createDeviceInventoryService({
observePolicy = null,
applyPolicies = null,
vendor = () => null,
resolveHostname = resolveDeviceHostname,
now = () => new Date(),
}: {
store: InventoryStore;
@@ -652,6 +682,7 @@ export function createDeviceInventoryService({
observePolicy?: (() => unknown | Promise<unknown>) | null;
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
vendor?: (mac: string) => string | null;
resolveHostname?: (ip: string) => unknown | Promise<unknown>;
now?: () => Date;
}) {
let refreshPromise: Promise<unknown> | null = null;
@@ -660,6 +691,7 @@ export function createDeviceInventoryService({
const trafficCursorByMac = new Map<string, TrafficCursor>();
let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>();
let domainTrafficSnapshot: Record<string, unknown> = {
epoch: null,
observedAt: null,
@@ -995,6 +1027,34 @@ export function createDeviceInventoryService({
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
identitiesByMac.set(mac, identities);
}
const previousByMac = new Map(migrateDeviceInventoryState(store.read()).devices
.map((device) => [device.mac, device]));
const hostnameCandidates = new Map<string, DeviceObservation>();
const hostnameKeys = new Set<string>();
const refreshTime = new Date(observedAt).getTime();
for (const observation of observations) {
const key = `${observation.mac}|${observation.ip}`;
hostnameKeys.add(key);
const previous = previousByMac.get(observation.mac);
if (!observation.active || (identitiesByMac.get(observation.mac)?.size || 0) !== 1
|| (previous?.hostname && previous.ip === observation.ip)
|| refreshTime - (hostnameAttempts.get(key) || 0) < HOSTNAME_RETRY_MS) continue;
hostnameCandidates.set(observation.mac, observation);
}
for (const key of hostnameAttempts.keys()) {
if (!hostnameKeys.has(key)) hostnameAttempts.delete(key);
}
// ponytail: resolve eight names per poll; add a queue only if large LANs need faster first-pass naming.
const hostnameByMac = new Map<string, string>();
await Promise.all([...hostnameCandidates].slice(0, HOSTNAME_LOOKUP_LIMIT).map(async ([mac, observation]) => {
hostnameAttempts.set(`${mac}|${observation.ip}`, refreshTime);
try {
const hostname = normalizeHostname(await resolveHostname(observation.ip), observation.ip);
if (hostname) hostnameByMac.set(mac, hostname);
} catch {
// Reverse lookup is best-effort and must not make inventory refresh stale.
}
}));
return serializePolicy(async () => {
if (typeof domainTrafficResult?.transportError === 'string') {
domainTrafficSnapshot = {
@@ -1021,7 +1081,7 @@ export function createDeviceInventoryService({
alias: previous?.alias || '',
pinned: previous?.pinned === true,
deprioritized: previous?.deprioritized === true && previous?.pinned !== true,
hostname: previous?.hostname || null,
hostname: hostnameByMac.get(mac) || previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac),
mac,
ip: replaceAddress ? observation.ip : previous.ip,