Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
+24
-2
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
const appMode = process.env.APP_MODE === "client" ? "client" : "gateway";
|
||||
const appComponent = process.env.APP_COMPONENT || "";
|
||||
const dataDir = process.env.DATA_DIR || path.resolve(".vpn-proxy");
|
||||
const parsePort = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number.parseInt(value || '', 10);
|
||||
@@ -7,17 +9,33 @@ const parsePort = (value: string | undefined, fallback: number) => {
|
||||
};
|
||||
const proxyPort = parsePort(
|
||||
process.env.PROXY_PORT,
|
||||
process.env.APP_MODE === "client" ? 8082 : 8080,
|
||||
appMode === "client" ? 8082 : 8080,
|
||||
);
|
||||
const trafficSource = process.env.SING_BOX_TRAFFIC_SOURCE
|
||||
|| (appMode === "client" ? "native" : "snapshot");
|
||||
if (appMode === "client" && trafficSource !== "native" && trafficSource !== "disabled") {
|
||||
throw new Error("SING_BOX_TRAFFIC_SOURCE must be native or disabled in client mode");
|
||||
}
|
||||
if (appMode === "gateway" && !["snapshot", "shadow", "native"].includes(trafficSource)) {
|
||||
throw new Error("SING_BOX_TRAFFIC_SOURCE must be snapshot, shadow or native in gateway mode");
|
||||
}
|
||||
if (appMode === "gateway" && trafficSource !== "snapshot"
|
||||
&& ((appComponent !== "control" && appComponent !== "dataplane")
|
||||
|| !process.env.DATAPLANE_SOCKET?.trim())) {
|
||||
throw new Error("Gateway shadow and native traffic modes require split control/dataplane topology");
|
||||
}
|
||||
|
||||
export const settings = {
|
||||
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
|
||||
appMode,
|
||||
appComponent,
|
||||
port: parsePort(process.env.PORT, 3456),
|
||||
proxyPort,
|
||||
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
|
||||
failoverPrimaryProxyPort: parsePort(process.env.FAILOVER_PRIMARY_PROXY_PORT, 18081),
|
||||
failoverReserveProxyPort: parsePort(process.env.FAILOVER_RESERVE_PROXY_PORT, 18082),
|
||||
singboxApiPort: parsePort(process.env.SING_BOX_API_PORT, 19090),
|
||||
singboxNativeApiPort: 19091,
|
||||
singboxTrafficSource: trafficSource as "native" | "disabled" | "snapshot" | "shadow",
|
||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||
@@ -40,6 +58,10 @@ export const settings = {
|
||||
configPath:
|
||||
process.env.SING_BOX_CONFIG || path.join(dataDir, "sing-box-config.json"),
|
||||
cachePath: process.env.SING_BOX_CACHE || "/var/lib/sing-box/cache.db",
|
||||
gatewayNativeApiSecretPath:
|
||||
process.env.SING_BOX_API_SECRET || "/var/lib/sing-box/api.secret",
|
||||
gatewayRuntimeConfigPath:
|
||||
process.env.SING_BOX_RUNTIME_CONFIG || "/var/lib/sing-box/runtime-config.json",
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
deviceStatePath: path.join(dataDir, "devices.json"),
|
||||
activityJournalPath: path.join(dataDir, "activity-journal.json"),
|
||||
|
||||
+196
-15
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { LiveTrafficConnection, LiveTrafficSnapshot } from '../shared/liveTraffic.js';
|
||||
import { settings } from './config.js';
|
||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
||||
import { buildVersionInfo } from './version.js';
|
||||
@@ -13,13 +15,27 @@ import {
|
||||
createDomainTrafficService,
|
||||
readSingboxConnections,
|
||||
} from './services/domainTrafficService.js';
|
||||
import { deviceId } from './services/deviceInventoryService.js';
|
||||
import {
|
||||
createLiveTrafficService,
|
||||
} from './services/liveTrafficService.js';
|
||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||
|
||||
const socketPath = settings.dataplaneSocket;
|
||||
const trafficMode = settings.singboxTrafficSource as 'snapshot' | 'shadow' | 'native';
|
||||
const nativeTrafficEnabled = trafficMode === 'shadow' || trafficMode === 'native';
|
||||
const runtime = createSingboxRuntime({
|
||||
configPath: settings.configPath,
|
||||
gateway: true,
|
||||
tproxyChain: settings.tproxyChain,
|
||||
gatewayRuntimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||
...(nativeTrafficEnabled ? {
|
||||
nativeApi: {
|
||||
apiPort: settings.singboxNativeApiPort,
|
||||
secretPath: settings.gatewayNativeApiSecretPath,
|
||||
runtimeConfigPath: settings.gatewayRuntimeConfigPath,
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
const versionInfo = buildVersionInfo('gateway');
|
||||
const traffic = createDeviceTrafficService({
|
||||
@@ -46,10 +62,23 @@ const failoverDiagnostics = {
|
||||
reserve: createConnectivityDiagnosticsService({ proxyPort: settings.failoverReserveProxyPort }),
|
||||
};
|
||||
const selector = createSingboxSelectorService({ port: settings.singboxApiPort });
|
||||
const domainTraffic = createDomainTrafficService({
|
||||
const snapshotDomainTraffic = createDomainTrafficService({
|
||||
observe: () => readSingboxConnections(settings.singboxApiPort),
|
||||
devices: () => traffic.snapshot().devices,
|
||||
});
|
||||
const nativeDomainTraffic = createDomainTrafficService({
|
||||
observe: () => ({ connections: [] }),
|
||||
devices: () => traffic.snapshot().devices,
|
||||
});
|
||||
const domainTraffic = trafficMode === 'native' ? nativeDomainTraffic : snapshotDomainTraffic;
|
||||
let originsByIp = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||
let liveTraffic = createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: false,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => false,
|
||||
resolveOrigin,
|
||||
});
|
||||
let ready = false;
|
||||
let trafficTimer: NodeJS.Timeout | null = null;
|
||||
let domainTrafficTimer: NodeJS.Timeout | null = null;
|
||||
@@ -65,6 +94,133 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function updateOrigins(devices: unknown) {
|
||||
const next = new Map<string, LiveTrafficConnection['origin'] | null>();
|
||||
for (const value of Array.isArray(devices) ? devices : []) {
|
||||
const device = record(value);
|
||||
const ip = String(device.ip || '');
|
||||
const mac = String(device.mac || '').toLowerCase();
|
||||
if (!net.isIPv4(ip) || !/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/.test(mac)) continue;
|
||||
const origin: LiveTrafficConnection['origin'] = {
|
||||
kind: 'device',
|
||||
id: deviceId(mac),
|
||||
label: ip,
|
||||
provenance: 'source-ip',
|
||||
};
|
||||
next.set(ip, next.has(ip) ? null : origin);
|
||||
}
|
||||
originsByIp = next;
|
||||
}
|
||||
|
||||
function resolveOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||
return originsByIp.get(sourceIp) || {
|
||||
kind: 'unknown',
|
||||
id: null,
|
||||
label: 'Неизвестное устройство',
|
||||
provenance: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshDeviceTraffic() {
|
||||
try {
|
||||
return await traffic.refresh();
|
||||
} finally {
|
||||
refreshOrigins();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOrigins() {
|
||||
updateOrigins(readNeighborSnapshot().observations);
|
||||
}
|
||||
|
||||
function decimal(value: unknown) {
|
||||
return typeof value === 'string' && /^\d+$/.test(value) ? BigInt(value) : 0n;
|
||||
}
|
||||
|
||||
function trackedTotals(snapshot: unknown) {
|
||||
let upload = 0n;
|
||||
let download = 0n;
|
||||
for (const value of Array.isArray(record(snapshot).tracked) ? record(snapshot).tracked as unknown[] : []) {
|
||||
const entry = record(value);
|
||||
upload += decimal(entry.uploadBytes);
|
||||
download += decimal(entry.downloadBytes);
|
||||
}
|
||||
return { upload, download };
|
||||
}
|
||||
|
||||
function mismatchCount(left: unknown, right: unknown, fields: string[]) {
|
||||
const entries = (value: unknown) => {
|
||||
const values = Array.isArray(value) ? value : [];
|
||||
return new Map(values.map((item) => {
|
||||
const entry = record(item);
|
||||
const key = fields.map((field) => String(entry[field] || '')).join('\0');
|
||||
return [key, `${entry.uploadBytes || '0'}\0${entry.downloadBytes || '0'}`];
|
||||
}));
|
||||
};
|
||||
const leftEntries = entries(left);
|
||||
const rightEntries = entries(right);
|
||||
const keys = new Set([...leftEntries.keys(), ...rightEntries.keys()]);
|
||||
let mismatches = 0;
|
||||
for (const key of keys) if (leftEntries.get(key) !== rightEntries.get(key)) mismatches += 1;
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function liveTrafficSnapshot(): LiveTrafficSnapshot {
|
||||
const snapshot = liveTraffic.snapshot();
|
||||
return nativeTrafficEnabled && runtime.nativeApiWarning ? {
|
||||
...snapshot,
|
||||
source: {
|
||||
...snapshot.source,
|
||||
state: 'incompatible',
|
||||
error: runtime.nativeApiWarning,
|
||||
},
|
||||
} : snapshot;
|
||||
}
|
||||
|
||||
function trafficCollectorSource() {
|
||||
const canonical = domainTraffic.snapshot();
|
||||
const canonicalSource = record(canonical.source);
|
||||
const nativeLive = nativeTrafficEnabled ? liveTrafficSnapshot() : null;
|
||||
const nativeProjection = nativeDomainTraffic.snapshot();
|
||||
const legacyProjection = snapshotDomainTraffic.snapshot();
|
||||
let shadow = null;
|
||||
if (trafficMode === 'shadow') {
|
||||
const nativeTotals = trackedTotals(nativeProjection);
|
||||
const legacyTotals = trackedTotals(legacyProjection);
|
||||
shadow = {
|
||||
activeDifference: Number(record(nativeProjection.source).activeConnections || 0)
|
||||
- Number(record(legacyProjection.source).activeConnections || 0),
|
||||
uploadDifferenceBytes: (nativeTotals.upload - legacyTotals.upload).toString(),
|
||||
downloadDifferenceBytes: (nativeTotals.download - legacyTotals.download).toString(),
|
||||
routeMismatches: mismatchCount(nativeProjection.tracked, legacyProjection.tracked, ['source', 'outbound']),
|
||||
deviceMismatches: mismatchCount(nativeProjection.routes, legacyProjection.routes, ['deviceId', 'source', 'outbound']),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: runtime.nativeApiWarning
|
||||
|| (trafficMode === 'native' ? nativeLive?.source.error : canonicalSource.error)
|
||||
|| null,
|
||||
mode: trafficMode,
|
||||
writer: trafficMode === 'native' ? 'native' as const : 'snapshot' as const,
|
||||
activeConnections: Number(canonicalSource.activeConnections || 0),
|
||||
native: nativeLive ? {
|
||||
state: nativeLive.source.state,
|
||||
epoch: nativeLive.epoch,
|
||||
sequence: nativeLive.sequence,
|
||||
observedAt: nativeLive.observedAt,
|
||||
active: nativeLive.summary.active,
|
||||
unattributedUploadBytes: nativeLive.source.unattributedUploadBytes,
|
||||
unattributedDownloadBytes: nativeLive.source.unattributedDownloadBytes,
|
||||
} : null,
|
||||
shadow,
|
||||
};
|
||||
}
|
||||
|
||||
function domainTrafficSnapshot() {
|
||||
const snapshot = domainTraffic.snapshot();
|
||||
return { ...snapshot, source: trafficCollectorSource() };
|
||||
}
|
||||
|
||||
function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -105,6 +261,7 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||
singBoxVersion: versionInfo.runtime.singBox,
|
||||
devicePolicy: devicePolicy.snapshot(),
|
||||
trafficCollector: trafficCollectorSource(),
|
||||
ready,
|
||||
});
|
||||
}
|
||||
@@ -115,7 +272,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
return sendJson(res, 200, traffic.snapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/domain-traffic') {
|
||||
return sendJson(res, 200, domainTraffic.snapshot());
|
||||
return sendJson(res, 200, domainTrafficSnapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/traffic/live') {
|
||||
return sendJson(res, 200, liveTrafficSnapshot());
|
||||
}
|
||||
if (req.method === 'GET' && req.url === '/device-policy') {
|
||||
return sendJson(res, 200, devicePolicy.snapshot());
|
||||
@@ -153,7 +313,10 @@ const server = http.createServer(async (req: IncomingMessage, res: ServerRespons
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/failover/activity/read') {
|
||||
const { thresholdBytesPerSecond = 0 } = record(await readJson(req));
|
||||
return sendJson(res, 200, { activity: domainTraffic.activitySnapshot(thresholdBytesPerSecond) });
|
||||
const sourceLive = trafficMode !== 'native' || liveTrafficSnapshot().source.state === 'live';
|
||||
return sendJson(res, 200, {
|
||||
activity: sourceLive ? domainTraffic.activitySnapshot(thresholdBytesPerSecond) : null,
|
||||
});
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/config/check') {
|
||||
const { config } = record(await readJson(req));
|
||||
@@ -183,27 +346,44 @@ server.listen(socketPath, async () => {
|
||||
} catch (error) {
|
||||
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
refreshOrigins();
|
||||
liveTraffic = createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: nativeTrafficEnabled,
|
||||
gateway: true,
|
||||
isRuntimeRunning: () => runtime.running && !runtime.nativeApiWarning,
|
||||
resolveOrigin,
|
||||
authorization: () => runtime.nativeApiSecret,
|
||||
onProjection: (batch) => {
|
||||
nativeDomainTraffic.ingestNative(batch);
|
||||
},
|
||||
});
|
||||
liveTraffic.start();
|
||||
ready = true;
|
||||
if (settings.deviceTrafficAccountingEnabled) {
|
||||
setImmediate(() => {
|
||||
traffic.refresh()
|
||||
refreshDeviceTraffic()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
|
||||
});
|
||||
trafficTimer = setInterval(() => {
|
||||
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||
refreshDeviceTraffic().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
|
||||
}, 15_000);
|
||||
trafficTimer.unref();
|
||||
} else {
|
||||
trafficTimer = setInterval(refreshOrigins, 15_000);
|
||||
trafficTimer.unref();
|
||||
}
|
||||
if (trafficMode !== 'native') {
|
||||
setImmediate(() => {
|
||||
snapshotDomainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||
});
|
||||
domainTrafficTimer = setInterval(() => {
|
||||
snapshotDomainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||
}, 2_000);
|
||||
domainTrafficTimer.unref();
|
||||
}
|
||||
setImmediate(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
|
||||
});
|
||||
// ponytail: snapshots can miss connections shorter than 2s; switch to an upstream close-event API if sing-box adds one.
|
||||
domainTrafficTimer = setInterval(() => {
|
||||
domainTraffic.refresh()
|
||||
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не обновлён: ${errorMessage(error)}`));
|
||||
}, 2_000);
|
||||
domainTrafficTimer.unref();
|
||||
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||
}
|
||||
});
|
||||
@@ -215,6 +395,7 @@ async function shutdown() {
|
||||
ready = false;
|
||||
if (trafficTimer) clearInterval(trafficTimer);
|
||||
if (domainTrafficTimer) clearInterval(domainTrafficTimer);
|
||||
await liveTraffic.stop();
|
||||
await runtime.shutdown();
|
||||
server.close(() => {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
|
||||
@@ -73,6 +73,7 @@ export function createDataplaneClient(socketPath: string, send: SendDataplaneReq
|
||||
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||
observeDomainTraffic: () => send(socketPath, '/domain-traffic', 'GET'),
|
||||
observeLiveTraffic: () => send(socketPath, '/traffic/live', 'GET'),
|
||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||
applyDevicePolicies: (devices: unknown) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||
runConnectivityDiagnostics: async (services: unknown = [], target: unknown = null) => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
interface MaterializeOptions {
|
||||
apiPort: number;
|
||||
secretPath: string;
|
||||
runtimeConfigPath: string;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function message(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function privateWrite(filePath: string, value: unknown) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
const temporaryPath = `${filePath}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`;
|
||||
let descriptor: number | null = null;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, JSON.stringify(value));
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = null;
|
||||
fs.renameSync(temporaryPath, filePath);
|
||||
fs.chmodSync(filePath, 0o600);
|
||||
const status = fs.lstatSync(filePath);
|
||||
if (!status.isFile() || status.isSymbolicLink() || (status.mode & 0o777) !== 0o600) {
|
||||
throw new Error('private runtime config is not a regular 0600 file');
|
||||
}
|
||||
} finally {
|
||||
if (descriptor !== null) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openSecret(secretPath: string) {
|
||||
const readFlags = fs.constants.O_RDWR | fs.constants.O_NOFOLLOW;
|
||||
try {
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
descriptor: fs.openSync(
|
||||
secretPath,
|
||||
readFlags | fs.constants.O_CREAT | fs.constants.O_EXCL,
|
||||
0o600,
|
||||
),
|
||||
created: true,
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||
return { descriptor: fs.openSync(secretPath, readFlags), created: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureGatewayNativeApiSecret(secretPath: string) {
|
||||
fs.mkdirSync(path.dirname(secretPath), { recursive: true, mode: 0o700 });
|
||||
const { descriptor, created } = openSecret(secretPath);
|
||||
try {
|
||||
const secret = created
|
||||
? crypto.randomBytes(32).toString('hex')
|
||||
: fs.readFileSync(descriptor, 'utf8');
|
||||
if (created) {
|
||||
fs.writeFileSync(descriptor, secret);
|
||||
fs.fsyncSync(descriptor);
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(secret)) {
|
||||
throw new Error('native API secret must contain exactly 64 lowercase hex characters');
|
||||
}
|
||||
fs.fchmodSync(descriptor, 0o600);
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
const linked = fs.lstatSync(secretPath);
|
||||
if (!opened.isFile() || linked.isSymbolicLink() || !linked.isFile()
|
||||
|| opened.dev !== linked.dev || opened.ino !== linked.ino
|
||||
|| (opened.mode & 0o777) !== 0o600 || (linked.mode & 0o777) !== 0o600) {
|
||||
throw new Error('native API secret is not a regular 0600 file');
|
||||
}
|
||||
return secret;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function withoutApiServices(config: unknown) {
|
||||
const safe = structuredClone(record(config));
|
||||
const services = Array.isArray(safe.services)
|
||||
? safe.services.filter((service) => record(service).type !== 'api')
|
||||
: [];
|
||||
if (services.length) safe.services = services;
|
||||
else delete safe.services;
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function materializeGatewaySnapshotConfig(config: unknown, runtimeConfigPath: string) {
|
||||
privateWrite(runtimeConfigPath, withoutApiServices(config));
|
||||
return { configPath: runtimeConfigPath, secret: null, warning: null };
|
||||
}
|
||||
|
||||
function withAuthenticatedApi(config: unknown, secret: string) {
|
||||
const materialized = structuredClone(record(config));
|
||||
const services = Array.isArray(materialized.services) ? materialized.services : [];
|
||||
materialized.services = services.map((service) => (
|
||||
record(service).type === 'api'
|
||||
? { ...record(service), secret }
|
||||
: service
|
||||
));
|
||||
return materialized;
|
||||
}
|
||||
|
||||
function validateApiService(config: unknown, apiPort: number) {
|
||||
const configuredServices = record(config).services;
|
||||
const services = Array.isArray(configuredServices) ? configuredServices : [];
|
||||
const apiServices = services.map(record).filter(({ type }) => type === 'api');
|
||||
if (apiServices.length !== 1) throw new Error('expected exactly one native API service');
|
||||
const [service] = apiServices;
|
||||
if (service.listen !== '127.0.0.1' || service.listen_port !== apiPort
|
||||
|| service.dashboard !== false || Object.hasOwn(service, 'secret')) {
|
||||
throw new Error(`native API service must be unauthenticated base config on 127.0.0.1:${apiPort}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeGatewayNativeConfig(
|
||||
config: unknown,
|
||||
{ apiPort, secretPath, runtimeConfigPath }: MaterializeOptions,
|
||||
) {
|
||||
let warning: string | null = null;
|
||||
try {
|
||||
validateApiService(config, apiPort);
|
||||
const secret = ensureGatewayNativeApiSecret(secretPath);
|
||||
privateWrite(runtimeConfigPath, withAuthenticatedApi(config, secret));
|
||||
return { configPath: runtimeConfigPath, secret, warning };
|
||||
} catch (error) {
|
||||
warning = `Native traffic API disabled: ${message(error)}`;
|
||||
}
|
||||
|
||||
const safeConfig = withoutApiServices(config);
|
||||
try {
|
||||
privateWrite(runtimeConfigPath, safeConfig);
|
||||
return { configPath: runtimeConfigPath, secret: null, warning };
|
||||
} catch (error) {
|
||||
warning = `${warning}; private runtime config unavailable: ${message(error)}`;
|
||||
const suffix = crypto.createHash('sha256').update(runtimeConfigPath).digest('hex').slice(0, 12);
|
||||
const fallbackPath = path.join(os.tmpdir(), `harbor-singbox-runtime-${process.pid}-${suffix}.json`);
|
||||
privateWrite(fallbackPath, safeConfig);
|
||||
return { configPath: fallbackPath, secret: null, warning };
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface LiveTrafficReader {
|
||||
snapshot(): unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
interface DeviceInventoryReader {
|
||||
snapshot(): unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
export function enrichLiveTrafficDeviceLabels(
|
||||
snapshot: LiveTrafficSnapshot,
|
||||
inventory: unknown,
|
||||
): LiveTrafficSnapshot {
|
||||
const devices = Array.isArray(record(inventory).devices)
|
||||
? (record(inventory).devices as unknown[]).map(record)
|
||||
: [];
|
||||
const labels = new Map(devices.flatMap((device) => {
|
||||
const id = String(device.id || '');
|
||||
if (!/^dev_[a-f0-9]{16}$/.test(id)) return [];
|
||||
const label = [device.alias, device.hostname, device.ip]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
return label ? [[id, String(label).trim()] as const] : [];
|
||||
}));
|
||||
if (!labels.size) return snapshot;
|
||||
return {
|
||||
...snapshot,
|
||||
connections: snapshot.connections.map((connection) => {
|
||||
const label = connection.origin.kind === 'device' && connection.origin.id
|
||||
? labels.get(connection.origin.id)
|
||||
: null;
|
||||
return label ? {
|
||||
...connection,
|
||||
origin: { ...connection.origin, label },
|
||||
} : connection;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveTrafficRoute({
|
||||
traffic,
|
||||
deviceInventory = null,
|
||||
}: {
|
||||
traffic: LiveTrafficReader | null;
|
||||
deviceInventory?: DeviceInventoryReader | null;
|
||||
}) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (pathname !== '/api/traffic/live') return false;
|
||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||
const enriched = deviceInventory
|
||||
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||
: snapshot;
|
||||
sendJson(res, 200, enriched);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -84,6 +84,7 @@ import { createConnectivityDiagnosticsRoute } from './http/routes/connectivityDi
|
||||
import { createGatewayPresenceRoute } from './http/routes/gatewayPresenceRoute.js';
|
||||
import { createSharedProxyRoute } from './http/routes/sharedProxyRoute.js';
|
||||
import { createVersionRoute } from './http/routes/versionRoute.js';
|
||||
import { createLiveTrafficRoute } from './http/routes/liveTrafficRoute.js';
|
||||
import { createSingboxSelectorService } from './services/singboxSelectorService.js';
|
||||
import { createFailoverService } from './features/failover/failoverService.js';
|
||||
import { createFailoverRoute } from './http/routes/failoverRoute.js';
|
||||
@@ -260,6 +261,16 @@ function selectRuntime() {
|
||||
throw new Error('Harbor runtime is not configured');
|
||||
}
|
||||
const singboxRuntime = selectRuntime();
|
||||
const clientLiveTraffic = settings.appMode === 'client'
|
||||
? (await import('./services/liveTrafficService.js')).createLiveTrafficService({
|
||||
port: settings.singboxNativeApiPort,
|
||||
enabled: settings.singboxTrafficSource === 'native',
|
||||
isRuntimeRunning: () => Boolean(localRuntime?.running),
|
||||
})
|
||||
: null;
|
||||
const liveTraffic = clientLiveTraffic || (remoteDataplane ? {
|
||||
snapshot: () => requireRemoteRuntime().observeLiveTraffic(),
|
||||
} : null);
|
||||
|
||||
function requireRemoteRuntime() {
|
||||
if (!remoteRuntime) throw new Error('Harbor dataplane runtime is not configured');
|
||||
@@ -515,6 +526,10 @@ const versionRoute = createVersionRoute({
|
||||
? () => requireRemoteRuntime().refresh()
|
||||
: null,
|
||||
});
|
||||
const liveTrafficRoute = createLiveTrafficRoute({
|
||||
traffic: liveTraffic,
|
||||
deviceInventory: remoteDataplane ? deviceInventory : null,
|
||||
});
|
||||
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||
readBody,
|
||||
@@ -887,6 +902,19 @@ function currentConfigMatchesAppliedTarget(state: StoredState) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (settings.appMode === 'client' || settings.appMode === 'gateway') {
|
||||
const apiServices = (Array.isArray(config.services) ? config.services : [])
|
||||
.map(record)
|
||||
.filter(({ type }) => type === 'api');
|
||||
const nativeApiMatches = apiServices.length === 1
|
||||
&& apiServices[0].listen === '127.0.0.1'
|
||||
&& apiServices[0].listen_port === settings.singboxNativeApiPort
|
||||
&& apiServices[0].dashboard === false
|
||||
&& !Object.hasOwn(apiServices[0], 'secret');
|
||||
const nativeApiExpected = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
if (nativeApiExpected ? !nativeApiMatches : apiServices.length > 0) return false;
|
||||
}
|
||||
if (state.appliedFailoverPolicy) {
|
||||
const expectedRole = state.appliedProfileId === state.appliedFailoverPolicy.reserve.profileId
|
||||
&& state.appliedServerId === state.appliedFailoverPolicy.reserve.serverId
|
||||
@@ -955,6 +983,8 @@ async function handleApi(req: IncomingMessage, res: ServerResponse) {
|
||||
|
||||
if (await versionRoute.handle(req, res)) return;
|
||||
|
||||
if (await liveTrafficRoute.handle(req, res)) return;
|
||||
|
||||
if (await sharedProxyRoute.handle(req, res)) return;
|
||||
|
||||
if (await deviceInventoryRoute.handle(req, res)) return;
|
||||
@@ -1004,6 +1034,7 @@ async function shutdown() {
|
||||
gatewayAutoService.stopDiscovery();
|
||||
if (deviceDiscoveryTimer) clearInterval(deviceDiscoveryTimer);
|
||||
await gatewayFailover?.shutdown().catch((error) => console.warn(`[control] failover shutdown: ${errorMessage(error)}`));
|
||||
await clientLiveTraffic?.stop().catch((error) => console.warn(`[control] traffic shutdown: ${errorMessage(error)}`));
|
||||
await serializeControl(() => singboxRuntime.shutdown());
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -1073,6 +1104,8 @@ if (deviceInventory) {
|
||||
.catch((error: unknown) => console.warn(`[control] device policy не применена: ${errorMessage(error)}`));
|
||||
}
|
||||
|
||||
clientLiveTraffic?.start();
|
||||
|
||||
server.listen(settings.port, '0.0.0.0', () => {
|
||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ServerResponse } from 'node:http';
|
||||
|
||||
const COUNTER_PATTERN = /^\d+$/;
|
||||
const SIGNED_DECIMAL_PATTERN = /^-?\d+$/;
|
||||
const COLLECTOR_MODES = new Set(['snapshot', 'shadow', 'native']);
|
||||
const COLLECTOR_WRITERS = new Set(['snapshot', 'native']);
|
||||
const COLLECTOR_STATES = new Set([
|
||||
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||
]);
|
||||
|
||||
const labelValue = (value: unknown) => String(value ?? '')
|
||||
.replaceAll('\\', '\\\\')
|
||||
@@ -23,6 +29,19 @@ function counter(value: unknown) {
|
||||
return decimal;
|
||||
}
|
||||
|
||||
function signedGauge(value: unknown) {
|
||||
const decimal = String(value ?? '');
|
||||
if (!SIGNED_DECIMAL_PATTERN.test(decimal)) throw new Error(`Invalid Prometheus gauge: ${decimal}`);
|
||||
return decimal;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, { signed = false } = {}) {
|
||||
if (!Number.isSafeInteger(value) || (!signed && Number(value) < 0)) {
|
||||
throw new Error(`Invalid Prometheus gauge: ${String(value)}`);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown) {
|
||||
const milliseconds = Date.parse(String(value ?? ''));
|
||||
return Number.isFinite(milliseconds) ? String(milliseconds / 1000) : null;
|
||||
@@ -151,10 +170,64 @@ export function renderPrometheusMetrics(value: unknown) {
|
||||
}
|
||||
|
||||
const domainTraffic = record(snapshot.domainTraffic);
|
||||
const collectorSource = record(domainTraffic.source);
|
||||
const hasCollectorDiagnostics = ['mode', 'writer', 'native', 'shadow']
|
||||
.some((field) => Object.hasOwn(collectorSource, field));
|
||||
if (hasCollectorDiagnostics) {
|
||||
const source = collectorSource;
|
||||
const mode = String(source.mode || '');
|
||||
const writer = String(source.writer || '');
|
||||
if (!COLLECTOR_MODES.has(mode) || !COLLECTOR_WRITERS.has(writer)) {
|
||||
throw new Error('Invalid traffic collector labels');
|
||||
}
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_info Current Gateway traffic collector mode and canonical writer.',
|
||||
'# TYPE harbor_traffic_collector_info gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_info', { mode, writer }, '1');
|
||||
|
||||
if (source.native !== null) {
|
||||
const native = record(source.native);
|
||||
const state = String(native.state || '');
|
||||
if (!COLLECTOR_STATES.has(state)) throw new Error('Invalid traffic collector state');
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_state Current native traffic collector state.',
|
||||
'# TYPE harbor_traffic_collector_state gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_state', { state }, '1');
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_collector_unattributed_bytes Native traffic bytes not attributed to a lifecycle connection.',
|
||||
'# TYPE harbor_traffic_collector_unattributed_bytes gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'download' }, counter(native.unattributedDownloadBytes));
|
||||
metric(lines, 'harbor_traffic_collector_unattributed_bytes', { direction: 'upload' }, counter(native.unattributedUploadBytes));
|
||||
}
|
||||
|
||||
if (source.shadow !== null) {
|
||||
const shadow = record(source.shadow);
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_shadow_active_difference Native active connections minus snapshot active connections.',
|
||||
'# TYPE harbor_traffic_shadow_active_difference gauge',
|
||||
`harbor_traffic_shadow_active_difference ${safeInteger(shadow.activeDifference, { signed: true })}`,
|
||||
'# HELP harbor_traffic_shadow_difference_bytes Native traffic bytes minus snapshot traffic bytes.',
|
||||
'# TYPE harbor_traffic_shadow_difference_bytes gauge',
|
||||
);
|
||||
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'download' }, signedGauge(shadow.downloadDifferenceBytes));
|
||||
metric(lines, 'harbor_traffic_shadow_difference_bytes', { direction: 'upload' }, signedGauge(shadow.uploadDifferenceBytes));
|
||||
lines.push(
|
||||
'# HELP harbor_traffic_shadow_route_mismatches Route aggregate keys that differ between native and snapshot projections.',
|
||||
'# TYPE harbor_traffic_shadow_route_mismatches gauge',
|
||||
`harbor_traffic_shadow_route_mismatches ${safeInteger(shadow.routeMismatches)}`,
|
||||
'# HELP harbor_traffic_shadow_device_mismatches Device aggregate keys that differ between native and snapshot projections.',
|
||||
'# TYPE harbor_traffic_shadow_device_mismatches gauge',
|
||||
`harbor_traffic_shadow_device_mismatches ${safeInteger(shadow.deviceMismatches)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
|
||||
if (trackedSeries.length) {
|
||||
lines.push(
|
||||
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.',
|
||||
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the configured sing-box traffic collector; excludes IP and tunnel overhead.',
|
||||
'# TYPE harbor_singbox_tracked_bytes_total counter',
|
||||
);
|
||||
for (const series of trackedSeries) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { domainToASCII } from 'node:url';
|
||||
import { deviceId } from './deviceInventoryService.js';
|
||||
import type { NativeTrafficProjectionBatch } from './liveTrafficService.js';
|
||||
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const DEFAULT_MAX_SERIES = 4096;
|
||||
@@ -17,6 +18,7 @@ const SERVICE_DOMAINS = [
|
||||
|
||||
interface ParsedBaseConnection {
|
||||
id: string;
|
||||
startedAt?: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
@@ -38,6 +40,7 @@ type ParsedConnection =
|
||||
});
|
||||
|
||||
interface PreviousConnection {
|
||||
startedAt?: string;
|
||||
outcome: AttributionOutcome | 'classified';
|
||||
key?: string;
|
||||
requestedKey?: string;
|
||||
@@ -67,7 +70,7 @@ interface RouteSeriesTotal {
|
||||
interface DomainTrafficSnapshot {
|
||||
epoch: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
source: { error: string | null; activeConnections: number };
|
||||
overflowConnections: string;
|
||||
attributionEvents: Record<AttributionOutcome, string>;
|
||||
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
|
||||
@@ -170,6 +173,46 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
|
||||
};
|
||||
}
|
||||
|
||||
function decimalCounter(value: unknown) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) {
|
||||
throw new Error('Sing-box вернул невалидный native traffic counter');
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
function parseNativeConnection(value: unknown): ParsedConnection {
|
||||
const connection = record(value);
|
||||
const inbound = record(connection.inbound);
|
||||
const origin = record(connection.origin);
|
||||
const destination = record(connection.destination);
|
||||
const route = record(connection.route);
|
||||
const traffic = record(connection.traffic);
|
||||
const parsed = {
|
||||
id: String(connection.id || ''),
|
||||
startedAt: typeof connection.startedAt === 'string' ? connection.startedAt : undefined,
|
||||
upload: decimalCounter(traffic.uploadBytes),
|
||||
download: decimalCounter(traffic.downloadBytes),
|
||||
};
|
||||
if (!parsed.id) throw new Error('Sing-box вернул native traffic без id');
|
||||
const source = sourceFor(`${String(inbound.type || '')}/${String(inbound.tag || '')}`);
|
||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||
const outbound: TrafficRoute = route.kind === 'vpn' || route.kind === 'direct' ? route.kind : 'unknown';
|
||||
const currentDeviceId = origin.kind === 'device' && typeof origin.id === 'string' && origin.id
|
||||
? origin.id
|
||||
: null;
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
|
||||
const classifiedDomain = classifyDomain(destination.domain);
|
||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||
return {
|
||||
...parsed,
|
||||
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||
deviceId: currentDeviceId,
|
||||
...domain,
|
||||
source,
|
||||
outbound,
|
||||
};
|
||||
}
|
||||
|
||||
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
||||
@@ -223,6 +266,9 @@ export function createDomainTrafficService({
|
||||
const normalSeriesLimit = maxSeries - 2;
|
||||
let normalSeries = 0;
|
||||
let previousConnections = new Map<string, PreviousConnection>();
|
||||
const settledNativeConnections = new Map<string, PreviousConnection>();
|
||||
let nativeEpoch: string | null = null;
|
||||
let activeConnections = 0;
|
||||
let overflowConnections = 0n;
|
||||
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||
unresolved_host: 0n,
|
||||
@@ -237,7 +283,7 @@ export function createDomainTrafficService({
|
||||
let current: DomainTrafficSnapshot = {
|
||||
epoch,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
source: { error: null, activeConnections: 0 },
|
||||
overflowConnections: '0',
|
||||
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||
tracked: [],
|
||||
@@ -249,7 +295,7 @@ export function createDomainTrafficService({
|
||||
return {
|
||||
epoch,
|
||||
observedAt: current.observedAt,
|
||||
source: { error },
|
||||
source: { error, activeConnections },
|
||||
overflowConnections: overflowConnections.toString(),
|
||||
attributionEvents: Object.fromEntries(
|
||||
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||
@@ -290,6 +336,153 @@ export function createDomainTrafficService({
|
||||
};
|
||||
}
|
||||
|
||||
function applyParsedConnections({
|
||||
connections,
|
||||
reset,
|
||||
closedIds = [],
|
||||
observed,
|
||||
deviceLabels,
|
||||
sourceActiveConnections,
|
||||
}: {
|
||||
connections: ParsedConnection[];
|
||||
reset: boolean;
|
||||
closedIds?: string[];
|
||||
observed: Date;
|
||||
deviceLabels: Map<string, string>;
|
||||
sourceActiveConnections?: number;
|
||||
}) {
|
||||
const nextConnections = reset
|
||||
? new Map<string, PreviousConnection>()
|
||||
: new Map(previousConnections);
|
||||
const activityEntries: ActivityEntry[] = [];
|
||||
for (const connection of connections) {
|
||||
const settled = settledNativeConnections.get(connection.id);
|
||||
const previous = previousConnections.get(connection.id)
|
||||
?? (connection.startedAt && settled?.startedAt === connection.startedAt ? settled : undefined);
|
||||
if (previous === settled) settledNativeConnections.delete(connection.id);
|
||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||
attributionEvents[connection.outcome] += 1n;
|
||||
}
|
||||
if (connection.outcome !== 'unsupported_source') {
|
||||
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
|
||||
? connection.upload - previous.trackedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
|
||||
? connection.download - previous.trackedDownload
|
||||
: connection.download;
|
||||
const trackedKey = `${connection.source}\0${connection.outbound}`;
|
||||
const tracked = trackedTotals.get(trackedKey) || {
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
tracked.uploadBytes += uploadDelta;
|
||||
tracked.downloadBytes += downloadDelta;
|
||||
trackedTotals.set(trackedKey, tracked);
|
||||
if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) {
|
||||
activityEntries.push({
|
||||
device: 'deviceId' in connection
|
||||
? deviceLabels.get(connection.deviceId) || 'Устройство'
|
||||
: 'Неизвестное устройство',
|
||||
service: 'service' in connection ? connection.service : 'Не распознано',
|
||||
upload: uploadDelta,
|
||||
download: downloadDelta,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||
nextConnections.set(connection.id, {
|
||||
startedAt: connection.startedAt,
|
||||
outcome: connection.outcome,
|
||||
countedUpload: previous?.countedUpload ?? null,
|
||||
countedDownload: previous?.countedDownload ?? null,
|
||||
trackedUpload: connection.outcome === 'unknown_device'
|
||||
? connection.upload
|
||||
: previous?.trackedUpload ?? null,
|
||||
trackedDownload: connection.outcome === 'unknown_device'
|
||||
? connection.download
|
||||
: previous?.trackedDownload ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||
overflowConnections += 1n;
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
key = `_other\0${domain}\0${connection.source}`;
|
||||
} else if (!totals.has(key)) {
|
||||
normalSeries += 1;
|
||||
}
|
||||
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||
? connection.upload - previous.countedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||
? connection.download - previous.countedDownload
|
||||
: connection.download;
|
||||
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
|
||||
const routeTotal = routeTotals.get(routeKey) || {
|
||||
deviceId: connection.deviceId,
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
routeTotal.uploadBytes += uploadDelta;
|
||||
routeTotal.downloadBytes += downloadDelta;
|
||||
routeTotals.set(routeKey, routeTotal);
|
||||
const total = totals.get(key) || {
|
||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||
domain,
|
||||
service,
|
||||
source: connection.source,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
total.uploadBytes += uploadDelta;
|
||||
total.downloadBytes += downloadDelta;
|
||||
totals.set(key, total);
|
||||
nextConnections.set(connection.id, {
|
||||
startedAt: connection.startedAt,
|
||||
outcome: connection.outcome,
|
||||
key,
|
||||
requestedKey,
|
||||
countedUpload: connection.upload,
|
||||
countedDownload: connection.download,
|
||||
trackedUpload: connection.upload,
|
||||
trackedDownload: connection.download,
|
||||
});
|
||||
}
|
||||
for (const id of closedIds) {
|
||||
const baseline = nextConnections.get(id);
|
||||
if (baseline?.startedAt) {
|
||||
settledNativeConnections.delete(id);
|
||||
settledNativeConnections.set(id, baseline);
|
||||
while (settledNativeConnections.size > 2_048) {
|
||||
settledNativeConnections.delete(settledNativeConnections.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
nextConnections.delete(id);
|
||||
}
|
||||
previousConnections = nextConnections;
|
||||
activeConnections = sourceActiveConnections ?? nextConnections.size;
|
||||
if (activityEnabled) {
|
||||
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
|
||||
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
|
||||
}
|
||||
current = { ...current, observedAt: observed.toISOString() };
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
try {
|
||||
const response = record(await observe());
|
||||
@@ -306,118 +499,46 @@ export function createDomainTrafficService({
|
||||
deviceLabels.set(id, publicDeviceLabel(device));
|
||||
}
|
||||
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
|
||||
const activeConnections = new Map<string, PreviousConnection>();
|
||||
const activityEntries: ActivityEntry[] = [];
|
||||
for (const connection of connections) {
|
||||
const previous = previousConnections.get(connection.id);
|
||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||
attributionEvents[connection.outcome] += 1n;
|
||||
nativeEpoch = null;
|
||||
return applyParsedConnections({
|
||||
connections,
|
||||
reset: true,
|
||||
observed: now(),
|
||||
deviceLabels,
|
||||
sourceActiveConnections: response.connections.length,
|
||||
});
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ingestNative(batch: NativeTrafficProjectionBatch) {
|
||||
try {
|
||||
const observed = new Date(batch.observedAt);
|
||||
if (!batch.epoch || Number.isNaN(observed.getTime())) throw new Error('Sing-box вернул невалидный native traffic batch');
|
||||
const deviceLabels = new Map<string, string>();
|
||||
for (const value of batch.connections) {
|
||||
const connection = record(value);
|
||||
const origin = record(connection.origin);
|
||||
if (origin.kind === 'device' && typeof origin.id === 'string' && origin.id) {
|
||||
deviceLabels.set(origin.id, publicDeviceLabel({ alias: origin.label }));
|
||||
}
|
||||
if (connection.outcome !== 'unsupported_source') {
|
||||
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
|
||||
? connection.upload - previous.trackedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
|
||||
? connection.download - previous.trackedDownload
|
||||
: connection.download;
|
||||
const trackedKey = `${connection.source}\0${connection.outbound}`;
|
||||
const tracked = trackedTotals.get(trackedKey) || {
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
tracked.uploadBytes += uploadDelta;
|
||||
tracked.downloadBytes += downloadDelta;
|
||||
trackedTotals.set(trackedKey, tracked);
|
||||
if (activityEnabled && connection.outbound === 'vpn' && uploadDelta + downloadDelta > 0n) {
|
||||
activityEntries.push({
|
||||
device: 'deviceId' in connection
|
||||
? deviceLabels.get(connection.deviceId) || 'Устройство'
|
||||
: 'Неизвестное устройство',
|
||||
service: 'service' in connection ? connection.service : 'Не распознано',
|
||||
upload: uploadDelta,
|
||||
download: downloadDelta,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
countedUpload: previous?.countedUpload ?? null,
|
||||
countedDownload: previous?.countedDownload ?? null,
|
||||
trackedUpload: connection.outcome === 'unknown_device'
|
||||
? connection.upload
|
||||
: previous?.trackedUpload ?? null,
|
||||
trackedDownload: connection.outcome === 'unknown_device'
|
||||
? connection.download
|
||||
: previous?.trackedDownload ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||
overflowConnections += 1n;
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
key = `_other\0${domain}\0${connection.source}`;
|
||||
} else if (!totals.has(key)) {
|
||||
normalSeries += 1;
|
||||
}
|
||||
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||
? connection.upload - previous.countedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||
? connection.download - previous.countedDownload
|
||||
: connection.download;
|
||||
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
|
||||
const routeTotal = routeTotals.get(routeKey) || {
|
||||
deviceId: connection.deviceId,
|
||||
source: connection.source,
|
||||
outbound: connection.outbound,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
routeTotal.uploadBytes += uploadDelta;
|
||||
routeTotal.downloadBytes += downloadDelta;
|
||||
routeTotals.set(routeKey, routeTotal);
|
||||
const total = totals.get(key) || {
|
||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||
domain,
|
||||
service,
|
||||
source: connection.source,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
total.uploadBytes += uploadDelta;
|
||||
total.downloadBytes += downloadDelta;
|
||||
totals.set(key, total);
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
key,
|
||||
requestedKey,
|
||||
countedUpload: connection.upload,
|
||||
countedDownload: connection.download,
|
||||
trackedUpload: connection.upload,
|
||||
trackedDownload: connection.download,
|
||||
});
|
||||
}
|
||||
previousConnections = activeConnections;
|
||||
const observed = now();
|
||||
if (activityEnabled) {
|
||||
activitySamples.push({ at: observed.getTime(), entries: activityEntries });
|
||||
activitySamples = activitySamples.filter(({ at }) => at >= observed.getTime() - 10_000);
|
||||
if (nativeEpoch !== batch.epoch) {
|
||||
nativeEpoch = batch.epoch;
|
||||
previousConnections = new Map();
|
||||
settledNativeConnections.clear();
|
||||
}
|
||||
current = { ...current, observedAt: observed.toISOString() };
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
const connections = batch.connections.map(parseNativeConnection);
|
||||
const result = applyParsedConnections({
|
||||
connections,
|
||||
reset: batch.reset,
|
||||
closedIds: batch.closedIds,
|
||||
observed,
|
||||
deviceLabels,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
@@ -492,5 +613,5 @@ export function createDomainTrafficService({
|
||||
};
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh, enableActivity, disableActivity, activitySnapshot };
|
||||
return { snapshot: () => current, refresh, ingestNative, enableActivity, disableActivity, activitySnapshot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { createClient } from '@connectrpc/connect';
|
||||
import { createGrpcTransport } from '@connectrpc/connect-node';
|
||||
|
||||
import type { LiveTrafficConnection, LiveTrafficSnapshot, LiveTrafficSourceState } from '../../shared/liveTraffic.js';
|
||||
import {
|
||||
ConnectionEventType,
|
||||
StartedService,
|
||||
type Connection,
|
||||
type ConnectionEvents,
|
||||
type Status,
|
||||
} from '../generated/daemon/started_service_pb.js';
|
||||
|
||||
const CONNECTION_INTERVAL = 1_000_000_000n;
|
||||
const SUPPORTED_SINGBOX_VERSION = '1.14.0-rc.5';
|
||||
const SUPPORTED_SINGBOX_API_VERSION = 4;
|
||||
const MAX_VISIBLE = 256;
|
||||
const MAX_SETTLED_IDS = 2048;
|
||||
const MAX_RECENT_CONNECTIONS = 2048;
|
||||
const RECENT_CONNECTION_MS = 30_000;
|
||||
const RETRY_MS = 500;
|
||||
const STALE_MS = 3_000;
|
||||
const VPN_OUTBOUND_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
|
||||
interface ActiveConnection {
|
||||
value: Omit<LiveTrafficConnection, 'traffic'>;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
uploadRate: bigint;
|
||||
downloadRate: bigint;
|
||||
}
|
||||
|
||||
interface LiveTrafficLedgerOptions {
|
||||
enabled?: boolean;
|
||||
now?: () => Date;
|
||||
gateway?: boolean;
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||
}
|
||||
|
||||
export interface NativeTrafficProjectionBatch {
|
||||
epoch: string;
|
||||
observedAt: string;
|
||||
reset: boolean;
|
||||
connections: LiveTrafficConnection[];
|
||||
closedIds: string[];
|
||||
}
|
||||
|
||||
interface NativeTrafficClient {
|
||||
getVersion(
|
||||
input: Record<string, never>,
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): Promise<{ version: string; apiVersion: number }>;
|
||||
getStartedAt(
|
||||
input: Record<string, never>,
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): Promise<{ startedAt: bigint }>;
|
||||
subscribeConnections(
|
||||
input: { interval: bigint },
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): AsyncIterable<ConnectionEvents>;
|
||||
subscribeStatus(
|
||||
input: { interval: bigint },
|
||||
options: { signal: AbortSignal; headers?: Record<string, string> },
|
||||
): AsyncIterable<Status>;
|
||||
}
|
||||
|
||||
interface LiveTrafficServiceOptions {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
isRuntimeRunning: () => boolean;
|
||||
gateway?: boolean;
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'];
|
||||
authorization?: () => string | null;
|
||||
unavailableError?: string | null;
|
||||
onProjection?: (batch: NativeTrafficProjectionBatch) => Promise<void> | void;
|
||||
clientFactory?: (port: number) => NativeTrafficClient;
|
||||
}
|
||||
|
||||
function positive(value: bigint) {
|
||||
return value > 0n ? value : 0n;
|
||||
}
|
||||
|
||||
function safeError(error: unknown) {
|
||||
return (error instanceof Error ? error.message : String(error || 'Native traffic stream unavailable'))
|
||||
.replace(/https?:\/\/\S+/gi, '[endpoint]')
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
function parseEndpoint(value: string) {
|
||||
const text = value.trim();
|
||||
const bracketed = /^\[(.+)]:(\d+)$/.exec(text);
|
||||
if (bracketed) return { ip: bracketed[1], port: Number(bracketed[2]) };
|
||||
const separator = text.lastIndexOf(':');
|
||||
if (separator > 0 && !text.slice(0, separator).includes(':') && /^\d+$/.test(text.slice(separator + 1))) {
|
||||
return { ip: text.slice(0, separator), port: Number(text.slice(separator + 1)) };
|
||||
}
|
||||
return { ip: text, port: null };
|
||||
}
|
||||
|
||||
function isoFromMilliseconds(value: bigint, fallback: Date) {
|
||||
const milliseconds = Number(value);
|
||||
return Number.isSafeInteger(milliseconds) && milliseconds > 0
|
||||
? new Date(milliseconds).toISOString()
|
||||
: fallback.toISOString();
|
||||
}
|
||||
|
||||
function routeKindFromValues(
|
||||
outbound: string | null,
|
||||
outboundType: string | null,
|
||||
chain: string[] = [],
|
||||
gateway = false,
|
||||
): 'vpn' | 'direct' | 'other' {
|
||||
if (outbound === 'direct' || outboundType === 'direct') return 'direct';
|
||||
if (gateway) {
|
||||
if (chain[0] === 'direct') return 'direct';
|
||||
return chain.length > 0 ? 'vpn' : 'other';
|
||||
}
|
||||
return outboundType && VPN_OUTBOUND_TYPES.has(outboundType) ? 'vpn' : 'other';
|
||||
}
|
||||
|
||||
function routeKind(connection: Connection, gateway: boolean): 'vpn' | 'direct' | 'other' {
|
||||
return routeKindFromValues(
|
||||
connection.outbound || null,
|
||||
connection.outboundType || null,
|
||||
connection.chainList,
|
||||
gateway,
|
||||
);
|
||||
}
|
||||
|
||||
function macOrigin(): LiveTrafficConnection['origin'] {
|
||||
return {
|
||||
kind: 'this-mac',
|
||||
id: null,
|
||||
label: 'Этот Mac',
|
||||
provenance: 'client-runtime',
|
||||
};
|
||||
}
|
||||
|
||||
function unknownOrigin(sourceIp: string): LiveTrafficConnection['origin'] {
|
||||
return {
|
||||
kind: 'unknown',
|
||||
id: null,
|
||||
label: sourceIp || 'Неизвестное устройство',
|
||||
provenance: 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function mapConnection(
|
||||
connection: Connection,
|
||||
now: Date,
|
||||
gateway: boolean,
|
||||
resolveOrigin?: (sourceIp: string) => LiveTrafficConnection['origin'],
|
||||
): Omit<LiveTrafficConnection, 'traffic'> {
|
||||
const source = parseEndpoint(connection.source);
|
||||
const destination = parseEndpoint(connection.destination);
|
||||
const destinationHost = destination.ip.trim();
|
||||
const domain = connection.domain.trim().toLowerCase()
|
||||
|| (destinationHost && !isIP(destinationHost) ? destinationHost.toLowerCase() : null);
|
||||
const destinationIp = isIP(destinationHost) ? destinationHost : null;
|
||||
return {
|
||||
id: connection.id,
|
||||
startedAt: isoFromMilliseconds(connection.createdAt, now),
|
||||
closedAt: null,
|
||||
inbound: { tag: connection.inbound, type: connection.inboundType },
|
||||
network: connection.network === 'tcp' || connection.network === 'udp' ? connection.network : 'unknown',
|
||||
protocol: connection.protocol || null,
|
||||
source,
|
||||
destination: {
|
||||
domain,
|
||||
ip: destinationIp,
|
||||
port: destination.port,
|
||||
provenance: domain || destinationIp ? 'sing-box' : 'unknown',
|
||||
},
|
||||
origin: resolveOrigin?.(source.ip) ?? (gateway ? unknownOrigin(source.ip) : macOrigin()),
|
||||
route: {
|
||||
kind: routeKind(connection, gateway),
|
||||
scope: 'local-sing-box',
|
||||
outbound: connection.outbound || null,
|
||||
outboundType: connection.outboundType || null,
|
||||
chain: [...connection.chainList],
|
||||
rule: connection.rule || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFinalMetadata(
|
||||
current: Omit<LiveTrafficConnection, 'traffic'> | undefined,
|
||||
final: Omit<LiveTrafficConnection, 'traffic'> | null,
|
||||
gateway: boolean,
|
||||
) {
|
||||
if (!current) return final;
|
||||
if (!final) return current;
|
||||
const domain = final.destination.domain ?? current.destination.domain;
|
||||
const ip = final.destination.ip ?? current.destination.ip;
|
||||
const outbound = final.route.outbound ?? current.route.outbound;
|
||||
const outboundType = final.route.outboundType ?? current.route.outboundType;
|
||||
return {
|
||||
...current,
|
||||
inbound: {
|
||||
tag: final.inbound.tag || current.inbound.tag,
|
||||
type: final.inbound.type || current.inbound.type,
|
||||
},
|
||||
network: final.network === 'unknown' ? current.network : final.network,
|
||||
protocol: final.protocol ?? current.protocol,
|
||||
source: {
|
||||
ip: final.source.ip || current.source.ip,
|
||||
port: final.source.port ?? current.source.port,
|
||||
},
|
||||
destination: {
|
||||
domain,
|
||||
ip,
|
||||
port: final.destination.port ?? current.destination.port,
|
||||
provenance: domain || ip ? 'sing-box' as const : 'unknown' as const,
|
||||
},
|
||||
route: {
|
||||
...current.route,
|
||||
kind: routeKindFromValues(
|
||||
outbound,
|
||||
outboundType,
|
||||
final.route.chain.length ? final.route.chain : current.route.chain,
|
||||
gateway,
|
||||
),
|
||||
outbound,
|
||||
outboundType,
|
||||
chain: final.route.chain.length ? final.route.chain : current.route.chain,
|
||||
rule: final.route.rule ?? current.route.rule,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveTrafficLedger({
|
||||
enabled = true,
|
||||
now = () => new Date(),
|
||||
gateway = false,
|
||||
resolveOrigin,
|
||||
}: LiveTrafficLedgerOptions = {}) {
|
||||
let epoch: string | null = null;
|
||||
let sequence = 0;
|
||||
let observedAt: string | null = null;
|
||||
let state: LiveTrafficSourceState = enabled ? 'connecting' : 'disabled';
|
||||
let singBoxVersion: string | null = null;
|
||||
let singBoxApiVersion: number | null = null;
|
||||
let error: string | null = null;
|
||||
let accountedUpload = 0n;
|
||||
let accountedDownload = 0n;
|
||||
let explicitGapUpload = 0n;
|
||||
let explicitGapDownload = 0n;
|
||||
let statusGapUpload = 0n;
|
||||
let statusGapDownload = 0n;
|
||||
let mismatchCount = 0;
|
||||
let resetSeen = false;
|
||||
let statusSeen = false;
|
||||
let projectionError = false;
|
||||
let lastStatus: Status | null = null;
|
||||
const active = new Map<string, ActiveConnection>();
|
||||
const recent = new Map<string, ActiveConnection>();
|
||||
const settled = new Map<string, true>();
|
||||
|
||||
const changed = (updateObservedAt = true) => {
|
||||
sequence += 1;
|
||||
if (updateObservedAt) observedAt = now().toISOString();
|
||||
};
|
||||
const settle = (id: string) => {
|
||||
if (!id) return;
|
||||
settled.delete(id);
|
||||
settled.set(id, true);
|
||||
while (settled.size > MAX_SETTLED_IDS) settled.delete(settled.keys().next().value as string);
|
||||
};
|
||||
const rememberRecent = (connection: ActiveConnection) => {
|
||||
const id = connection.value.id;
|
||||
recent.delete(id);
|
||||
recent.set(id, connection);
|
||||
while (recent.size > MAX_RECENT_CONNECTIONS) recent.delete(recent.keys().next().value as string);
|
||||
};
|
||||
const pruneRecent = (timestamp: Date) => {
|
||||
const cutoff = timestamp.getTime() - RECENT_CONNECTION_MS;
|
||||
for (const [id, connection] of recent) {
|
||||
const closedAt = connection.value.closedAt;
|
||||
if (closedAt !== null && Date.parse(closedAt) <= cutoff) recent.delete(id);
|
||||
}
|
||||
};
|
||||
const clearEpoch = () => {
|
||||
active.clear();
|
||||
recent.clear();
|
||||
settled.clear();
|
||||
accountedUpload = 0n;
|
||||
accountedDownload = 0n;
|
||||
explicitGapUpload = 0n;
|
||||
explicitGapDownload = 0n;
|
||||
statusGapUpload = 0n;
|
||||
statusGapDownload = 0n;
|
||||
mismatchCount = 0;
|
||||
resetSeen = false;
|
||||
statusSeen = false;
|
||||
projectionError = false;
|
||||
lastStatus = null;
|
||||
};
|
||||
const reconcileStatus = (countMismatch = false) => {
|
||||
if (!lastStatus) return;
|
||||
const statusUpload = positive(lastStatus.uplinkTotal);
|
||||
const statusDownload = positive(lastStatus.downlinkTotal);
|
||||
statusGapUpload = statusUpload > accountedUpload ? statusUpload - accountedUpload : 0n;
|
||||
statusGapDownload = statusDownload > accountedDownload ? statusDownload - accountedDownload : 0n;
|
||||
const mismatch = active.size !== lastStatus.connectionsIn
|
||||
|| statusGapUpload > 0n
|
||||
|| statusGapDownload > 0n
|
||||
|| accountedUpload > statusUpload
|
||||
|| accountedDownload > statusDownload;
|
||||
if (countMismatch) mismatchCount = mismatch ? mismatchCount + 1 : 0;
|
||||
if (resetSeen && statusSeen) state = mismatchCount >= 3 || projectionError ? 'degraded' : 'live';
|
||||
};
|
||||
const addUnattributed = (upload: bigint, download: bigint) => {
|
||||
const safeUpload = positive(upload);
|
||||
const safeDownload = positive(download);
|
||||
explicitGapUpload += safeUpload;
|
||||
explicitGapDownload += safeDownload;
|
||||
accountedUpload += safeUpload;
|
||||
accountedDownload += safeDownload;
|
||||
};
|
||||
const project = (connection: ActiveConnection): LiveTrafficConnection => ({
|
||||
...connection.value,
|
||||
origin: resolveOrigin?.(connection.value.source.ip) ?? connection.value.origin,
|
||||
traffic: {
|
||||
uploadBytes: connection.upload.toString(),
|
||||
downloadBytes: connection.download.toString(),
|
||||
uploadBytesPerSecond: connection.uploadRate.toString(),
|
||||
downloadBytesPerSecond: connection.downloadRate.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
beginEpoch(startedAt: bigint, version: string, apiVersion: number) {
|
||||
const nextEpoch = `sing-box-${startedAt}`;
|
||||
const epochChanged = nextEpoch !== epoch;
|
||||
if (epochChanged) clearEpoch();
|
||||
epoch = nextEpoch;
|
||||
singBoxVersion = version;
|
||||
singBoxApiVersion = apiVersion;
|
||||
error = null;
|
||||
state = 'connecting';
|
||||
changed(epochChanged || observedAt === null);
|
||||
},
|
||||
|
||||
applyConnections(batch: ConnectionEvents) {
|
||||
const timestamp = now();
|
||||
const touched = new Set<string>();
|
||||
const closedIds = new Set<string>();
|
||||
const closedConnections = new Map<string, LiveTrafficConnection>();
|
||||
pruneRecent(timestamp);
|
||||
if (batch.reset || batch.events.some(({ type }) => (
|
||||
type === ConnectionEventType.CONNECTION_EVENT_UPDATE
|
||||
))) {
|
||||
for (const connection of active.values()) {
|
||||
connection.uploadRate = 0n;
|
||||
connection.downloadRate = 0n;
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.reset) {
|
||||
const next = new Map<string, ActiveConnection>();
|
||||
for (const event of batch.events) {
|
||||
const connection = event.connection;
|
||||
if (!connection?.id) continue;
|
||||
touched.add(connection.id);
|
||||
const upload = positive(connection.uplinkTotal);
|
||||
const download = positive(connection.downlinkTotal);
|
||||
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||
const recentPrevious = recent.get(connection.id);
|
||||
const previous = active.get(connection.id)
|
||||
?? (recentPrevious?.value.startedAt === mapped.startedAt ? recentPrevious : undefined);
|
||||
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||
const alreadySettled = settled.has(connection.id);
|
||||
if (!alreadySettled) {
|
||||
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||
const closedAt = event.closedAt > 0n
|
||||
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||
: isoFromMilliseconds(connection.closedAt, timestamp);
|
||||
const settledConnection = {
|
||||
value: { ...mapped, closedAt },
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
};
|
||||
closedConnections.set(connection.id, project(settledConnection));
|
||||
rememberRecent(settledConnection);
|
||||
}
|
||||
closedIds.add(connection.id);
|
||||
settle(connection.id);
|
||||
continue;
|
||||
}
|
||||
recent.delete(connection.id);
|
||||
settled.delete(connection.id);
|
||||
accountedUpload += previous ? positive(upload - previous.upload) : upload;
|
||||
accountedDownload += previous ? positive(download - previous.download) : download;
|
||||
next.set(connection.id, {
|
||||
value: mapped,
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
});
|
||||
}
|
||||
for (const [id] of active) {
|
||||
if (next.has(id)) continue;
|
||||
closedIds.add(id);
|
||||
settle(id);
|
||||
}
|
||||
active.clear();
|
||||
for (const [id, connection] of next) active.set(id, connection);
|
||||
resetSeen = true;
|
||||
} else {
|
||||
for (const event of batch.events) {
|
||||
const id = event.id || event.connection?.id || '';
|
||||
if (!id) continue;
|
||||
touched.add(id);
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_NEW) {
|
||||
const connection = event.connection;
|
||||
if (!connection || active.has(id)) continue;
|
||||
if (connection.closedAt > 0n || event.closedAt > 0n) {
|
||||
settle(id);
|
||||
continue;
|
||||
}
|
||||
const mapped = mapConnection(connection, timestamp, gateway, resolveOrigin);
|
||||
const previous = recent.get(id);
|
||||
if (!previous && settled.has(id)) continue;
|
||||
if (previous && mapped.startedAt <= previous.value.startedAt) continue;
|
||||
recent.delete(id);
|
||||
settled.delete(id);
|
||||
const upload = positive(connection.uplinkTotal);
|
||||
const download = positive(connection.downlinkTotal);
|
||||
active.set(id, {
|
||||
value: mapped,
|
||||
upload,
|
||||
download,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
});
|
||||
accountedUpload += upload;
|
||||
accountedDownload += download;
|
||||
continue;
|
||||
}
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_UPDATE) {
|
||||
const upload = positive(event.uplinkDelta);
|
||||
const download = positive(event.downlinkDelta);
|
||||
const connection = active.get(id);
|
||||
if (!connection) continue;
|
||||
connection.upload += upload;
|
||||
connection.download += download;
|
||||
connection.uploadRate += upload;
|
||||
connection.downloadRate += download;
|
||||
accountedUpload += upload;
|
||||
accountedDownload += download;
|
||||
continue;
|
||||
}
|
||||
if (event.type === ConnectionEventType.CONNECTION_EVENT_CLOSED
|
||||
&& !settled.has(id) && !recent.has(id)) {
|
||||
const current = active.get(id);
|
||||
const finalUpload = event.connection ? positive(event.connection.uplinkTotal) : 0n;
|
||||
const finalDownload = event.connection ? positive(event.connection.downlinkTotal) : 0n;
|
||||
const tailUpload = event.connection
|
||||
? (current && finalUpload > current.upload ? finalUpload - current.upload : current ? 0n : finalUpload)
|
||||
: positive(event.uplinkDelta);
|
||||
const tailDownload = event.connection
|
||||
? (current && finalDownload > current.download ? finalDownload - current.download : current ? 0n : finalDownload)
|
||||
: positive(event.downlinkDelta);
|
||||
if (current) {
|
||||
accountedUpload += tailUpload;
|
||||
accountedDownload += tailDownload;
|
||||
} else if (event.connection) {
|
||||
addUnattributed(tailUpload, tailDownload);
|
||||
}
|
||||
const metadata = mergeFinalMetadata(
|
||||
current?.value,
|
||||
event.connection ? mapConnection(event.connection, timestamp, gateway, resolveOrigin) : null,
|
||||
gateway,
|
||||
);
|
||||
if (metadata) {
|
||||
const closedAt = event.closedAt > 0n
|
||||
? isoFromMilliseconds(event.closedAt, timestamp)
|
||||
: event.connection && event.connection.closedAt > 0n
|
||||
? isoFromMilliseconds(event.connection.closedAt, timestamp)
|
||||
: timestamp.toISOString();
|
||||
const settledConnection = {
|
||||
value: { ...metadata, id, closedAt },
|
||||
upload: current ? current.upload + tailUpload : finalUpload,
|
||||
download: current ? current.download + tailDownload : finalDownload,
|
||||
uploadRate: 0n,
|
||||
downloadRate: 0n,
|
||||
};
|
||||
closedConnections.set(id, project(settledConnection));
|
||||
rememberRecent(settledConnection);
|
||||
}
|
||||
active.delete(id);
|
||||
closedIds.add(id);
|
||||
settle(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
reconcileStatus();
|
||||
changed();
|
||||
if (!epoch) return null;
|
||||
const connections: LiveTrafficConnection[] = [];
|
||||
for (const id of touched) {
|
||||
const settledConnection = closedConnections.get(id);
|
||||
if (settledConnection) connections.push(settledConnection);
|
||||
else {
|
||||
const connection = active.get(id);
|
||||
if (connection) connections.push(project(connection));
|
||||
}
|
||||
}
|
||||
return {
|
||||
epoch,
|
||||
observedAt: timestamp.toISOString(),
|
||||
reset: batch.reset,
|
||||
connections,
|
||||
closedIds: [...closedIds],
|
||||
} satisfies NativeTrafficProjectionBatch;
|
||||
},
|
||||
|
||||
applyStatus(status: Status) {
|
||||
const timestamp = now();
|
||||
pruneRecent(timestamp);
|
||||
lastStatus = status;
|
||||
statusSeen = true;
|
||||
reconcileStatus(true);
|
||||
changed();
|
||||
return epoch && state === 'live' ? {
|
||||
epoch,
|
||||
observedAt: timestamp.toISOString(),
|
||||
reset: false,
|
||||
connections: [],
|
||||
closedIds: [],
|
||||
} satisfies NativeTrafficProjectionBatch : null;
|
||||
},
|
||||
|
||||
markStopped() {
|
||||
if (state === 'stopped' && active.size === 0) return;
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
state = 'stopped';
|
||||
error = null;
|
||||
changed();
|
||||
},
|
||||
|
||||
markTransportError(reason: unknown) {
|
||||
error = safeError(reason);
|
||||
state = epoch ? 'stale' : 'connecting';
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markProjectionError(reason: unknown) {
|
||||
projectionError = true;
|
||||
error = safeError(reason);
|
||||
state = 'degraded';
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markProjectionHealthy() {
|
||||
if (!projectionError) return;
|
||||
projectionError = false;
|
||||
error = null;
|
||||
reconcileStatus();
|
||||
changed(false);
|
||||
},
|
||||
|
||||
markUnavailable(reason: unknown) {
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
state = 'incompatible';
|
||||
error = safeError(reason);
|
||||
changed();
|
||||
},
|
||||
|
||||
markIncompatible(version: string, apiVersion: number) {
|
||||
clearEpoch();
|
||||
epoch = null;
|
||||
singBoxVersion = version;
|
||||
singBoxApiVersion = apiVersion;
|
||||
state = 'incompatible';
|
||||
error = `sing-box ${version} API ${apiVersion} is incompatible`;
|
||||
changed();
|
||||
},
|
||||
|
||||
snapshot(): LiveTrafficSnapshot {
|
||||
const recentCutoff = now().getTime() - RECENT_CONNECTION_MS;
|
||||
const all = [...active.values()];
|
||||
all.sort((left, right) => right.value.startedAt.localeCompare(left.value.startedAt)
|
||||
|| left.value.id.localeCompare(right.value.id));
|
||||
const allRecent = [...recent.values()].filter(({ value }) => (
|
||||
value.closedAt !== null && Date.parse(value.closedAt) > recentCutoff
|
||||
));
|
||||
allRecent.sort((left, right) => (right.value.closedAt ?? '').localeCompare(left.value.closedAt ?? '')
|
||||
|| left.value.id.localeCompare(right.value.id));
|
||||
const visible = all.slice(0, MAX_VISIBLE);
|
||||
if (visible.length < MAX_VISIBLE) visible.push(...allRecent.slice(0, MAX_VISIBLE - visible.length));
|
||||
const connections = visible.map(project);
|
||||
const recognized = all.filter(({ value }) => value.destination.domain !== null).length;
|
||||
return {
|
||||
apiVersion: 1,
|
||||
epoch,
|
||||
sequence,
|
||||
observedAt,
|
||||
capabilities: {
|
||||
lifecycle: true,
|
||||
deviceAttribution: Boolean(resolveOrigin),
|
||||
applicationAttribution: false,
|
||||
},
|
||||
source: {
|
||||
transport: 'native',
|
||||
state,
|
||||
completeness: 'lifecycle',
|
||||
singBoxVersion,
|
||||
singBoxApiVersion,
|
||||
error,
|
||||
unattributedUploadBytes: (explicitGapUpload + statusGapUpload).toString(),
|
||||
unattributedDownloadBytes: (explicitGapDownload + statusGapDownload).toString(),
|
||||
},
|
||||
summary: {
|
||||
active: all.length,
|
||||
recent: allRecent.length,
|
||||
visible: connections.length,
|
||||
recognized,
|
||||
unresolved: all.length - recognized,
|
||||
unresolvedOrigin: all.filter((connection) => project(connection).origin.kind === 'unknown').length,
|
||||
truncated: all.length + allRecent.length > MAX_VISIBLE,
|
||||
},
|
||||
connections,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function defaultClientFactory(port: number): NativeTrafficClient {
|
||||
return createClient(StartedService, createGrpcTransport({
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
timer.unref();
|
||||
});
|
||||
}
|
||||
|
||||
export function createLiveTrafficService({
|
||||
port,
|
||||
enabled,
|
||||
isRuntimeRunning,
|
||||
gateway = false,
|
||||
resolveOrigin,
|
||||
authorization,
|
||||
unavailableError = null,
|
||||
onProjection,
|
||||
clientFactory = defaultClientFactory,
|
||||
}: LiveTrafficServiceOptions) {
|
||||
const ledger = createLiveTrafficLedger({ enabled, gateway, resolveOrigin });
|
||||
if (!enabled && unavailableError) ledger.markUnavailable(unavailableError);
|
||||
let stopped = false;
|
||||
let controller: AbortController | null = null;
|
||||
let running: Promise<void> | null = null;
|
||||
let failedProjection: NativeTrafficProjectionBatch | null = null;
|
||||
let projectionQueue = Promise.resolve();
|
||||
|
||||
const project = (batch: NativeTrafficProjectionBatch) => {
|
||||
if (!onProjection) return Promise.resolve();
|
||||
const run = async () => {
|
||||
if (failedProjection) {
|
||||
const retry = failedProjection;
|
||||
try {
|
||||
await onProjection(retry);
|
||||
failedProjection = null;
|
||||
} catch (reason) {
|
||||
ledger.markProjectionError(reason);
|
||||
throw reason;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await onProjection(batch);
|
||||
ledger.markProjectionHealthy();
|
||||
} catch (reason) {
|
||||
failedProjection = batch;
|
||||
ledger.markProjectionError(reason);
|
||||
throw reason;
|
||||
}
|
||||
};
|
||||
const result = projectionQueue.then(run, run);
|
||||
projectionQueue = result.catch(() => undefined);
|
||||
return result;
|
||||
};
|
||||
|
||||
const attach = async () => {
|
||||
const client = clientFactory(port);
|
||||
const signal = controller?.signal;
|
||||
if (!signal) return;
|
||||
const secret = authorization?.();
|
||||
const options = secret ? { signal, headers: { authorization: `Bearer ${secret}` } } : { signal };
|
||||
const version = await client.getVersion({}, options);
|
||||
if (version.version !== SUPPORTED_SINGBOX_VERSION
|
||||
|| version.apiVersion !== SUPPORTED_SINGBOX_API_VERSION) {
|
||||
ledger.markIncompatible(version.version, version.apiVersion);
|
||||
return;
|
||||
}
|
||||
const started = await client.getStartedAt({}, options);
|
||||
ledger.beginEpoch(started.startedAt, version.version, version.apiVersion);
|
||||
let lastStatusAt = Date.now();
|
||||
const watchdog = setInterval(() => {
|
||||
if (!isRuntimeRunning() || Date.now() - lastStatusAt > STALE_MS) {
|
||||
controller?.abort(new Error(isRuntimeRunning() ? 'Native traffic status is stale' : 'sing-box stopped'));
|
||||
}
|
||||
}, RETRY_MS);
|
||||
watchdog.unref();
|
||||
const streams = [
|
||||
(async () => {
|
||||
for await (const batch of client.subscribeConnections({ interval: CONNECTION_INTERVAL }, options)) {
|
||||
const projection = ledger.applyConnections(batch);
|
||||
if (projection) await project(projection);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
for await (const status of client.subscribeStatus({ interval: CONNECTION_INTERVAL }, options)) {
|
||||
lastStatusAt = Date.now();
|
||||
const projection = ledger.applyStatus(status);
|
||||
if (projection) await project(projection);
|
||||
}
|
||||
})(),
|
||||
];
|
||||
try {
|
||||
await Promise.race(streams);
|
||||
throw new Error('Native traffic stream ended');
|
||||
} finally {
|
||||
controller?.abort(new Error('Native traffic stream ended'));
|
||||
await Promise.allSettled(streams);
|
||||
clearInterval(watchdog);
|
||||
}
|
||||
};
|
||||
|
||||
const loop = async () => {
|
||||
while (!stopped) {
|
||||
if (!isRuntimeRunning()) {
|
||||
ledger.markStopped();
|
||||
await delay(RETRY_MS);
|
||||
continue;
|
||||
}
|
||||
controller = new AbortController();
|
||||
try {
|
||||
await attach();
|
||||
} catch (reason) {
|
||||
if (!stopped) {
|
||||
if (isRuntimeRunning()) ledger.markTransportError(reason);
|
||||
else ledger.markStopped();
|
||||
}
|
||||
} finally {
|
||||
controller = null;
|
||||
}
|
||||
if (!stopped) await delay(RETRY_MS);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (!enabled || running) return;
|
||||
running = loop();
|
||||
},
|
||||
async stop() {
|
||||
stopped = true;
|
||||
controller?.abort();
|
||||
await running;
|
||||
},
|
||||
snapshot: ledger.snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export type LiveTrafficService = ReturnType<typeof createLiveTrafficService>;
|
||||
+22
-2
@@ -99,6 +99,8 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
routeRules = [],
|
||||
}: { clientDirect?: boolean; routeRules?: unknown } = {}) {
|
||||
const clientMode = settings.appMode === 'client';
|
||||
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
const directClient = clientMode && clientDirect;
|
||||
const vpnOutbound = selectedOutbound(subscriptionConfig, selectedTag);
|
||||
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
||||
@@ -158,13 +160,21 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
|
||||
|
||||
return {
|
||||
log: { level: settings.logLevel, timestamp: true },
|
||||
...(nativeTraffic ? {
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: settings.singboxNativeApiPort,
|
||||
dashboard: false,
|
||||
}],
|
||||
} : {}),
|
||||
experimental: {
|
||||
cache_file: { enabled: true, path: settings.cachePath },
|
||||
...(!clientMode ? {
|
||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||
} : {}),
|
||||
},
|
||||
dns: { independent_cache: true },
|
||||
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||
inbounds,
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
@@ -185,6 +195,8 @@ export function buildDualChannelGatewayConfig(
|
||||
{ routeRules = [], defaultRole = 'primary' }: { routeRules?: unknown; defaultRole?: 'primary' | 'reserve' } = {},
|
||||
) {
|
||||
if (settings.appMode === 'client') throw new Error('Dual-channel config доступен только Gateway');
|
||||
const nativeTraffic = settings.singboxTrafficSource === 'native'
|
||||
|| settings.singboxTrafficSource === 'shadow';
|
||||
const primary = selectedOutbound(
|
||||
channels.primary.subscriptionConfig,
|
||||
channels.primary.selectedServerId,
|
||||
@@ -204,11 +216,19 @@ export function buildDualChannelGatewayConfig(
|
||||
const userInbounds = [TPROXY_INBOUND, MIXED_INBOUND];
|
||||
return {
|
||||
log: { level: settings.logLevel, timestamp: true },
|
||||
...(nativeTraffic ? {
|
||||
services: [{
|
||||
type: 'api',
|
||||
listen: '127.0.0.1',
|
||||
listen_port: settings.singboxNativeApiPort,
|
||||
dashboard: false,
|
||||
}],
|
||||
} : {}),
|
||||
experimental: {
|
||||
cache_file: { enabled: true, path: settings.cachePath },
|
||||
clash_api: { external_controller: `127.0.0.1:${settings.singboxApiPort}` },
|
||||
},
|
||||
dns: { independent_cache: true },
|
||||
dns: nativeTraffic ? {} : { independent_cache: true },
|
||||
inbounds: [
|
||||
{ type: 'tproxy', tag: TPROXY_INBOUND, listen: '::', listen_port: settings.tproxyPort },
|
||||
{ type: 'mixed', tag: MIXED_INBOUND, listen: settings.bindIp, listen_port: settings.proxyPort, set_system_proxy: false },
|
||||
|
||||
@@ -3,33 +3,64 @@ import fs from 'node:fs';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import { setGatewayInterception } from './gatewayRouting.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import {
|
||||
materializeGatewayNativeConfig,
|
||||
materializeGatewaySnapshotConfig,
|
||||
} from './gatewayNativeRuntime.js';
|
||||
|
||||
export function createSingboxRuntime({
|
||||
configPath,
|
||||
gateway = false,
|
||||
tproxyChain = '',
|
||||
gatewayRuntimeConfigPath,
|
||||
nativeApi,
|
||||
}: {
|
||||
configPath: string;
|
||||
gateway?: boolean;
|
||||
tproxyChain?: string;
|
||||
gatewayRuntimeConfigPath?: string;
|
||||
nativeApi?: {
|
||||
apiPort: number;
|
||||
secretPath: string;
|
||||
runtimeConfigPath: string;
|
||||
};
|
||||
}) {
|
||||
let child: ChildProcess | null = null;
|
||||
let configHash = '';
|
||||
let startedAt: string | null = null;
|
||||
let nativeApiSecret: string | null = null;
|
||||
let nativeApiWarning: string | null = null;
|
||||
|
||||
const state = () => ({ running: Boolean(child), startedAt });
|
||||
const state = () => ({ running: Boolean(child), startedAt, nativeApiWarning });
|
||||
|
||||
function checked(configFile: string) {
|
||||
const check = spawnSync('sing-box', ['check', '-c', configFile], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkConfig(config: unknown) {
|
||||
if (nativeApi) {
|
||||
const materialized = materializeGatewayNativeConfig(config, nativeApi);
|
||||
checked(materialized.configPath);
|
||||
return {
|
||||
valid: true,
|
||||
...(materialized.warning ? { warning: materialized.warning } : {}),
|
||||
};
|
||||
}
|
||||
if (gatewayRuntimeConfigPath) {
|
||||
const materialized = materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath);
|
||||
checked(materialized.configPath);
|
||||
return { valid: true };
|
||||
}
|
||||
const directory = fs.mkdtempSync(`${configPath}.check-`);
|
||||
const candidatePath = `${directory}/config.json`;
|
||||
try {
|
||||
fs.writeFileSync(candidatePath, JSON.stringify(config));
|
||||
const check = spawnSync('sing-box', ['check', '-c', candidatePath], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
}
|
||||
checked(candidatePath);
|
||||
return { valid: true };
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
@@ -41,6 +72,7 @@ export function createSingboxRuntime({
|
||||
if (!child) {
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
@@ -48,6 +80,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
current.kill('SIGKILL');
|
||||
@@ -65,23 +98,37 @@ export function createSingboxRuntime({
|
||||
async function apply({ force = false } = {}) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
await stop();
|
||||
nativeApiWarning = null;
|
||||
return state();
|
||||
}
|
||||
|
||||
const check = spawnSync('sing-box', ['check', '-c', configPath], { encoding: 'utf8' });
|
||||
if (check.status !== 0) {
|
||||
throw new HarborError('CONFIG_INVALID', {
|
||||
cause: new Error((check.stderr || check.stdout || check.error?.message || 'sing-box check failed').trim()),
|
||||
});
|
||||
let materialized = { configPath, secret: null as string | null, warning: null as string | null };
|
||||
if (nativeApi || gatewayRuntimeConfigPath) {
|
||||
let config: unknown;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch (cause) {
|
||||
throw new HarborError('CONFIG_INVALID', { cause });
|
||||
}
|
||||
materialized = nativeApi
|
||||
? materializeGatewayNativeConfig(config, nativeApi)
|
||||
: materializeGatewaySnapshotConfig(config, gatewayRuntimeConfigPath!);
|
||||
}
|
||||
checked(materialized.configPath);
|
||||
|
||||
const nextHash = crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex');
|
||||
if (!force && child && nextHash === configHash) return state();
|
||||
const nextHash = crypto.createHash('sha256')
|
||||
.update(fs.readFileSync(materialized.configPath))
|
||||
.digest('hex');
|
||||
if (!force && child && nextHash === configHash) {
|
||||
nativeApiSecret = materialized.secret;
|
||||
nativeApiWarning = materialized.warning;
|
||||
return state();
|
||||
}
|
||||
|
||||
await stop();
|
||||
let current: ChildProcess;
|
||||
try {
|
||||
current = spawn('sing-box', ['run', '-c', configPath], {
|
||||
current = spawn('sing-box', ['run', '-c', materialized.configPath], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -94,6 +141,8 @@ export function createSingboxRuntime({
|
||||
child = current;
|
||||
configHash = nextHash;
|
||||
startedAt = new Date().toISOString();
|
||||
nativeApiSecret = materialized.secret;
|
||||
nativeApiWarning = materialized.warning;
|
||||
try {
|
||||
if (gateway) setGatewayInterception(true, tproxyChain);
|
||||
} catch (error) {
|
||||
@@ -101,6 +150,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
throw new HarborError('PROCESS_START_FAILED', { cause: error });
|
||||
}
|
||||
current.once('exit', () => {
|
||||
@@ -108,6 +158,7 @@ export function createSingboxRuntime({
|
||||
child = null;
|
||||
configHash = '';
|
||||
startedAt = null;
|
||||
nativeApiSecret = null;
|
||||
if (gateway) setGatewayInterception(false, tproxyChain);
|
||||
});
|
||||
return state();
|
||||
@@ -116,6 +167,8 @@ export function createSingboxRuntime({
|
||||
return {
|
||||
get running() { return Boolean(child); },
|
||||
get startedAt() { return startedAt; },
|
||||
get nativeApiSecret() { return nativeApiSecret; },
|
||||
get nativeApiWarning() { return nativeApiWarning; },
|
||||
refresh: async () => state(),
|
||||
checkConfig,
|
||||
apply,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
export type LiveTrafficSourceState =
|
||||
| 'connecting'
|
||||
| 'live'
|
||||
| 'degraded'
|
||||
| 'stale'
|
||||
| 'stopped'
|
||||
| 'incompatible'
|
||||
| 'disabled';
|
||||
|
||||
export interface LiveTrafficConnection {
|
||||
id: string;
|
||||
startedAt: string;
|
||||
closedAt: string | null;
|
||||
inbound: { tag: string; type: string };
|
||||
network: 'tcp' | 'udp' | 'unknown';
|
||||
protocol: string | null;
|
||||
source: { ip: string; port: number | null };
|
||||
destination: {
|
||||
domain: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
provenance: 'sing-box' | 'unknown';
|
||||
};
|
||||
origin: {
|
||||
kind: 'this-mac' | 'device' | 'unknown';
|
||||
id: string | null;
|
||||
label: string;
|
||||
provenance: 'client-runtime' | 'source-ip' | 'unknown';
|
||||
};
|
||||
route: {
|
||||
kind: 'vpn' | 'direct' | 'other';
|
||||
scope: 'local-sing-box';
|
||||
outbound: string | null;
|
||||
outboundType: string | null;
|
||||
chain: string[];
|
||||
rule: string | null;
|
||||
};
|
||||
traffic: {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
uploadBytesPerSecond: string;
|
||||
downloadBytesPerSecond: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LiveTrafficSnapshot {
|
||||
apiVersion: 1;
|
||||
epoch: string | null;
|
||||
sequence: number;
|
||||
observedAt: string | null;
|
||||
capabilities: {
|
||||
lifecycle: true;
|
||||
deviceAttribution: boolean;
|
||||
applicationAttribution: false;
|
||||
};
|
||||
source: {
|
||||
transport: 'native';
|
||||
state: LiveTrafficSourceState;
|
||||
completeness: 'lifecycle';
|
||||
singBoxVersion: string | null;
|
||||
singBoxApiVersion: number | null;
|
||||
error: string | null;
|
||||
unattributedUploadBytes: string;
|
||||
unattributedDownloadBytes: string;
|
||||
};
|
||||
summary: {
|
||||
active: number;
|
||||
recent: number;
|
||||
visible: number;
|
||||
recognized: number;
|
||||
unresolved: number;
|
||||
unresolvedOrigin: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
connections: LiveTrafficConnection[];
|
||||
}
|
||||
|
||||
const sourceStates = new Set<LiveTrafficSourceState>([
|
||||
'connecting', 'live', 'degraded', 'stale', 'stopped', 'incompatible', 'disabled',
|
||||
]);
|
||||
const decimal = /^\d+$/;
|
||||
|
||||
function isoTimestamp(value: unknown) {
|
||||
return typeof value === 'string'
|
||||
&& !Number.isNaN(Date.parse(value))
|
||||
&& new Date(value).toISOString() === value;
|
||||
}
|
||||
|
||||
function decimalString(value: unknown) {
|
||||
return typeof value === 'string' && decimal.test(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object');
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
if (value !== null && typeof value !== 'string') throw new Error('Expected nullable string');
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown) {
|
||||
if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error('Expected non-negative integer');
|
||||
}
|
||||
|
||||
function nullablePort(value: unknown) {
|
||||
if (value !== null && (!Number.isInteger(value) || Number(value) < 0 || Number(value) > 65_535)) {
|
||||
throw new Error('Expected nullable port');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLiveTrafficSnapshot(value: unknown): LiveTrafficSnapshot {
|
||||
const snapshot = record(value);
|
||||
if (snapshot.apiVersion !== 1) throw new Error('Expected live traffic apiVersion 1');
|
||||
nullableString(snapshot.epoch);
|
||||
nullableString(snapshot.observedAt);
|
||||
nonNegativeInteger(snapshot.sequence);
|
||||
|
||||
const capabilities = record(snapshot.capabilities);
|
||||
if (capabilities.lifecycle !== true || typeof capabilities.deviceAttribution !== 'boolean'
|
||||
|| capabilities.applicationAttribution !== false) throw new Error('Invalid traffic capabilities');
|
||||
|
||||
const source = record(snapshot.source);
|
||||
if (source.transport !== 'native' || source.completeness !== 'lifecycle'
|
||||
|| !sourceStates.has(source.state as LiveTrafficSourceState)) throw new Error('Invalid traffic source');
|
||||
nullableString(source.singBoxVersion);
|
||||
nullableString(source.error);
|
||||
if (source.singBoxApiVersion !== null) nonNegativeInteger(source.singBoxApiVersion);
|
||||
if (!decimalString(source.unattributedUploadBytes)
|
||||
|| !decimalString(source.unattributedDownloadBytes)) throw new Error('Invalid traffic gap');
|
||||
|
||||
const summary = record(snapshot.summary);
|
||||
for (const field of ['active', 'recent', 'visible', 'recognized', 'unresolved', 'unresolvedOrigin']) {
|
||||
nonNegativeInteger(summary[field]);
|
||||
}
|
||||
if (typeof summary.truncated !== 'boolean') throw new Error('Invalid traffic summary');
|
||||
if (!Array.isArray(snapshot.connections) || snapshot.connections.length > 256) {
|
||||
throw new Error('Invalid traffic connection list');
|
||||
}
|
||||
const activeTotal = Number(summary.active);
|
||||
const recentTotal = Number(summary.recent);
|
||||
const visibleTotal = Number(summary.visible);
|
||||
const expectedVisible = Math.min(256, activeTotal + recentTotal);
|
||||
if (visibleTotal !== snapshot.connections.length
|
||||
|| visibleTotal !== expectedVisible
|
||||
|| Number(summary.recognized) + Number(summary.unresolved) !== Number(summary.active)
|
||||
|| Number(summary.unresolvedOrigin) > Number(summary.active)
|
||||
|| summary.truncated !== (activeTotal + recentTotal > visibleTotal)) {
|
||||
throw new Error('Inconsistent traffic summary');
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
let visibleActive = 0;
|
||||
let visibleRecent = 0;
|
||||
let recentSeen = false;
|
||||
for (const rawConnection of snapshot.connections) {
|
||||
const connection = record(rawConnection);
|
||||
if (typeof connection.id !== 'string' || !connection.id
|
||||
|| ids.has(connection.id)
|
||||
|| !isoTimestamp(connection.startedAt)
|
||||
|| (connection.closedAt !== null && !isoTimestamp(connection.closedAt))) {
|
||||
throw new Error('Invalid traffic connection identity');
|
||||
}
|
||||
ids.add(connection.id);
|
||||
if (connection.closedAt === null) {
|
||||
if (recentSeen) throw new Error('Inconsistent traffic summary');
|
||||
visibleActive += 1;
|
||||
} else {
|
||||
recentSeen = true;
|
||||
visibleRecent += 1;
|
||||
}
|
||||
const inbound = record(connection.inbound);
|
||||
const sourceAddress = record(connection.source);
|
||||
const destination = record(connection.destination);
|
||||
const origin = record(connection.origin);
|
||||
const route = record(connection.route);
|
||||
const traffic = record(connection.traffic);
|
||||
if (typeof inbound.tag !== 'string' || typeof inbound.type !== 'string'
|
||||
|| !['tcp', 'udp', 'unknown'].includes(String(connection.network))
|
||||
|| (connection.protocol !== null && typeof connection.protocol !== 'string')
|
||||
|| typeof sourceAddress.ip !== 'string'
|
||||
|| (destination.domain !== null && typeof destination.domain !== 'string')
|
||||
|| (destination.ip !== null && typeof destination.ip !== 'string')
|
||||
|| !['sing-box', 'unknown'].includes(String(destination.provenance))
|
||||
|| !['this-mac', 'device', 'unknown'].includes(String(origin.kind))
|
||||
|| (origin.id !== null && typeof origin.id !== 'string')
|
||||
|| typeof origin.label !== 'string'
|
||||
|| !['client-runtime', 'source-ip', 'unknown'].includes(String(origin.provenance))
|
||||
|| !['vpn', 'direct', 'other'].includes(String(route.kind))
|
||||
|| route.scope !== 'local-sing-box'
|
||||
|| (route.outbound !== null && typeof route.outbound !== 'string')
|
||||
|| (route.outboundType !== null && typeof route.outboundType !== 'string')
|
||||
|| (route.rule !== null && typeof route.rule !== 'string')
|
||||
|| !Array.isArray(route.chain) || !route.chain.every((item) => typeof item === 'string')) {
|
||||
throw new Error('Invalid traffic connection');
|
||||
}
|
||||
nullablePort(sourceAddress.port);
|
||||
nullablePort(destination.port);
|
||||
for (const field of ['uploadBytes', 'downloadBytes', 'uploadBytesPerSecond', 'downloadBytesPerSecond']) {
|
||||
if (!decimalString(traffic[field])) throw new Error('Invalid traffic byte value');
|
||||
}
|
||||
if (connection.closedAt !== null
|
||||
&& (traffic.uploadBytesPerSecond !== '0' || traffic.downloadBytesPerSecond !== '0')) {
|
||||
throw new Error('Invalid closed traffic rate');
|
||||
}
|
||||
}
|
||||
if (visibleActive !== Math.min(activeTotal, visibleTotal)
|
||||
|| visibleRecent !== visibleTotal - visibleActive
|
||||
|| visibleRecent > recentTotal) {
|
||||
throw new Error('Inconsistent traffic summary');
|
||||
}
|
||||
return value as LiveTrafficSnapshot;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.33.0',
|
||||
gatewayClient: '0.34.0',
|
||||
gatewayBackend: '0.34.0',
|
||||
macClient: '0.34.0',
|
||||
gatewayClient: '0.36.0',
|
||||
gatewayBackend: '0.36.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -29,6 +29,7 @@ const componentActions = {
|
||||
pingServers: api.servers.ping,
|
||||
runConnectivityDiagnostics: api.diagnostics.connectivity,
|
||||
loadActivityJournal: api.activityJournal.page,
|
||||
loadLiveTraffic: api.traffic.live,
|
||||
};
|
||||
|
||||
interface UiError {
|
||||
|
||||
@@ -230,6 +230,9 @@ export const api = {
|
||||
activityJournal: {
|
||||
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
|
||||
},
|
||||
traffic: {
|
||||
live: () => request('/api/traffic/live'),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
InstructionsToggle,
|
||||
useInstructionsFeature,
|
||||
} from '../features/instructions/index.js';
|
||||
import {
|
||||
TrafficPanel,
|
||||
TrafficToggle,
|
||||
useTrafficFeature,
|
||||
} from '../features/traffic/index.js';
|
||||
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
|
||||
import {
|
||||
ActivityJournalPanel,
|
||||
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
|
||||
] as const;
|
||||
|
||||
const DRAWER_SWITCH_MS = 620;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
|
||||
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
|
||||
type DrawerKey = typeof DRAWER_ORDER[number];
|
||||
|
||||
const failoverReasonLabel = (reason: string | null) => ({
|
||||
@@ -128,6 +133,7 @@ interface ComponentActions {
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
|
||||
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClientViewState extends StateSnapshot {
|
||||
@@ -586,6 +592,11 @@ export function ClientOverviewPage({
|
||||
});
|
||||
const failoverFeature = useFailoverFeature();
|
||||
const activityJournalFeature = useActivityJournalFeature();
|
||||
const trafficFeature = useTrafficFeature({
|
||||
enabled: true,
|
||||
isGateway,
|
||||
loadLiveTraffic: actions.loadLiveTraffic,
|
||||
});
|
||||
const diagnosticsAvailable = hasSubscription;
|
||||
const drawerControls = {
|
||||
subscription: {
|
||||
@@ -612,6 +623,12 @@ export function ClientOverviewPage({
|
||||
show: devicesFeature.toggle,
|
||||
close: devicesFeature.close,
|
||||
},
|
||||
traffic: {
|
||||
isOpen: trafficFeature.isOpen,
|
||||
panelRef: trafficFeature.panelRef,
|
||||
show: trafficFeature.toggle,
|
||||
close: trafficFeature.close,
|
||||
},
|
||||
diagnostics: {
|
||||
isOpen: diagnosticsFeature.isOpen,
|
||||
panelRef: diagnosticsFeature.panelRef,
|
||||
@@ -654,8 +671,9 @@ export function ClientOverviewPage({
|
||||
diagnosticsFeature.close();
|
||||
failoverFeature.close();
|
||||
activityJournalFeature.close();
|
||||
trafficFeature.close();
|
||||
}
|
||||
}, [hasSubscription, isGateway]);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!diagnosticsAvailable) diagnosticsFeature.close();
|
||||
@@ -851,6 +869,11 @@ export function ClientOverviewPage({
|
||||
open={activeRailDrawer === 'devices'}
|
||||
onToggle={() => switchDrawer('devices')}
|
||||
/>}
|
||||
<TrafficToggle
|
||||
feature={trafficFeature}
|
||||
open={activeRailDrawer === 'traffic'}
|
||||
onToggle={() => switchDrawer('traffic')}
|
||||
/>
|
||||
<DiagnosticsToggle
|
||||
feature={diagnosticsFeature}
|
||||
open={activeRailDrawer === 'diagnostics'}
|
||||
@@ -936,6 +959,8 @@ export function ClientOverviewPage({
|
||||
|
||||
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
||||
|
||||
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
|
||||
|
||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||
feature={diagnosticsFeature}
|
||||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficConnection,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { formatByteString } from '../../utils/format.js';
|
||||
import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
trafficGroupMatches,
|
||||
type DisplayedTrafficGroup,
|
||||
type TrafficConnectionGroup,
|
||||
type TrafficQualityFilter,
|
||||
type TrafficRouteFilter,
|
||||
} from './trafficRows.js';
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
|
||||
const RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||
|
||||
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
||||
|
||||
interface TrafficFeatureOptions {
|
||||
enabled: boolean;
|
||||
isGateway: boolean;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||
vpn: 'VPN',
|
||||
direct: 'Direct',
|
||||
other: 'Другое',
|
||||
};
|
||||
|
||||
function storedRetentionSeconds(): RetentionSeconds {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
|
||||
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function address(ip: string | null, port: number | null) {
|
||||
if (!ip) return '—';
|
||||
return port === null ? ip : `${ip}:${port}`;
|
||||
}
|
||||
|
||||
function updatedAt(value: string | null | undefined) {
|
||||
if (!value) return 'обновлений ещё нет';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? 'время неизвестно'
|
||||
: `обновлено ${new Intl.DateTimeFormat('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||
const [requestState, setRequestState] = useState<RequestState>('idle');
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOpen || paused) return undefined;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setRequestState((current) => current === 'idle' ? 'loading' : current);
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
|
||||
if (!cancelled) {
|
||||
setSnapshot(next);
|
||||
setRequestState('ready');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setRequestState('error');
|
||||
} finally {
|
||||
if (!cancelled) timer = setTimeout(poll, POLL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [enabled, isOpen, paused, loadLiveTraffic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeTraffic = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
|
||||
)) return;
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeTraffic);
|
||||
document.addEventListener('keydown', closeTraffic);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeTraffic);
|
||||
document.removeEventListener('keydown', closeTraffic);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
function close() {
|
||||
setIsOpen(false);
|
||||
setPaused(false);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isOpen) close();
|
||||
else if (enabled) setIsOpen(true);
|
||||
}
|
||||
|
||||
return {
|
||||
isGateway,
|
||||
isOpen,
|
||||
paused,
|
||||
snapshot,
|
||||
requestState,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
close,
|
||||
toggle,
|
||||
togglePause: () => setPaused((current) => !current),
|
||||
};
|
||||
}
|
||||
|
||||
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
|
||||
|
||||
export function TrafficToggle({
|
||||
feature,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
feature: TrafficFeature;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return <RailAction
|
||||
buttonRef={feature.toggleRef}
|
||||
className="client-traffic-toggle"
|
||||
open={open}
|
||||
controls="client-traffic"
|
||||
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
|
||||
label="Трафик"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3.5 19.5h17" />
|
||||
<path d="m5 16 4-4 3 2 6-7" />
|
||||
<circle cx="5" cy="16" r=".7" />
|
||||
<circle cx="9" cy="12" r=".7" />
|
||||
<circle cx="12" cy="14" r=".7" />
|
||||
<circle cx="18" cy="7" r=".7" />
|
||||
</svg>
|
||||
</RailAction>;
|
||||
}
|
||||
|
||||
function groupStatus(group: TrafficConnectionGroup) {
|
||||
if (group.connections.length === 1) {
|
||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
||||
}
|
||||
const states = [];
|
||||
if (group.activeCount > 0) states.push(`Активно: ${group.activeCount}`);
|
||||
if (group.recentCount > 0) {
|
||||
states.push(`${group.activeCount > 0 ? 'завершено' : 'Завершено'}: ${group.recentCount}`);
|
||||
}
|
||||
states.push(group.protocol);
|
||||
return states.join(' · ');
|
||||
}
|
||||
|
||||
function groupDestination(group: TrafficConnectionGroup) {
|
||||
const { domain, ip, port } = group.destination;
|
||||
if (!domain) return address(ip, port);
|
||||
if (group.destinationIps.length === 1) return `${domain} · ${address(group.destinationIps[0], port)}`;
|
||||
if (group.destinationIps.length > 1) {
|
||||
return `${domain} · IP: ${group.destinationIps.length}${port === null ? '' : ` · порт ${port}`}`;
|
||||
}
|
||||
return port === null ? domain : `${domain} · порт ${port}`;
|
||||
}
|
||||
|
||||
function TrafficGroupRow({
|
||||
group,
|
||||
expanded,
|
||||
exiting,
|
||||
onExited,
|
||||
onToggle,
|
||||
}: {
|
||||
group: TrafficConnectionGroup;
|
||||
expanded: boolean;
|
||||
exiting: boolean;
|
||||
onExited: () => void;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const detailsId = useId();
|
||||
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||
const source = onlyConnection
|
||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||
const chain = group.route.chain.length
|
||||
? group.route.chain.join(' → ')
|
||||
: group.route.outbound || '—';
|
||||
|
||||
return <div
|
||||
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
|
||||
role="listitem"
|
||||
inert={exiting || undefined}
|
||||
aria-hidden={exiting || undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
|
||||
onExited();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="client-traffic-connection-summary"
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={detailsId}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="client-traffic-identity">
|
||||
<strong aria-label={group.connections.length > 1
|
||||
? `${group.label}, соединений: ${group.connections.length}`
|
||||
: undefined}
|
||||
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
|
||||
<small>{groupStatus(group)}</small>
|
||||
</span>
|
||||
<span className="client-traffic-route" data-route={group.route.kind}>
|
||||
{routeLabels[group.route.kind]}
|
||||
</span>
|
||||
<span className="client-traffic-values">
|
||||
{group.activeCount > 0 && <strong>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
|
||||
</strong>}
|
||||
<small>
|
||||
<span>↓ {formatByteString(group.traffic.downloadBytes)}</span>
|
||||
<span>↑ {formatByteString(group.traffic.uploadBytes)}</span>
|
||||
</small>
|
||||
</span>
|
||||
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="m5 6 3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
|
||||
</dl>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TrafficState({ feature }: { feature: TrafficFeature }) {
|
||||
const { snapshot, requestState } = feature;
|
||||
const sourceState = snapshot?.source.state;
|
||||
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
|
||||
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
|
||||
{[0, 1, 2, 3].map((item) => <span key={item} />)}
|
||||
</div>;
|
||||
}
|
||||
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
|
||||
if (sourceState === 'disabled') {
|
||||
return <p className="client-traffic-state" role="status">{feature.isGateway
|
||||
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
|
||||
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
|
||||
}
|
||||
if (sourceState === 'incompatible') {
|
||||
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
|
||||
}
|
||||
if (sourceState === 'stopped') {
|
||||
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
|
||||
}
|
||||
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
|
||||
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика…</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
|
||||
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
));
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const snapshot = feature.snapshot;
|
||||
const sourceState = snapshot?.source.state;
|
||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
||||
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
|
||||
const groups = useMemo(() => trafficGroups.filter((group) => (
|
||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
||||
)), [trafficGroups, query, routeFilter, qualityFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReducedMotion(media.matches);
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const immediate = reducedMotion
|
||||
|| !snapshot
|
||||
|| ['disabled', 'incompatible', 'stopped'].includes(sourceState || '');
|
||||
if (immediate) {
|
||||
const desiredIds = new Set(groups.map((group) => group.id));
|
||||
setExpandedId((current) => desiredIds.has(current) ? current : '');
|
||||
}
|
||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
||||
|
||||
function selectRetention(seconds: RetentionSeconds) {
|
||||
setRetentionSeconds(seconds);
|
||||
try {
|
||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
||||
} catch {
|
||||
// The setting remains available for this session when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function finishExit(id: string) {
|
||||
setDisplayedGroups((current) => current.filter((row) => (
|
||||
row.group.id !== id || !row.exiting
|
||||
)));
|
||||
setExpandedId((current) => current === id ? '' : current);
|
||||
}
|
||||
|
||||
const canShowList = snapshot
|
||||
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
|
||||
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
|
||||
const stale = feature.requestState === 'error' || sourceState === 'stale';
|
||||
const degraded = sourceState === 'degraded';
|
||||
|
||||
return <Drawer
|
||||
panelRef={feature.panelRef}
|
||||
closeRef={feature.closeRef}
|
||||
id="client-traffic"
|
||||
className="client-traffic"
|
||||
sheetClassName="client-traffic-sheet"
|
||||
open={feature.isOpen}
|
||||
labelledBy="client-traffic-title"
|
||||
closeLabel="Закрыть трафик"
|
||||
onClose={feature.close}
|
||||
>
|
||||
<header className="client-traffic-header">
|
||||
<div className="client-traffic-meta">
|
||||
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
|
||||
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={feature.paused}
|
||||
onClick={feature.togglePause}
|
||||
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
|
||||
</div>
|
||||
<h2 id="client-traffic-title">Трафик</h2>
|
||||
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
|
||||
</header>
|
||||
|
||||
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
|
||||
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
|
||||
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
|
||||
</div>}
|
||||
|
||||
<div className="client-traffic-tools">
|
||||
<label className="client-traffic-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="10.5" cy="10.5" r="6" />
|
||||
<path d="m15 15 5 5" />
|
||||
</svg>
|
||||
<span className="client-live-region">Поиск трафика</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти домен, сервис или IP"
|
||||
placeholder="Домен, сервис или IP"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['vpn', 'VPN'],
|
||||
['direct', 'Direct'],
|
||||
['other', 'Другое'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={routeFilter === value}
|
||||
onClick={() => setRouteFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['recognized', 'Распознано'],
|
||||
['attention', 'Требует внимания'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={qualityFilter === value}
|
||||
onClick={() => setQualityFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Показывать завершённые</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||
{RETENTION_OPTIONS.map((seconds) => <button
|
||||
type="button"
|
||||
key={seconds}
|
||||
aria-pressed={retentionSeconds === seconds}
|
||||
onClick={() => selectRetention(seconds)}
|
||||
>{seconds} с</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||
</p>}
|
||||
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Данные временно не обновляются. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
|
||||
className="client-traffic-notice"
|
||||
role="status"
|
||||
>
|
||||
Инспектор переподключается. Показан последний полученный снимок.
|
||||
</p>}
|
||||
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
|
||||
Часть трафика не удалось сопоставить с соединениями: ↓ {formatByteString(snapshot.source.unattributedDownloadBytes)} · ↑ {formatByteString(snapshot.source.unattributedUploadBytes)}.
|
||||
</p>}
|
||||
|
||||
<TrafficState feature={feature} />
|
||||
|
||||
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
Активных соединений пока нет.
|
||||
</p>}
|
||||
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
|
||||
По выбранным фильтрам ничего не найдено.
|
||||
</p>}
|
||||
{canShowList && displayedGroups.length > 0 && <div
|
||||
className="client-traffic-list"
|
||||
role="list"
|
||||
aria-label="Группы активных и недавно завершённых соединений"
|
||||
aria-busy={feature.requestState === 'loading'}
|
||||
>
|
||||
{displayedGroups.map((row) => <TrafficGroupRow
|
||||
key={row.group.id}
|
||||
group={row.group}
|
||||
expanded={expandedId === row.group.id}
|
||||
exiting={row.exiting}
|
||||
onExited={() => finishExit(row.group.id)}
|
||||
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
|
||||
/>)}
|
||||
</div>}
|
||||
|
||||
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
|
||||
Снимок ограничен 256 соединениями; активные показаны первыми.
|
||||
</p>}
|
||||
<p className="client-traffic-honesty">
|
||||
{feature.isGateway
|
||||
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
|
||||
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
|
||||
</p>
|
||||
</Drawer>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TrafficPanel,
|
||||
TrafficToggle,
|
||||
useTrafficFeature,
|
||||
type TrafficFeature,
|
||||
} from './TrafficFeature.js';
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||
import { byteString } from '../../utils/format.js';
|
||||
|
||||
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
||||
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
||||
|
||||
export interface TrafficConnectionGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
connections: LiveTrafficConnection[];
|
||||
activeCount: number;
|
||||
recentCount: number;
|
||||
protocol: string;
|
||||
route: LiveTrafficConnection['route'];
|
||||
origin: LiveTrafficConnection['origin'];
|
||||
destination: LiveTrafficConnection['destination'];
|
||||
destinationIps: string[];
|
||||
traffic: LiveTrafficConnection['traffic'];
|
||||
}
|
||||
|
||||
export interface DisplayedTrafficGroup {
|
||||
group: TrafficConnectionGroup;
|
||||
exiting: boolean;
|
||||
}
|
||||
|
||||
function normalizedDestination(connection: LiveTrafficConnection) {
|
||||
const domain = connection.destination.domain?.trim().toLowerCase() || null;
|
||||
const ip = connection.destination.ip?.trim().toLowerCase() || null;
|
||||
return { domain, ip };
|
||||
}
|
||||
|
||||
function trafficGroupId(connection: LiveTrafficConnection) {
|
||||
const { domain, ip } = normalizedDestination(connection);
|
||||
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
||||
return JSON.stringify([
|
||||
destination,
|
||||
connection.destination.port,
|
||||
connection.network,
|
||||
connection.protocol?.trim().toLowerCase() || null,
|
||||
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
|
||||
[
|
||||
connection.route.kind,
|
||||
connection.route.scope,
|
||||
connection.route.outbound,
|
||||
connection.route.outboundType,
|
||||
connection.route.chain,
|
||||
connection.route.rule,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): TrafficConnectionGroup {
|
||||
const first = connections[0];
|
||||
const { domain, ip } = normalizedDestination(first);
|
||||
const active = connections.filter(({ closedAt }) => closedAt === null);
|
||||
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
||||
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
||||
.toString();
|
||||
return {
|
||||
id,
|
||||
label: domain || ip || 'Назначение не определено',
|
||||
connections,
|
||||
activeCount: active.length,
|
||||
recentCount: connections.length - active.length,
|
||||
protocol: first.protocol || first.network.toUpperCase(),
|
||||
route: first.route,
|
||||
origin: first.origin,
|
||||
destination: { ...first.destination, domain, ip },
|
||||
destinationIps: [...new Set(connections.flatMap(({ destination }) => {
|
||||
const address = destination.ip?.trim().toLowerCase();
|
||||
return address ? [address] : [];
|
||||
}))].sort((left, right) => left.localeCompare(right)),
|
||||
traffic: {
|
||||
uploadBytes: sum('uploadBytes'),
|
||||
downloadBytes: sum('downloadBytes'),
|
||||
uploadBytesPerSecond: sum('uploadBytesPerSecond', active),
|
||||
downloadBytesPerSecond: sum('downloadBytesPerSecond', active),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
||||
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||
for (const connection of connections) {
|
||||
const id = trafficGroupId(connection);
|
||||
const members = grouped.get(id);
|
||||
if (members) members.push(connection);
|
||||
else grouped.set(id, [connection]);
|
||||
}
|
||||
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
||||
}
|
||||
|
||||
export function trafficGroupMatches(
|
||||
group: TrafficConnectionGroup,
|
||||
query: string,
|
||||
route: TrafficRouteFilter,
|
||||
quality: TrafficQualityFilter,
|
||||
) {
|
||||
if (route !== 'all' && group.route.kind !== route) return false;
|
||||
const recognized = group.destination.domain !== null;
|
||||
if (quality === 'recognized' && !recognized) return false;
|
||||
if (quality === 'attention' && recognized) return false;
|
||||
const needle = query.trim().toLocaleLowerCase('ru-RU');
|
||||
if (!needle) return true;
|
||||
return group.connections.some((connection) => [
|
||||
connection.destination.domain,
|
||||
connection.destination.ip,
|
||||
connection.source.ip,
|
||||
connection.protocol,
|
||||
connection.inbound.tag,
|
||||
connection.inbound.type,
|
||||
connection.route.outbound,
|
||||
connection.route.outboundType,
|
||||
...connection.route.chain,
|
||||
].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle)));
|
||||
}
|
||||
|
||||
export function reconcileTrafficGroups(
|
||||
current: DisplayedTrafficGroup[],
|
||||
desired: TrafficConnectionGroup[],
|
||||
immediate: boolean,
|
||||
) {
|
||||
const next = desired.map((group) => ({ group, exiting: false }));
|
||||
if (immediate) return next;
|
||||
|
||||
const desiredIds = new Set(desired.map((group) => group.id));
|
||||
current.forEach((row, index) => {
|
||||
if (!desiredIds.has(row.group.id)) {
|
||||
next.splice(Math.min(index, next.length), 0, { ...row, exiting: true });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
.client-traffic-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.client-traffic-sheet {
|
||||
padding: 54px 72px 72px 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.client-traffic {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.client-traffic-header {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 0 8px 28px;
|
||||
}
|
||||
|
||||
.client-traffic-meta {
|
||||
min-height: 28px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.client-traffic-meta > span,
|
||||
.client-traffic-meta time {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-label);
|
||||
letter-spacing: var(--type-label-tracking);
|
||||
text-transform: var(--type-label-transform);
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-meta time {
|
||||
text-transform: var(--type-label-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-meta button {
|
||||
width: 96px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-meta button:hover,
|
||||
.client-traffic-meta button:focus-visible,
|
||||
.client-traffic-meta button[aria-pressed='true'] {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-meta button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-traffic-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font: var(--type-drawer-title);
|
||||
letter-spacing: var(--type-drawer-title-tracking);
|
||||
text-transform: var(--type-drawer-title-transform);
|
||||
}
|
||||
|
||||
.client-traffic-header p {
|
||||
max-width: 44ch;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-body);
|
||||
letter-spacing: var(--type-body-tracking);
|
||||
text-transform: var(--type-body-transform);
|
||||
}
|
||||
|
||||
.client-traffic-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 8px 24px;
|
||||
color: var(--client-text);
|
||||
font: var(--type-data);
|
||||
letter-spacing: var(--type-data-tracking);
|
||||
text-transform: var(--type-data-transform);
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-summary span {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.client-traffic-summary b {
|
||||
min-width: 112px;
|
||||
color: var(--client-muted);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.client-traffic-tools {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0 8px 22px;
|
||||
}
|
||||
|
||||
.client-traffic-search {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-traffic-search:focus-within {
|
||||
border-bottom-color: var(--client-accent);
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-search svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
}
|
||||
|
||||
.client-traffic-search input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: var(--type-body);
|
||||
letter-spacing: var(--type-body-tracking);
|
||||
text-transform: var(--type-body-transform);
|
||||
}
|
||||
|
||||
.client-traffic-search input::placeholder {
|
||||
color: var(--client-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.client-traffic-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 18px;
|
||||
}
|
||||
|
||||
.client-traffic-filters button {
|
||||
min-height: 36px;
|
||||
padding: 4px 0 2px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-filters button:hover,
|
||||
.client-traffic-filters button:focus-visible,
|
||||
.client-traffic-filters button[aria-pressed='true'] {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-filters button[aria-pressed='true'] {
|
||||
border-bottom-color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-filters button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-traffic-retention {
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.client-traffic-retention > span {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-notice,
|
||||
.client-traffic-state,
|
||||
.client-traffic-truncated,
|
||||
.client-traffic-honesty {
|
||||
margin: 0 8px;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-notice {
|
||||
margin-bottom: 14px;
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-notice.is-warning {
|
||||
color: oklch(0.68 0.14 72);
|
||||
}
|
||||
|
||||
.client-traffic-state {
|
||||
min-height: 118px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-traffic-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.client-traffic-skeleton span {
|
||||
height: 58px;
|
||||
background: color-mix(in oklch, var(--client-border) 24%, transparent);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.client-traffic-list {
|
||||
display: grid;
|
||||
margin: 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.client-traffic-connection {
|
||||
min-width: 0;
|
||||
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
|
||||
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-connection.is-exiting {
|
||||
pointer-events: none;
|
||||
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary:hover,
|
||||
.client-traffic-connection-summary:focus-visible {
|
||||
outline: 0;
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--client-accent);
|
||||
}
|
||||
|
||||
.client-traffic-identity,
|
||||
.client-traffic-values {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-traffic-identity strong {
|
||||
overflow: hidden;
|
||||
font: var(--type-item-title);
|
||||
letter-spacing: var(--type-item-title-tracking);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: var(--type-item-title-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-identity small {
|
||||
overflow: hidden;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-overflow: ellipsis;
|
||||
text-transform: var(--type-control-transform);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-route {
|
||||
justify-self: start;
|
||||
color: var(--client-muted);
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-route[data-route='vpn'] {
|
||||
color: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-traffic-route[data-route='direct'] {
|
||||
color: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-traffic-values {
|
||||
justify-items: end;
|
||||
font-variant-numeric: var(--numeric-tabular);
|
||||
}
|
||||
|
||||
.client-traffic-values strong,
|
||||
.client-traffic-values small {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-traffic-values strong {
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-values small {
|
||||
color: var(--client-muted);
|
||||
font: var(--type-micro);
|
||||
letter-spacing: var(--type-micro-tracking);
|
||||
text-transform: var(--type-micro-transform);
|
||||
}
|
||||
|
||||
.client-traffic-values span:first-child {
|
||||
color: var(--harbor-connect);
|
||||
}
|
||||
|
||||
.client-traffic-values span:last-child {
|
||||
color: var(--harbor-gateway);
|
||||
}
|
||||
|
||||
.client-traffic-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
.client-traffic-details {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 4px 0 16px;
|
||||
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-traffic-details > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 82px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.client-traffic-details dt,
|
||||
.client-traffic-details dd {
|
||||
margin: 0;
|
||||
font: var(--type-control);
|
||||
letter-spacing: var(--type-control-tracking);
|
||||
text-transform: var(--type-control-transform);
|
||||
}
|
||||
|
||||
.client-traffic-details dt {
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-traffic-details dd {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--client-text);
|
||||
}
|
||||
|
||||
.client-traffic-truncated {
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-traffic-honesty {
|
||||
margin-top: 26px;
|
||||
padding-top: 16px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
|
||||
}
|
||||
|
||||
@keyframes client-traffic-details-in {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes client-traffic-connection-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes client-traffic-connection-out {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(4px); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.client-traffic-sheet {
|
||||
padding: 40px 58px 60px 18px;
|
||||
}
|
||||
|
||||
.client-traffic-meta {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.client-traffic-meta > span {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.client-traffic-connection-summary {
|
||||
grid-template-columns: minmax(0, 1fr) auto 18px;
|
||||
}
|
||||
|
||||
.client-traffic-values {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.client-traffic-values strong,
|
||||
.client-traffic-values small {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.client-traffic-details > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.client-traffic-connection,
|
||||
.client-traffic-details {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -10,5 +10,6 @@
|
||||
@import './features/diagnostics.css';
|
||||
@import './features/failover.css';
|
||||
@import './features/activity-journal.css';
|
||||
@import './features/traffic.css';
|
||||
@import './layout.css';
|
||||
@import './themes.css';
|
||||
|
||||
Reference in New Issue
Block a user