Add native traffic inspection to Harbor Connect and Gateway
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user