Files
harbor-net/src/shared/liveTraffic.ts
T

214 lines
7.9 KiB
TypeScript

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;
}