Add Gateway device inventory panel
This commit is contained in:
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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,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());
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user