Update Harbor client and gateway integration workflows
Build and Deploy Gateway / build-and-push (push) Failing after 14s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-19 18:16:10 +03:00
parent 416b2b294a
commit daec12e013
63 changed files with 5016 additions and 108 deletions
@@ -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,
};
}
+104 -2
View File
@@ -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 };
}
+1 -1
View File
@@ -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;