Add native traffic inspection to Harbor Connect and Gateway

This commit is contained in:
2026-08-31 05:19:15 +03:00
parent 116686a138
commit 4d066cb879
62 changed files with 10975 additions and 220 deletions
+24 -2
View File
@@ -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
View File
@@ -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 });
+1
View File
@@ -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) => {
+163
View File
@@ -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;
},
};
}
+33
View File
@@ -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}`);
});
+74 -1
View File
@@ -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) {
+234 -113
View File
@@ -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 };
}
+777
View File
@@ -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
View File
@@ -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 },
+68 -15
View File
@@ -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,
+213
View File
@@ -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;
}
+3 -3
View File
@@ -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 {
+1
View File
@@ -29,6 +29,7 @@ const componentActions = {
pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity,
loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
};
interface UiError {
+3
View File
@@ -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' }),
+27 -2
View File
@@ -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}
+503
View File
@@ -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>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
type TrafficFeature,
} from './TrafficFeature.js';
+133
View File
@@ -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;
}
+469
View File
@@ -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;
}
}
+1
View File
@@ -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';