Add Gateway device inventory panel
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-06 10:17:33 +03:00
parent fdc6f687f3
commit 158aaadd23
18 changed files with 811 additions and 5 deletions
+50
View File
@@ -0,0 +1,50 @@
import { spawnSync } from 'node:child_process';
const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']);
const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const states = (Array.isArray(entry?.state) ? entry.state : [entry?.state])
.filter(Boolean)
.map((state) => String(state).toUpperCase());
const mac = String(entry?.lladdr || '').toLowerCase();
if (!entry?.dst || !entry?.dev || !MAC_PATTERN.test(mac) || states.some((state) => IGNORED_STATES.has(state))) {
return [];
}
return [{
ip: String(entry.dst),
mac,
interface: String(entry.dev),
active: states.some((state) => ACTIVE_STATES.has(state)),
observedAt,
source: 'neighbor',
}];
});
}
export function readNeighborSnapshot(run = spawnSync, now = () => new Date()) {
const observedAt = now().toISOString();
const result = run('ip', ['-j', 'neigh', 'show'], {
encoding: 'utf8',
timeout: 1500,
});
if (result.error || result.status !== 0) {
return {
observedAt,
observations: [],
error: result.error?.message || String(result.stderr || 'ip neigh завершился с ошибкой').trim(),
};
}
try {
return {
observedAt,
observations: parseNeighborSnapshot(JSON.parse(result.stdout || '[]'), observedAt),
error: null,
};
} catch (error) {
return { observedAt, observations: [], error: `ip neigh вернул невалидный JSON: ${error.message}` };
}
}
+1
View File
@@ -24,6 +24,7 @@ export const settings = {
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
statePath: path.join(dataDir, "state.json"),
deviceStatePath: path.join(dataDir, "devices.json"),
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
hostNetworkStatePath:
+4
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js';
import { readNeighborSnapshot } from './adapters/neighbors.js';
const socketPath = settings.dataplaneSocket;
const runtime = createSingboxRuntime({
@@ -29,6 +30,9 @@ const server = http.createServer(async (req, res) => {
ready,
});
}
if (req.method === 'GET' && req.url === '/devices') {
return sendJson(res, 200, readNeighborSnapshot());
}
if (req.method === 'POST' && req.url === '/apply') {
return sendJson(res, 200, await runtime.apply());
}
+1
View File
@@ -43,6 +43,7 @@ export function createDataplaneClient(socketPath, send = request) {
get running() { return Boolean(current.running); },
get startedAt() { return current.startedAt || null; },
refresh: () => update('/status', 'GET'),
observeDevices: () => send(socketPath, '/devices', 'GET'),
apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'),
stop: () => update('/stop', 'POST'),
+40
View File
@@ -4,6 +4,7 @@ import http from 'node:http';
import path from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { createDataplaneClient } from './dataplaneClient.js';
import { readNeighborSnapshot } from './adapters/neighbors.js';
import { settings } from './config.js';
import {
applyGatewayPreference,
@@ -38,11 +39,13 @@ import {
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { createDeviceInventoryService, createVendorLookup } from './services/deviceInventoryService.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
const DEVICE_DISCOVERY_INTERVAL_MS = 60_000;
const TERMINAL_SUBSCRIPTION_CODES = new Set([
'SUBSCRIPTION_EXPIRED',
'SUBSCRIPTION_DISABLED',
@@ -56,6 +59,10 @@ const subscriptionCacheStore = createJsonStore({
filePath: settings.subscriptionCachePath,
defaultValue: null,
});
const deviceStore = createJsonStore({
filePath: settings.deviceStatePath,
defaultValue: {},
});
let cacheRecoveryLogged = false;
function readSubscriptionCache() {
@@ -86,10 +93,20 @@ const singboxRuntime = remoteDataplane
gateway: settings.appMode === 'gateway',
tproxyChain: settings.tproxyChain,
});
const deviceInventory = settings.appMode === 'gateway'
? createDeviceInventoryService({
store: deviceStore,
observe: remoteDataplane
? () => singboxRuntime.observeDevices()
: () => readNeighborSnapshot(),
vendor: createVendorLookup(),
})
: null;
let subscriptionRefreshPromise = null;
let subscriptionRefreshTimer = null;
let gatewayDiscoveryPromise = null;
let gatewayDiscoveryTimer = null;
let deviceDiscoveryTimer = null;
let gatewayAutoState = createGatewayAutoState();
let controlOperation = Promise.resolve();
let operationState = stateStore.recovery ? {
@@ -595,6 +612,18 @@ async function handleApi(req, res) {
}
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
if (requestUrl.pathname === '/api/devices') {
if (!deviceInventory || req.method !== 'GET') throw new HarborError('ENDPOINT_NOT_FOUND');
return sendJson(res, 200, deviceInventory.snapshot());
}
const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/);
if (deviceMatch && req.method === 'PUT') {
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
const body = await readBody(req);
return sendJson(res, 200, deviceInventory.update(deviceMatch[1], body, body.expectedRevision));
}
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
const state = stateStore.read();
return sendJson(res, 200, buildGatewayPresence({
@@ -768,6 +797,7 @@ const server = http.createServer(async (req, res) => {
async function shutdown() {
clearInterval(subscriptionRefreshTimer);
clearInterval(gatewayDiscoveryTimer);
clearInterval(deviceDiscoveryTimer);
await serializeControl(() => singboxRuntime.shutdown());
process.exit(0);
}
@@ -810,3 +840,13 @@ gatewayDiscoveryTimer = setInterval(() => {
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
}, GATEWAY_DISCOVERY_INTERVAL_MS);
gatewayDiscoveryTimer.unref();
if (deviceInventory) {
deviceInventory.refresh()
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
deviceDiscoveryTimer = setInterval(() => {
deviceInventory.refresh()
.catch((error) => console.warn(`[control] device inventory не обновлён: ${error.message}`));
}, DEVICE_DISCOVERY_INTERVAL_MS);
deviceDiscoveryTimer.unref();
}
@@ -0,0 +1,170 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import { HarborError } from '../../shared/errors.js';
const SCHEMA_VERSION = 1;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_STATE = {
schemaVersion: SCHEMA_VERSION,
revision: 0,
lastObservedAt: null,
lastError: null,
devices: [],
};
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
export function parseOuiVendors(text) {
const vendors = new Map();
for (const line of String(text || '').split(/\r?\n/)) {
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
}
return vendors;
}
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
let vendors;
return (mac) => {
if (!mac || isPrivateMac(mac)) return null;
if (!vendors) {
try {
vendors = parseOuiVendors(fs.readFileSync(filePath, 'utf8'));
} catch {
vendors = new Map();
}
}
return vendors.get(mac.replaceAll(':', '').slice(0, 6)) || null;
};
}
function migrate(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
if (version < 0 || version > SCHEMA_VERSION) {
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
}
return {
...DEFAULT_STATE,
...state,
schemaVersion: SCHEMA_VERSION,
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
devices: Array.isArray(state.devices) ? state.devices : [],
};
}
function deviceStatus(lastSeenAt, now) {
const age = now.getTime() - new Date(lastSeenAt).getTime();
if (age <= ONLINE_MS) return 'online';
if (age <= RECENT_MS) return 'recent';
return 'offline';
}
export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) {
function snapshot() {
const state = migrate(store.read());
const current = now();
const rank = { online: 0, recent: 1, offline: 2 };
const devices = state.devices.map((device) => ({
...device,
status: deviceStatus(device.lastSeenAt, current),
})).sort((left, right) => (
Number(right.pinned) - Number(left.pinned)
|| rank[left.status] - rank[right.status]
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
));
return {
revision: state.revision,
source: {
kind: 'neighbor',
lastObservedAt: state.lastObservedAt,
error: state.lastError,
},
devices,
};
}
async function refresh() {
let result;
try {
result = await observe();
} catch (error) {
result = { observedAt: now().toISOString(), observations: [], error: error.message || String(error) };
}
const observedAt = result?.observedAt || now().toISOString();
const observations = Array.isArray(result?.observations) ? result.observations : [];
store.update((stored) => {
const state = migrate(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac) continue;
const previous = byMac.get(mac);
const lastSeenAt = observation.active || !previous
? observation.observedAt || observedAt
: previous.lastSeenAt;
byMac.set(mac, {
id: previous?.id || deviceId(mac),
alias: previous?.alias || '',
pinned: previous?.pinned === true,
hostname: previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac),
mac,
ip: String(observation.ip || previous?.ip || ''),
interface: String(observation.interface || previous?.interface || ''),
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
lastSeenAt,
source: 'neighbor',
confidence: isPrivateMac(mac) ? 'medium' : 'high',
});
}
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const devices = [...byMac.values()].filter((device) => (
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
));
return {
...state,
revision: state.revision + 1,
lastObservedAt: observedAt,
lastError: result?.error || null,
devices,
};
});
return snapshot();
}
function update(id, patch, expectedRevision) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new HarborError('REQUEST_INVALID');
}
const aliasProvided = Object.hasOwn(patch, 'alias');
const pinProvided = Object.hasOwn(patch, 'pinned');
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|| (!aliasProvided && !pinProvided)
|| (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64))
|| (pinProvided && typeof patch.pinned !== 'boolean')) {
throw new HarborError('REQUEST_INVALID');
}
store.update((stored) => {
const state = migrate(stored);
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const devices = [...state.devices];
devices[index] = {
...devices[index],
...(aliasProvided ? { alias: patch.alias.trim() } : {}),
...(pinProvided ? { pinned: patch.pinned } : {}),
};
return { ...state, revision: state.revision + 1, devices };
});
return snapshot();
}
return { snapshot, refresh, update };
}