214 lines
7.9 KiB
TypeScript
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;
|
|
}
|