Update Harbor client and gateway integration workflows
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ACTIVITY_JOURNAL_MAX_EVENTS,
|
||||
ACTIVITY_JOURNAL_RETENTION_DAYS,
|
||||
normalizeActivityEventInput,
|
||||
normalizeStoredActivityEvent,
|
||||
type ActivityJournalEvent,
|
||||
type ActivityJournalEventInput,
|
||||
type ActivityJournalPage,
|
||||
} from '../../shared/activityJournal.js';
|
||||
import { createJsonStore } from './stateStore.js';
|
||||
|
||||
interface JournalState {
|
||||
schemaVersion: 1;
|
||||
events: ActivityJournalEvent[];
|
||||
}
|
||||
|
||||
const migrateJournal = (value: unknown): JournalState => {
|
||||
const candidate = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
events: (Array.isArray(candidate.events) ? candidate.events : [])
|
||||
.map(normalizeStoredActivityEvent)
|
||||
.filter((event): event is ActivityJournalEvent => Boolean(event)),
|
||||
};
|
||||
};
|
||||
|
||||
export function createActivityJournalService({
|
||||
filePath,
|
||||
now = () => new Date(),
|
||||
}: {
|
||||
filePath: string;
|
||||
now?: () => Date;
|
||||
}) {
|
||||
const store = createJsonStore<JournalState>({
|
||||
filePath,
|
||||
defaultValue: { schemaVersion: 1, events: [] },
|
||||
migrate: migrateJournal,
|
||||
});
|
||||
let recoveryRecorded = false;
|
||||
let writeFailed = false;
|
||||
|
||||
function retained(events: ActivityJournalEvent[]) {
|
||||
const cutoff = now().getTime() - ACTIVITY_JOURNAL_RETENTION_DAYS * 86_400_000;
|
||||
return events
|
||||
.filter(({ occurredAt }) => Date.parse(occurredAt) >= cutoff)
|
||||
.slice(-ACTIVITY_JOURNAL_MAX_EVENTS);
|
||||
}
|
||||
|
||||
function append(value: ActivityJournalEventInput) {
|
||||
const input = normalizeActivityEventInput(value);
|
||||
const storedInput = input.dedupeKey ? {
|
||||
...input,
|
||||
dedupeKey: `${input.type}:sha256:${crypto.createHash('sha256').update(input.dedupeKey).digest('hex')}`,
|
||||
} : input;
|
||||
let appended: ActivityJournalEvent | null = null;
|
||||
try {
|
||||
store.update((state) => {
|
||||
const events = retained(state.events);
|
||||
if (storedInput.dedupeKey && events.some(({ dedupeKey }) => dedupeKey === storedInput.dedupeKey)) {
|
||||
return { schemaVersion: 1, events };
|
||||
}
|
||||
appended = {
|
||||
id: crypto.randomUUID(),
|
||||
occurredAt: now().toISOString(),
|
||||
...storedInput,
|
||||
};
|
||||
return { schemaVersion: 1, events: retained([...events, appended]) };
|
||||
});
|
||||
writeFailed = false;
|
||||
} catch (error) {
|
||||
writeFailed = true;
|
||||
throw error;
|
||||
}
|
||||
return appended;
|
||||
}
|
||||
|
||||
function ensureRecoveryEvent() {
|
||||
if (!store.recovery || recoveryRecorded) return;
|
||||
append({
|
||||
type: 'journal.recovered',
|
||||
severity: 'warning',
|
||||
source: 'storage',
|
||||
dedupeKey: `journal.recovered:${store.recovery.recoveredAt}`,
|
||||
data: {},
|
||||
});
|
||||
recoveryRecorded = true;
|
||||
}
|
||||
|
||||
function page(limitValue: unknown = 50, cursorValue: unknown = null): ActivityJournalPage {
|
||||
try {
|
||||
let state = store.read();
|
||||
ensureRecoveryEvent();
|
||||
if (store.recovery) state = store.read();
|
||||
const retainedEvents = retained(state.events);
|
||||
if (retainedEvents.length !== state.events.length) {
|
||||
state = store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||
}
|
||||
const events = [...state.events].reverse();
|
||||
const limit = Math.min(100, Math.max(1, Number.isSafeInteger(limitValue) ? Number(limitValue) : 50));
|
||||
const cursor = typeof cursorValue === 'string' ? cursorValue : '';
|
||||
const cursorIndex = cursor ? events.findIndex(({ id }) => id === cursor) : -1;
|
||||
if (cursor && cursorIndex < 0) return {
|
||||
events: [],
|
||||
nextCursor: null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: writeFailed
|
||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||
: { status: 'ready', errorCode: null },
|
||||
};
|
||||
const safeStart = cursorIndex + 1;
|
||||
const selected = events.slice(safeStart, safeStart + limit);
|
||||
return {
|
||||
events: selected.map((event) => ({ ...event, dedupeKey: null })),
|
||||
nextCursor: safeStart + selected.length < events.length ? selected.at(-1)?.id || null : null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: writeFailed
|
||||
? { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' }
|
||||
: { status: 'ready', errorCode: null },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
events: [],
|
||||
nextCursor: null,
|
||||
retentionDays: 30,
|
||||
generatedAt: now().toISOString(),
|
||||
storage: { status: 'error', errorCode: 'JOURNAL_UNAVAILABLE' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const state = store.read();
|
||||
const retainedEvents = retained(state.events);
|
||||
if (retainedEvents.length !== state.events.length) {
|
||||
store.update(() => ({ schemaVersion: 1, events: retainedEvents }));
|
||||
}
|
||||
} catch {
|
||||
writeFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { append, page };
|
||||
}
|
||||
|
||||
export type ActivityJournalService = ReturnType<typeof createActivityJournalService>;
|
||||
@@ -44,6 +44,7 @@ interface RequestOptions {
|
||||
ipv4?: boolean;
|
||||
follow?: boolean;
|
||||
resolve?: string | null;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface RequestResult {
|
||||
@@ -179,7 +180,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
|
||||
ipv4 = false,
|
||||
follow = true,
|
||||
resolve = null,
|
||||
timeoutMs = 6_000,
|
||||
}: RequestOptions = {}): Promise<RequestResult> {
|
||||
const boundedTimeoutMs = Math.min(30_000, Math.max(1_000, Math.round(timeoutMs)));
|
||||
const args = [
|
||||
'--silent',
|
||||
'--show-error',
|
||||
@@ -189,9 +192,9 @@ async function request(probe: BaseProbe, path: PathKind, proxyPort: number, exec
|
||||
'--proto-redir',
|
||||
'=https',
|
||||
'--connect-timeout',
|
||||
'3',
|
||||
String(Math.min(3_000, boundedTimeoutMs) / 1_000),
|
||||
'--max-time',
|
||||
'6',
|
||||
String(boundedTimeoutMs / 1_000),
|
||||
'--user-agent',
|
||||
'Harbor-Diagnostics/1',
|
||||
'--output',
|
||||
@@ -379,6 +382,8 @@ async function siteProbe(
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
sampleCount = 1,
|
||||
timeoutMs = 6_000,
|
||||
retryFailure = true,
|
||||
): Promise<SiteProbeResult> {
|
||||
if (probe.validationError) return {
|
||||
id: probe.id,
|
||||
@@ -391,12 +396,12 @@ async function siteProbe(
|
||||
stage: 'validation',
|
||||
error: probe.validationError,
|
||||
};
|
||||
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
||||
const options = { follow: probe.follow !== false, resolve: probe.resolve, timeoutMs };
|
||||
const samples = [];
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||
if (retryFailure && sampleCount === 1 && samples[0] && !samples[0].ok) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
|
||||
@@ -469,15 +474,18 @@ async function probeTarget(
|
||||
path: PathKind,
|
||||
proxyPort: number,
|
||||
execute: CurlExecutor,
|
||||
timeoutMs = 6_000,
|
||||
sampleCount = TARGET_SAMPLE_COUNT,
|
||||
retryFailure = true,
|
||||
): Promise<ConnectivityPathResult> {
|
||||
const network = target.kind === 'network'
|
||||
? await networkProbe(path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
? await networkProbe(path, proxyPort, execute, sampleCount)
|
||||
: null;
|
||||
const ip = target.kind === 'ip'
|
||||
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
? await ipProbe(target.probe, path, proxyPort, execute, sampleCount)
|
||||
: null;
|
||||
const site = target.kind === 'site'
|
||||
? await siteProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||
? await siteProbe(target.probe, path, proxyPort, execute, sampleCount, timeoutMs, retryFailure)
|
||||
: null;
|
||||
const ipv4Sources = ip?.family === 4 ? [ip] : [];
|
||||
const ipv6Source = ip?.family === 6 ? ip : null;
|
||||
@@ -537,7 +545,25 @@ export function createConnectivityDiagnosticsService({
|
||||
assessment: assessConnectivity(direct, vpn),
|
||||
};
|
||||
}
|
||||
async function runVpn({ services = [], target: targetId = null, timeoutMs = 6_000 }: {
|
||||
services?: unknown;
|
||||
target?: unknown;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
|
||||
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
|
||||
: [];
|
||||
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||
const target = resolveTarget(targetId, siteProbes);
|
||||
if (!target || target.kind !== 'site') throw new Error('Failover check ожидает site target');
|
||||
return {
|
||||
checkedAt: now(),
|
||||
vpn: await probeTarget(target, 'vpn', proxyPort, execute, timeoutMs, TARGET_SAMPLE_COUNT, false),
|
||||
};
|
||||
}
|
||||
return {
|
||||
run: runOnce,
|
||||
runVpn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,12 +84,33 @@ interface DomainTrafficSnapshot {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
device: string;
|
||||
service: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
interface ActivitySample {
|
||||
at: number;
|
||||
entries: ActivityEntry[];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function publicDeviceLabel(value: unknown) {
|
||||
const device = record(value);
|
||||
for (const candidate of [device.alias, device.hostname]) {
|
||||
const label = typeof candidate === 'string' ? candidate.trim() : '';
|
||||
if (label && label.length <= 64 && !/[\/?#@\\]/.test(label) && !net.isIP(label)) return label;
|
||||
}
|
||||
return 'Устройство';
|
||||
}
|
||||
|
||||
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||
|
||||
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||
@@ -209,6 +230,10 @@ export function createDomainTrafficService({
|
||||
unsupported_source: 0n,
|
||||
};
|
||||
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||
let activityEnabled = false;
|
||||
let activityStartedAt = 0;
|
||||
let activitySamples: ActivitySample[] = [];
|
||||
let quietSince: string | null = null;
|
||||
let current: DomainTrafficSnapshot = {
|
||||
epoch,
|
||||
observedAt: null,
|
||||
@@ -270,6 +295,7 @@ export function createDomainTrafficService({
|
||||
const response = record(await observe());
|
||||
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||
const devicesByIp = new Map<string, string | null>();
|
||||
const deviceLabels = new Map<string, string>();
|
||||
const observedDevices = devices();
|
||||
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||
const device = record(value);
|
||||
@@ -277,9 +303,11 @@ export function createDomainTrafficService({
|
||||
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||
if (!net.isIPv4(ip) || !id) continue;
|
||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||
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) {
|
||||
@@ -302,6 +330,16 @@ export function createDomainTrafficService({
|
||||
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, {
|
||||
@@ -372,7 +410,12 @@ export function createDomainTrafficService({
|
||||
});
|
||||
}
|
||||
previousConnections = activeConnections;
|
||||
current = { ...current, observedAt: now().toISOString() };
|
||||
const observed = now();
|
||||
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;
|
||||
} catch (error) {
|
||||
@@ -390,5 +433,64 @@ export function createDomainTrafficService({
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh };
|
||||
function enableActivity() {
|
||||
if (activityEnabled) return;
|
||||
activityEnabled = true;
|
||||
activityStartedAt = now().getTime();
|
||||
activitySamples = [];
|
||||
quietSince = null;
|
||||
}
|
||||
|
||||
function disableActivity() {
|
||||
activityEnabled = false;
|
||||
activityStartedAt = 0;
|
||||
activitySamples = [];
|
||||
quietSince = null;
|
||||
}
|
||||
|
||||
function activitySnapshot(thresholdBytesPerSecond: unknown = 0) {
|
||||
if (!activityEnabled || !current.observedAt) return null;
|
||||
const observedAt = Date.parse(current.observedAt);
|
||||
const threshold = Math.max(0, Number(thresholdBytesPerSecond) || 0);
|
||||
const divisorMs = Math.max(1_000, Math.min(10_000, observedAt - activityStartedAt || 1_000));
|
||||
const totals = new Map<string, ActivityEntry>();
|
||||
let bytes = 0n;
|
||||
for (const sample of activitySamples) {
|
||||
for (const entry of sample.entries) {
|
||||
bytes += entry.upload + entry.download;
|
||||
const key = `${entry.device}\0${entry.service}`;
|
||||
const total = totals.get(key) || { ...entry, upload: 0n, download: 0n };
|
||||
total.upload += entry.upload;
|
||||
total.download += entry.download;
|
||||
totals.set(key, total);
|
||||
}
|
||||
}
|
||||
const totalBytesPerSecond = Number(bytes * 1_000n / BigInt(divisorMs));
|
||||
const active = totalBytesPerSecond > threshold;
|
||||
quietSince = active ? null : quietSince || current.observedAt;
|
||||
const latest = activitySamples.at(-1);
|
||||
return {
|
||||
state: active ? 'active' : 'quiet',
|
||||
observedAt: current.observedAt,
|
||||
windowMs: 10_000,
|
||||
thresholdBytesPerSecond: threshold,
|
||||
totalBytesPerSecond,
|
||||
transmittingConnections: latest?.entries.length || 0,
|
||||
quietSince,
|
||||
blockers: [...totals.values()]
|
||||
.map((entry) => ({
|
||||
device: entry.device,
|
||||
service: entry.service,
|
||||
uploadBytesPerSecond: Number(entry.upload * 1_000n / BigInt(divisorMs)),
|
||||
downloadBytesPerSecond: Number(entry.download * 1_000n / BigInt(divisorMs)),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
right.uploadBytesPerSecond + right.downloadBytesPerSecond
|
||||
- left.uploadBytesPerSecond - left.downloadBytesPerSecond
|
||||
))
|
||||
.slice(0, 3),
|
||||
};
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh, enableActivity, disableActivity, activitySnapshot };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import http from 'node:http';
|
||||
import {
|
||||
FAILOVER_PRIMARY_TAG,
|
||||
FAILOVER_RESERVE_TAG,
|
||||
FAILOVER_SELECTOR_TAG,
|
||||
} from '../singbox.js';
|
||||
|
||||
type Role = 'primary' | 'reserve';
|
||||
|
||||
function request(port: number, method: string, body?: unknown): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const encoded = body === undefined ? null : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: `/proxies/${encodeURIComponent(FAILOVER_SELECTOR_TAG)}`,
|
||||
method,
|
||||
headers: encoded ? {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(encoded),
|
||||
} : {},
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
if ((res.statusCode || 500) >= 400) return reject(new Error(`Sing-box selector HTTP ${res.statusCode}`));
|
||||
if (!chunks.length) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
} catch (cause) {
|
||||
reject(new Error('Sing-box selector вернул невалидный JSON', { cause }));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.setTimeout(2_000, () => req.destroy(new Error('Sing-box selector timeout')));
|
||||
req.on('error', reject);
|
||||
req.end(encoded);
|
||||
});
|
||||
}
|
||||
|
||||
const tagFor = (role: Role) => role === 'primary' ? FAILOVER_PRIMARY_TAG : FAILOVER_RESERVE_TAG;
|
||||
const roleFor = (tag: unknown): Role | null => (
|
||||
tag === FAILOVER_PRIMARY_TAG ? 'primary' : tag === FAILOVER_RESERVE_TAG ? 'reserve' : null
|
||||
);
|
||||
|
||||
export function createSingboxSelectorService({
|
||||
port,
|
||||
send = (method: string, body?: unknown) => request(port, method, body),
|
||||
}: {
|
||||
port: number;
|
||||
send?: (method: string, body?: unknown) => Promise<unknown>;
|
||||
}) {
|
||||
async function read() {
|
||||
const value = await send('GET') as Record<string, unknown>;
|
||||
const role = roleFor(value.now);
|
||||
if (!role) throw new Error('Sing-box selector вернул неизвестный outbound');
|
||||
return { role };
|
||||
}
|
||||
async function select(role: Role) {
|
||||
await send('PUT', { name: tagFor(role) });
|
||||
const selected = await read();
|
||||
if (selected.role !== role) throw new Error('Sing-box selector не подтвердил переключение');
|
||||
return selected;
|
||||
}
|
||||
return { read, select };
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type NormalizedServer,
|
||||
} from '../../shared/serverIdentity.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 7;
|
||||
export const STATE_SCHEMA_VERSION = 8;
|
||||
|
||||
export interface AtomicWriteOptions {
|
||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||
|
||||
Reference in New Issue
Block a user