Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
@@ -7,18 +7,100 @@ import {
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
type PathKind = 'direct' | 'vpn';
interface CurlExecution {
exitCode: number | null;
error: string;
stderr: string;
stdout: string;
}
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
type DnsLookup = typeof dnsLookup;
interface BaseProbe {
id: string;
label: string;
url: string;
}
interface IpProbe extends BaseProbe {
family: 4 | 6;
address: (body: string) => string | undefined;
}
interface SiteProbe extends BaseProbe {
follow?: boolean;
resolve?: string;
validationError?: string;
}
interface RequestOptions {
body?: boolean;
ipv4?: boolean;
follow?: boolean;
resolve?: string | null;
}
interface RequestResult {
ok: boolean;
body: string;
exitCode: number | null;
httpStatus: number | null;
latencyMs: number | null;
totalMs: number | null;
stage: string;
error: string | null;
}
interface IpProbeResult {
source: string;
label: string;
family: 4 | 6;
address: string | null;
attempts: number;
latencyMs: number | null;
error: string | null;
}
interface SiteProbeResult {
id: string;
label: string;
status: string;
attempts: number;
httpStatus: number | null;
latencyMs: number | null;
totalMs: number | null;
stage: string;
error: string | null;
[key: string]: unknown;
}
type DiagnosticTarget =
| { kind: 'ip'; probe: IpProbe }
| { kind: 'site'; probe: SiteProbe };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
...probe,
family: probe.family === 6 ? 6 : 4,
address: probe.id === 'cloudflare'
? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
: probe.id === 'yandex-internet'
? (body) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
: (body) => body.trim(),
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
: (body: string) => body.trim(),
}));
const SITE_PROBES = CONNECTIVITY_SITES;
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
const TARGET_SAMPLE_COUNT = 3;
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
@@ -27,18 +109,18 @@ for (const [address, prefix] of [
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4],
]) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
for (const [address, prefix] of [
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
function runCurl(args) {
function runCurl(args: string[]): Promise<CurlExecution> {
return new Promise((resolve) => {
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
resolve({
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
error: error?.message || '',
stderr: stderr || '',
stdout: stdout || '',
@@ -47,27 +129,27 @@ function runCurl(args) {
});
}
function stageFor(exitCode) {
function stageFor(exitCode: number | null) {
if (exitCode === 6) return 'dns';
if (exitCode === 7) return 'tcp';
if ([35, 51, 58, 60].includes(exitCode)) return 'tls';
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
if (exitCode === 28) return 'timeout';
return 'request';
}
function milliseconds(value) {
function milliseconds(value: unknown) {
const seconds = Number(value);
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
}
function average(values) {
const numbers = values.filter(Number.isFinite);
function average(values: Array<number | null>) {
const numbers = values.filter((value): value is number => Number.isFinite(value));
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
}
function mostCommon(values) {
const counts = new Map();
let selected = null;
function mostCommon<T>(values: T[]): T | null {
const counts = new Map<T, number>();
let selected: T | null = null;
let selectedCount = 0;
for (const value of values) {
const count = (counts.get(value) || 0) + 1;
@@ -80,12 +162,12 @@ function mostCommon(values) {
return selected;
}
async function request(probe, path, proxyPort, execute, {
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
body = false,
ipv4 = false,
follow = true,
resolve = null,
} = {}) {
}: RequestOptions = {}): Promise<RequestResult> {
const args = [
'--silent',
'--show-error',
@@ -114,13 +196,15 @@ async function request(probe, path, proxyPort, execute, {
const result = await execute(args);
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
let meta = {};
let meta: Record<string, unknown> = {};
try {
meta = JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}');
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
} catch {
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
}
const exitCode = Number.isInteger(meta.exitcode) ? meta.exitcode : result.exitCode;
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
? meta.exitcode
: result.exitCode;
const ok = exitCode === 0;
return {
ok,
@@ -134,8 +218,14 @@ async function request(probe, path, proxyPort, execute, {
};
}
async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
const samples = [];
async function ipProbe(
probe: IpProbe,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sampleCount = 1,
): Promise<IpProbeResult> {
const samples: Array<RequestResult & { address: string | null }> = [];
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
const parsed = result.ok ? probe.address(result.body) : null;
@@ -144,7 +234,7 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
});
}
const address = mostCommon(samples.map((sample) => sample.address).filter(Boolean));
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
const matching = samples.filter((sample) => sample.address === address);
return {
source: probe.id,
@@ -157,13 +247,13 @@ async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
};
}
async function publicIps(path, proxyPort, execute) {
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
const ipv4 = probes.filter((probe) => probe.family === 4);
const ipv6 = probes.find((probe) => probe.family === 6);
return {
ipv4: {
addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))],
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
sources: ipv4,
},
ipv6: ipv6?.address || null,
@@ -171,20 +261,21 @@ async function publicIps(path, proxyPort, execute) {
};
}
function isPublicAddress(address, family) {
function isPublicAddress(address: string, family: number) {
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
}
async function prepareCustomProbes(services, lookup) {
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
return Promise.all(requested.map(async (service, index) => {
const requestedId = String(service?.id || '');
const value = record(service);
const requestedId = String(value.id || '');
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
let parsed;
try {
parsed = new URL(String(service?.url || '').trim());
parsed = new URL(String(value.url || '').trim());
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
throw new Error('Разрешены только публичные HTTPS-адреса');
}
@@ -198,7 +289,7 @@ async function prepareCustomProbes(services, lookup) {
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
return {
id,
label: String(service?.label || '').trim().slice(0, 40) || hostname,
label: String(value.label || '').trim().slice(0, 40) || hostname,
url: parsed.href,
follow: false,
resolve: `${hostname}:443:${pinned}`,
@@ -206,19 +297,28 @@ async function prepareCustomProbes(services, lookup) {
} catch (error) {
return {
id,
label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
validationError: error.message || 'Некорректный адрес',
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
url: '',
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
};
}
}));
}
function siteStatus(result) {
function siteStatus(result: RequestResult) {
if (!result.ok) return 'unavailable';
return result.httpStatus >= 200 && result.httpStatus < 400 ? 'available' : 'responded';
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
? 'available'
: 'responded';
}
async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
async function siteProbe(
probe: SiteProbe,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sampleCount = 1,
): Promise<SiteProbeResult> {
if (probe.validationError) return {
id: probe.id,
label: probe.label,
@@ -235,12 +335,13 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
samples.push(await request(probe, path, proxyPort, execute, options));
}
if (sampleCount === 1 && !samples[0].ok) {
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
samples.push(await request(probe, path, proxyPort, execute, options));
}
const status = mostCommon(samples.map(siteStatus));
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
const matching = samples.filter((sample) => siteStatus(sample) === status);
const representative = matching.at(-1);
const representative = matching.at(-1) || samples.at(-1);
if (!representative) throw new Error('Diagnostic probe produced no samples');
return {
id: probe.id,
label: probe.label,
@@ -254,7 +355,12 @@ async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
};
}
async function probePath(path, proxyPort, execute, sites) {
async function probePath(
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sites: SiteProbe[],
): Promise<ConnectivityPathResult> {
const [ip, siteResults] = await Promise.all([
publicIps(path, proxyPort, execute),
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
@@ -269,7 +375,7 @@ async function probePath(path, proxyPort, execute, sites) {
};
}
function unavailablePath() {
function unavailablePath(): ConnectivityPathResult {
return {
available: false,
reason: 'vpn-off',
@@ -281,7 +387,7 @@ function unavailablePath() {
};
}
function resolveTarget(targetId, sites) {
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
if (typeof targetId !== 'string') return null;
if (targetId.startsWith('ip:')) {
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
@@ -294,7 +400,12 @@ function resolveTarget(targetId, sites) {
return null;
}
async function probeTarget(target, path, proxyPort, execute) {
async function probeTarget(
target: DiagnosticTarget,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
): Promise<ConnectivityPathResult> {
const ip = target.kind === 'ip'
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
: null;
@@ -308,7 +419,7 @@ async function probeTarget(target, path, proxyPort, execute) {
available: true,
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
ipv4: {
addresses: ipv4Sources.map(({ address }) => address).filter(Boolean),
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
sources: ipv4Sources,
},
ipv6: ipv6Source?.address || null,
@@ -324,10 +435,19 @@ export function createConnectivityDiagnosticsService({
execute = runCurl,
lookup = dnsLookup,
now = () => new Date().toISOString(),
}: {
proxyPort: number;
execute?: CurlExecutor;
lookup?: DnsLookup;
now?: () => string;
}) {
async function runOnce({ vpnAvailable, services = [], target: targetId = null }) {
const requestedServices = targetId?.startsWith('site:custom-')
? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId)
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
vpnAvailable: boolean;
services?: unknown;
target?: unknown;
}) {
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
: targetId ? [] : services;
const customProbes = await prepareCustomProbes(requestedServices, lookup);
const siteProbes = [...SITE_PROBES, ...customProbes];
@@ -5,6 +5,154 @@ import { HarborError } from '../../shared/errors.js';
import { isDeviceInterface } from '../adapters/neighbors.js';
import { fingerprintDirectDevices } from './devicePolicyService.js';
type DevicePolicyMode = 'vpn' | 'direct';
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
interface CounterBaseline {
epoch: string;
uploadBytes: string;
downloadBytes: string;
}
interface TrafficTotal {
uploadBytes: string;
downloadBytes: string;
observedAt?: string | null;
}
interface CounterTotal {
upload: bigint;
download: bigint;
}
interface GlobalTrafficSource {
epoch: string | null;
lastObservedAt: string | null;
uploadBytes: string;
downloadBytes: string;
baselinesByMac: Record<string, CounterBaseline>;
rebaselineMacs: string[];
}
interface ProxyTrafficState {
schemaVersion: number;
lastObservedAt: string | null;
lastError: string | null;
baselinesByMac: Record<string, CounterBaseline>;
totalsByMac: Record<string, TrafficTotal>;
rebaselineMacs: string[];
}
interface DevicePolicyEntry {
desired: DevicePolicyMode;
applied: DevicePolicyMode;
status: DevicePolicyStatus;
appliedAt: string | null;
error: string | null;
operationId: string | null;
}
interface DevicePolicyState {
schemaVersion: number;
defaultMode: DevicePolicyMode;
dataplaneEpoch: string | null;
generation: string | null;
fingerprint: string | null;
lastAppliedAt: string | null;
lastError: string | null;
byMac: Record<string, DevicePolicyEntry>;
}
interface InventoryDevice {
id: string;
alias: string;
pinned: boolean;
hostname: string | null;
manufacturer: string | null;
mac: string;
ip: string;
interface: string;
firstSeenAt: string;
lastSeenAt: string;
source: string;
confidence: string;
[key: string]: unknown;
}
interface InventoryTrafficState {
epoch: string | null;
generation: string | null;
lastObservedAt: string | null;
lastError: string | null;
baselinesByMac: Record<string, CounterBaseline>;
totalsByMac: Record<string, TrafficTotal>;
rebaselineMacs: string[];
proxy: ProxyTrafficState;
global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource };
[key: string]: unknown;
}
export interface InventoryState {
schemaVersion: number;
revision: number;
lastObservedAt: string | null;
lastError: string | null;
policy: DevicePolicyState;
traffic: InventoryTrafficState;
devices: InventoryDevice[];
[key: string]: unknown;
}
interface DirectDevice {
id: string;
ip: string;
mac: string;
interface: string;
}
interface PolicyAck {
epoch: string;
generation: string;
fingerprint: string;
observedAt: string;
appliedIds: string[];
}
interface InventoryStore {
read(): unknown;
update(transform: (stored: unknown) => InventoryState): InventoryState;
}
interface TrafficSample {
observedAt: string | null | undefined;
gatewayBytes: string;
proxyBytes: string;
}
interface TrafficCursor {
signature: string;
gateway: bigint;
proxy: bigint;
}
interface DeviceObservation {
mac: string;
ip: string;
interface: string;
active: boolean;
observedAt: string;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
@@ -14,11 +162,11 @@ const COUNTER_PATTERN = /^\d+$/;
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
const POLICY_MODES = new Set(['vpn', 'direct']);
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
const POLICY_MODES: ReadonlySet<unknown> = new Set(['vpn', 'direct']);
const POLICY_STATUSES: ReadonlySet<unknown> = new Set(['applied', 'applying', 'pending', 'failed']);
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
const DEFAULT_DEVICE_POLICY = Object.freeze({
const DEFAULT_DEVICE_POLICY: Readonly<DevicePolicyEntry> = Object.freeze({
desired: 'vpn',
applied: 'vpn',
status: 'applied',
@@ -27,7 +175,7 @@ const DEFAULT_DEVICE_POLICY = Object.freeze({
operationId: null,
});
const DEFAULT_POLICY_STATE = {
const DEFAULT_POLICY_STATE: DevicePolicyState = {
schemaVersion: 1,
defaultMode: 'vpn',
dataplaneEpoch: null,
@@ -38,7 +186,7 @@ const DEFAULT_POLICY_STATE = {
byMac: {},
};
const DEFAULT_PROXY_TRAFFIC = {
const DEFAULT_PROXY_TRAFFIC: ProxyTrafficState = {
schemaVersion: 1,
lastObservedAt: null,
lastError: null,
@@ -47,7 +195,7 @@ const DEFAULT_PROXY_TRAFFIC = {
rebaselineMacs: [],
};
const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
const DEFAULT_GLOBAL_TRAFFIC_SOURCE: GlobalTrafficSource = {
epoch: null,
lastObservedAt: null,
uploadBytes: '0',
@@ -56,7 +204,7 @@ const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
rebaselineMacs: [],
};
const DEFAULT_STATE = {
const DEFAULT_STATE: InventoryState = {
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: 0,
lastObservedAt: null,
@@ -79,23 +227,86 @@ const DEFAULT_STATE = {
devices: [],
};
const normalizeMac = (value) => String(value || '').trim().toLowerCase();
export const deviceId = (mac) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
const isPrivateMac = (mac) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
const recordEntries = (value) => value && typeof value === 'object' && !Array.isArray(value)
? Object.entries(value)
: [];
const parseStoredCounter = (value) => {
const normalizeMac = (value: unknown) => String(value || '').trim().toLowerCase();
export const deviceId = (mac: string) => `dev_${crypto.createHash('sha256').update(mac).digest('hex').slice(0, 16)}`;
const isPrivateMac = (mac: string) => (Number.parseInt(mac.slice(0, 2), 16) & 2) !== 0;
const recordEntries = (value: unknown): Array<[string, Record<string, unknown>]> => (
Object.entries(record(value)).map(([key, entry]) => [key, record(entry)])
);
const parseStoredCounter = (value: unknown) => {
const counter = String(value ?? '');
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
};
const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac)
.reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n)
const validTimestamp = (value: unknown): value is string => (
typeof value === 'string' && Number.isFinite(Date.parse(value))
);
function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
const device = record(value);
const mac = normalizeMac(device.mac);
const ip = typeof device.ip === 'string' ? device.ip : '';
const deviceInterface = typeof device.interface === 'string' ? device.interface : '';
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|| !validTimestamp(device.firstSeenAt) || !validTimestamp(device.lastSeenAt)) {
return null;
}
const confidence = ['high', 'medium', 'ambiguous'].includes(String(device.confidence))
? String(device.confidence)
: isPrivateMac(mac) ? 'medium' : 'high';
return {
...device,
id: typeof device.id === 'string' && DEVICE_ID_PATTERN.test(device.id)
? device.id
: deviceId(mac),
alias: typeof device.alias === 'string' ? device.alias : '',
pinned: device.pinned === true,
hostname: typeof device.hostname === 'string' ? device.hostname : null,
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
mac,
ip,
interface: deviceInterface,
firstSeenAt: device.firstSeenAt,
lastSeenAt: device.lastSeenAt,
source: typeof device.source === 'string' && device.source ? device.source : 'neighbor',
confidence,
};
}
function normalizeDeviceObservation(value: unknown): DeviceObservation | null {
const observation = record(value);
const mac = normalizeMac(observation.mac);
const ip = typeof observation.ip === 'string' ? observation.ip : '';
const deviceInterface = typeof observation.interface === 'string' ? observation.interface : '';
if (!MAC_PATTERN.test(mac) || !net.isIPv4(ip) || !isDeviceInterface(deviceInterface)
|| typeof observation.active !== 'boolean' || !validTimestamp(observation.observedAt)) {
return null;
}
return {
mac,
ip,
interface: deviceInterface,
active: observation.active,
observedAt: observation.observedAt,
};
}
const sumStoredTotals = (totalsByMac: unknown, key: string) => recordEntries(totalsByMac)
.reduce((total, [, value]) => total + BigInt(String(value[key] || '0')), 0n)
.toString();
function normalizeGlobalTrafficSource(value, fallback, version) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
function normalizeGlobalTrafficSource(
value: unknown,
fallback: {
epoch: string | null;
lastObservedAt: string | null;
baselinesByMac: Record<string, CounterBaseline>;
totalsByMac: Record<string, TrafficTotal>;
rebaselineMacs: string[];
},
version: number,
): GlobalTrafficSource {
const source = record(value);
const fallbackMacs = new Set([
...Object.keys(fallback.baselinesByMac),
...Object.keys(fallback.totalsByMac),
@@ -111,9 +322,9 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
rebaselineMacs: [...fallback.rebaselineMacs],
};
}
const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
const rebaselineMacs = new Set<string>((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
const baselinesByMac = {};
const baselinesByMac: Record<string, CounterBaseline> = {};
let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
|| Array.isArray(source.baselinesByMac);
for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
@@ -141,12 +352,12 @@ function normalizeGlobalTrafficSource(value, fallback, version) {
};
}
function normalizeProxyTraffic(value, devices) {
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
function normalizeProxyTraffic(value: unknown, devices: InventoryDevice[]): ProxyTrafficState {
const proxy = record(value);
if (typeof proxy.schemaVersion === 'number' && Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
}
const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
const rebaselineMacs = new Set<string>((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
let recovered = value !== undefined && (
proxy !== value || proxy.schemaVersion !== 1
@@ -156,7 +367,7 @@ function normalizeProxyTraffic(value, devices) {
if (recovered) {
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
}
const baselinesByMac = {};
const baselinesByMac: Record<string, CounterBaseline> = {};
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
@@ -169,7 +380,7 @@ function normalizeProxyTraffic(value, devices) {
}
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
}
const totalsByMac = {};
const totalsByMac: Record<string, TrafficTotal> = {};
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(total?.uploadBytes);
@@ -203,9 +414,9 @@ function normalizeProxyTraffic(value, devices) {
};
}
function normalizePolicyState(value) {
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const byMac = {};
function normalizePolicyState(value: unknown): DevicePolicyState {
const policy = record(value);
const byMac: Record<string, DevicePolicyEntry> = {};
let recovered = value !== undefined && (
policy.schemaVersion !== 1
|| policy.defaultMode !== 'vpn'
@@ -223,9 +434,9 @@ function normalizePolicyState(value) {
continue;
}
byMac[mac] = {
desired: entry.desired,
applied: entry.applied,
status: entry.status,
desired: entry.desired as DevicePolicyMode,
applied: entry.applied as DevicePolicyMode,
status: entry.status as DevicePolicyStatus,
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
error: typeof entry.error === 'string' ? entry.error : null,
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
@@ -246,8 +457,8 @@ function normalizePolicyState(value) {
};
}
export function parseOuiVendors(text) {
const vendors = new Map();
export function parseOuiVendors(text: unknown) {
const vendors = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) {
const match = line.match(/^([0-9a-f]{2}(?:-[0-9a-f]{2}){2})\s+\(hex\)\s+(.+)$/i);
if (match) vendors.set(match[1].replaceAll('-', '').toLowerCase(), match[2].trim());
@@ -256,8 +467,8 @@ export function parseOuiVendors(text) {
}
export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
let vendors;
return (mac) => {
let vendors: Map<string, string> | undefined;
return (mac: string) => {
if (!mac || isPrivateMac(mac)) return null;
if (!vendors) {
try {
@@ -270,20 +481,22 @@ export function createVendorLookup(filePath = '/usr/share/ieee-data/oui.txt') {
};
}
export function migrateDeviceInventoryState(value) {
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const version = Number.isSafeInteger(state.schemaVersion) ? state.schemaVersion : 0;
export function migrateDeviceInventoryState(value: unknown): InventoryState {
const state = record(value);
const version = typeof state.schemaVersion === 'number' && Number.isSafeInteger(state.schemaVersion)
? state.schemaVersion
: 0;
if (version < 0 || version > DEVICE_INVENTORY_SCHEMA_VERSION) {
throw new Error(`Unsupported device inventory schemaVersion: ${version}`);
}
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
? state.traffic
: {};
const traffic = record(state.traffic);
const devices = Array.isArray(state.devices)
? state.devices.filter((device) => isDeviceInterface(device?.interface))
? state.devices
.map(normalizeInventoryDevice)
.filter((device): device is InventoryDevice => device !== null)
: [];
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
const rebaselineMacs = new Set<string>((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
let recoveredTraffic = version >= 2 && (
traffic !== state.traffic
@@ -293,7 +506,7 @@ export function migrateDeviceInventoryState(value) {
if (recoveredTraffic) {
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
}
const baselinesByMac = {};
const baselinesByMac: Record<string, CounterBaseline> = {};
for (const [rawMac, baseline] of recordEntries(traffic.baselinesByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
@@ -306,7 +519,7 @@ export function migrateDeviceInventoryState(value) {
}
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
}
const totalsByMac = {};
const totalsByMac: Record<string, TrafficTotal> = {};
for (const [rawMac, total] of recordEntries(traffic.totalsByMac)) {
const mac = normalizeMac(rawMac);
const uploadBytes = parseStoredCounter(total?.uploadBytes);
@@ -329,14 +542,14 @@ export function migrateDeviceInventoryState(value) {
}
}
const global = {
gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, {
gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, {
epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
baselinesByMac,
totalsByMac,
rebaselineMacs: [...rebaselineMacs],
}, version),
proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, {
proxy: normalizeGlobalTrafficSource(record(traffic.global).proxy, {
epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
lastObservedAt: proxyTraffic.lastObservedAt,
baselinesByMac: proxyTraffic.baselinesByMac,
@@ -348,14 +561,14 @@ export function migrateDeviceInventoryState(value) {
...DEFAULT_STATE,
...state,
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
revision: typeof state.revision === 'number' && Number.isSafeInteger(state.revision) ? state.revision : 0,
policy: normalizePolicyState(state.policy),
traffic: {
...DEFAULT_STATE.traffic,
...traffic,
lastError: recoveredTraffic
? 'Повреждённый traffic checkpoint восстановлен из корректных данных'
: traffic.lastError || null,
: typeof traffic.lastError === 'string' ? traffic.lastError : null,
baselinesByMac,
totalsByMac,
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
@@ -366,17 +579,23 @@ export function migrateDeviceInventoryState(value) {
};
}
function deviceStatus(lastSeenAt, now) {
function deviceStatus(lastSeenAt: string, now: Date): 'online' | 'recent' | 'offline' {
const age = now.getTime() - new Date(lastSeenAt).getTime();
if (age <= ONLINE_MS) return 'online';
if (age <= RECENT_MS) return 'recent';
return 'offline';
}
function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) {
function accumulateGlobalTraffic(
source: GlobalTrafficSource,
countersByMac: Map<string, CounterTotal>,
epoch: string,
observedAt: string | null,
label: string,
): GlobalTrafficSource {
const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac };
const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs);
const baselinesByMac: Record<string, CounterBaseline> = epochChanged ? {} : { ...source.baselinesByMac };
const rebaselineMacs = new Set<string>(epochChanged ? [] : source.rebaselineMacs);
let uploadBytes = BigInt(source.uploadBytes);
let downloadBytes = BigInt(source.downloadBytes);
for (const [mac, processTotal] of countersByMac) {
@@ -419,14 +638,23 @@ export function createDeviceInventoryService({
applyPolicies = null,
vendor = () => null,
now = () => new Date(),
}: {
store: InventoryStore;
observe: () => unknown | Promise<unknown>;
observeTraffic?: (() => unknown | Promise<unknown>) | null;
observeDomainTraffic?: (() => unknown | Promise<unknown>) | null;
observePolicy?: (() => unknown | Promise<unknown>) | null;
applyPolicies?: ((requested: DirectDevice[]) => unknown | Promise<unknown>) | null;
vendor?: (mac: string) => string | null;
now?: () => Date;
}) {
let refreshPromise = null;
let policyQueue = Promise.resolve();
const trafficHistoryByMac = new Map();
const trafficCursorByMac = new Map();
let globalTrafficHistory = [];
let globalTrafficCursor = null;
let domainTrafficSnapshot = {
let refreshPromise: Promise<unknown> | null = null;
let policyQueue: Promise<unknown> = Promise.resolve();
const trafficHistoryByMac = new Map<string, TrafficSample[]>();
const trafficCursorByMac = new Map<string, TrafficCursor>();
let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null;
let domainTrafficSnapshot: Record<string, unknown> = {
epoch: null,
observedAt: null,
source: { error: null },
@@ -439,7 +667,7 @@ export function createDeviceInventoryService({
series: [],
};
function captureTrafficHistory(state) {
function captureTrafficHistory(state: InventoryState) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
for (const device of state.devices) {
const traffic = state.traffic.totalsByMac[device.mac];
@@ -481,23 +709,24 @@ export function createDeviceInventoryService({
}
}
function serializePolicy(action) {
const result = policyQueue.then(action, action);
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
const result = policyQueue.then(() => action(), () => action());
policyQueue = result.catch(() => {});
return result;
}
function policyFor(state, mac) {
function policyFor(state: InventoryState, mac: string): Readonly<DevicePolicyEntry> {
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
}
function policyIdentity(device) {
return Boolean(device) && device.confidence !== 'ambiguous'
function policyIdentity(device: InventoryDevice | null | undefined) {
if (!device) return false;
return device.confidence !== 'ambiguous'
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
&& isDeviceInterface(device.interface);
}
function directDevices(state) {
function directDevices(state: InventoryState): DirectDevice[] {
return state.devices
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
.map(({ id, ip, mac, interface: deviceInterface }) => ({
@@ -508,26 +737,27 @@ export function createDeviceInventoryService({
}));
}
function validatePolicyAck(result, requested) {
const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : [];
function validatePolicyAck(result: unknown, requested: DirectDevice[]): PolicyAck {
const value = record(result);
const appliedIds = Array.isArray(value.appliedIds) ? value.appliedIds : [];
const expectedIds = new Set(requested.map(({ id }) => id));
if (typeof result?.epoch !== 'string' || !result.epoch
|| typeof result.generation !== 'string' || !result.generation
|| !FINGERPRINT_PATTERN.test(result.fingerprint)
|| typeof result.observedAt !== 'string' || !result.observedAt
|| result.fingerprint !== fingerprintDirectDevices(requested)
if (typeof value.epoch !== 'string' || !value.epoch
|| typeof value.generation !== 'string' || !value.generation
|| typeof value.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(value.fingerprint)
|| typeof value.observedAt !== 'string' || !value.observedAt
|| value.fingerprint !== fingerprintDirectDevices(requested)
|| appliedIds.length !== expectedIds.size
|| new Set(appliedIds).size !== appliedIds.length
|| appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|| appliedIds.some((id) => typeof id !== 'string' || !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
}
return result;
return value as unknown as PolicyAck;
}
function snapshot() {
const state = migrateDeviceInventoryState(store.read());
const current = now();
const rank = { online: 0, recent: 1, offline: 2 };
const rank: Record<'online' | 'recent' | 'offline', number> = { online: 0, recent: 1, offline: 2 };
const devices = state.devices.map((device) => {
const traffic = state.traffic.totalsByMac[device.mac];
const proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
@@ -601,24 +831,27 @@ export function createDeviceInventoryService({
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
}
function markPolicyEpoch(observed) {
if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return;
function markPolicyEpoch(observed: unknown) {
const value = record(observed);
if (typeof value.epoch !== 'string' || !value.epoch || !Array.isArray(value.appliedIds)) return;
const epoch = value.epoch;
const acknowledgedIds = value.appliedIds;
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state;
const appliedIds = new Set(observed.appliedIds);
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === epoch) return state;
const appliedIds = new Set(acknowledgedIds);
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
const byMac = {};
const byMac: Record<string, DevicePolicyEntry> = {};
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
const device = devicesByMac.get(mac);
if (!device) continue;
const applied = appliedIds.has(device.id) ? 'direct' : 'vpn';
const applied: DevicePolicyMode = appliedIds.has(device.id) ? 'direct' : 'vpn';
if (entry.desired === 'vpn' && applied === 'vpn') continue;
byMac[mac] = {
...entry,
applied,
status: entry.desired === applied ? 'applied' : 'pending',
appliedAt: observed.observedAt || entry.appliedAt,
appliedAt: typeof value.observedAt === 'string' ? value.observedAt : entry.appliedAt,
error: entry.desired === applied
? null
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
@@ -630,10 +863,12 @@ export function createDeviceInventoryService({
revision: state.revision + 1,
policy: {
...state.policy,
dataplaneEpoch: observed.epoch,
generation: typeof observed.generation === 'string' ? observed.generation : null,
fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null,
lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt,
dataplaneEpoch: epoch,
generation: typeof value.generation === 'string' ? value.generation : null,
fingerprint: typeof value.fingerprint === 'string' && FINGERPRINT_PATTERN.test(value.fingerprint)
? value.fingerprint
: null,
lastAppliedAt: typeof value.observedAt === 'string' ? value.observedAt : state.policy.lastAppliedAt,
lastError: null,
byMac,
},
@@ -641,12 +876,12 @@ export function createDeviceInventoryService({
});
}
function commitPolicySuccess(result) {
function commitPolicySuccess(result: PolicyAck) {
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []);
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
const byMac = {};
const byMac: Record<string, DevicePolicyEntry> = {};
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
const device = devicesByMac.get(mac);
if (!device) continue;
@@ -677,11 +912,11 @@ export function createDeviceInventoryService({
});
}
function commitPolicyFailure(error) {
function commitPolicyFailure(error: unknown) {
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const message = error.message || String(error);
const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
const message = errorMessage(error);
const byMac: Record<string, DevicePolicyEntry> = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
...entry,
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
@@ -695,7 +930,7 @@ export function createDeviceInventoryService({
});
}
async function reconcileLocked(observedPolicy, throwOnError) {
async function reconcileLocked(observedPolicy: unknown, throwOnError: boolean) {
if (!applyPolicies) return snapshot();
markPolicyEpoch(observedPolicy);
const state = migrateDeviceInventoryState(store.read());
@@ -713,42 +948,44 @@ export function createDeviceInventoryService({
async function performRefresh() {
const [result, trafficResult, policyResult, domainTrafficResult] = await Promise.all([
Promise.resolve().then(() => observe()).catch((error) => ({
Promise.resolve().then(() => observe()).catch((error: unknown) => ({
observedAt: now().toISOString(),
observations: [],
error: error.message || String(error),
})),
error: errorMessage(error),
})).then(record),
observeTraffic
? Promise.resolve().then(() => observeTraffic())
.catch((error) => ({ transportError: error.message || String(error) }))
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
: null,
observePolicy
? Promise.resolve().then(() => observePolicy())
.catch((error) => ({ transportError: error.message || String(error) }))
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
: null,
observeDomainTraffic
? Promise.resolve().then(() => observeDomainTraffic())
.catch((error) => ({ transportError: error.message || String(error) }))
.catch((error: unknown) => ({ transportError: errorMessage(error) })).then(record)
: null,
]);
const observedAt = result?.observedAt || now().toISOString();
const observations = (Array.isArray(result?.observations) ? result.observations : [])
.filter((observation) => isDeviceInterface(observation?.interface));
const identitiesByMac = new Map();
const observedAt = validTimestamp(result.observedAt) ? result.observedAt : now().toISOString();
const observations = (Array.isArray(result.observations) ? result.observations : [])
.map(normalizeDeviceObservation)
.filter((observation): observation is DeviceObservation => observation !== null);
const identitiesByMac = new Map<string, Set<string>>();
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
const identities = identitiesByMac.get(mac) || new Set<string>();
identities.add(`${String(observation.ip)}|${observation.interface || ''}`);
identitiesByMac.set(mac, identities);
}
return serializePolicy(async () => {
if (domainTrafficResult?.transportError) {
if (typeof domainTrafficResult?.transportError === 'string') {
domainTrafficSnapshot = {
...domainTrafficSnapshot,
source: { error: domainTrafficResult.transportError },
};
} else if (domainTrafficResult) {
domainTrafficSnapshot = domainTrafficResult;
domainTrafficSnapshot = record(domainTrafficResult);
}
const nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
@@ -757,8 +994,9 @@ export function createDeviceInventoryService({
const mac = normalizeMac(observation.mac);
if (!mac) continue;
const previous = byMac.get(mac);
const observationTime = typeof observation.observedAt === 'string' ? observation.observedAt : observedAt;
const lastSeenAt = observation.active || !previous
? observation.observedAt || observedAt
? observationTime
: previous.lastSeenAt;
byMac.set(mac, {
id: previous?.id || deviceId(mac),
@@ -769,10 +1007,10 @@ export function createDeviceInventoryService({
mac,
ip: String(observation.ip || previous?.ip || ''),
interface: String(observation.interface || previous?.interface || ''),
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
firstSeenAt: previous?.firstSeenAt || observationTime,
lastSeenAt,
source: 'neighbor',
confidence: identitiesByMac.get(mac)?.size > 1
confidence: (identitiesByMac.get(mac)?.size || 0) > 1
? 'ambiguous'
: isPrivateMac(mac) ? 'medium' : 'high',
});
@@ -783,23 +1021,33 @@ export function createDeviceInventoryService({
));
let traffic = state.traffic;
if (trafficResult) {
if (trafficResult.transportError) {
if (typeof trafficResult.transportError === 'string') {
traffic = { ...traffic, lastError: trafficResult.transportError };
} else {
try {
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
throw new Error('Dataplane не вернул traffic epoch');
}
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : [];
const processByMac = new Map();
const proxyByMac = new Map();
const trafficEpoch = trafficResult.epoch;
const trafficObservedAt = typeof trafficResult.observedAt === 'string'
? trafficResult.observedAt
: null;
const trafficGeneration = typeof trafficResult.generation === 'string'
? trafficResult.generation
: null;
const trafficSourceError = typeof record(trafficResult.source).error === 'string'
? String(record(trafficResult.source).error)
: null;
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : [];
const processByMac = new Map<string, CounterTotal>();
const proxyByMac = new Map<string, CounterTotal>();
let proxyRows = 0;
let legacyRows = 0;
let proxySampleError = null;
let proxySampleError: string | null = null;
for (const row of rows) {
const mac = normalizeMac(row?.mac);
const upload = String(row?.uploadBytes ?? '');
const download = String(row?.downloadBytes ?? '');
const mac = normalizeMac(row.mac);
const upload = String(row.uploadBytes ?? '');
const download = String(row.downloadBytes ?? '');
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(upload) || !COUNTER_PATTERN.test(download)) {
throw new Error('Dataplane вернул невалидный traffic counter');
}
@@ -808,8 +1056,8 @@ export function createDeviceInventoryService({
upload: previous.upload + BigInt(upload),
download: previous.download + BigInt(download),
});
const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes');
const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes');
const hasProxyUpload = Object.hasOwn(row, 'proxyUploadBytes');
const hasProxyDownload = Object.hasOwn(row, 'proxyDownloadBytes');
if (!hasProxyUpload && !hasProxyDownload) {
legacyRows += 1;
continue;
@@ -840,7 +1088,7 @@ export function createDeviceInventoryService({
if (!knownMacs.has(mac)) continue;
const baseline = baselinesByMac[mac];
const recovering = rebaselineMacs.has(mac);
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
@@ -852,10 +1100,10 @@ export function createDeviceInventoryService({
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
downloadBytes: (BigInt(total.downloadBytes)
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
observedAt: trafficResult.observedAt || traffic.lastObservedAt,
observedAt: trafficObservedAt || traffic.lastObservedAt,
};
baselinesByMac[mac] = {
epoch: trafficResult.epoch,
epoch: trafficEpoch,
uploadBytes: processTotal.upload.toString(),
downloadBytes: processTotal.download.toString(),
};
@@ -864,8 +1112,8 @@ export function createDeviceInventoryService({
const globalGateway = accumulateGlobalTraffic(
traffic.global.gateway,
processByMac,
trafficResult.epoch,
trafficResult.observedAt || traffic.lastObservedAt,
trafficEpoch,
trafficObservedAt || traffic.lastObservedAt,
'Gateway',
);
for (const mac of Object.keys(totalsByMac)) {
@@ -916,7 +1164,7 @@ export function createDeviceInventoryService({
if (!knownMacs.has(mac)) continue;
const baseline = nextProxyBaselines[mac];
const recovering = nextProxyRebaseline.has(mac);
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
const sameEpoch = !recovering && baseline?.epoch === trafficEpoch;
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
@@ -928,10 +1176,10 @@ export function createDeviceInventoryService({
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
downloadBytes: (BigInt(total.downloadBytes)
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
observedAt: trafficResult.observedAt || proxy.lastObservedAt,
observedAt: trafficObservedAt || proxy.lastObservedAt,
};
nextProxyBaselines[mac] = {
epoch: trafficResult.epoch,
epoch: trafficEpoch,
uploadBytes: processTotal.upload.toString(),
downloadBytes: processTotal.download.toString(),
};
@@ -940,14 +1188,14 @@ export function createDeviceInventoryService({
const nextGlobalProxy = accumulateGlobalTraffic(
traffic.global.proxy,
proxyByMac,
trafficResult.epoch,
trafficResult.observedAt || proxy.lastObservedAt,
trafficEpoch,
trafficObservedAt || proxy.lastObservedAt,
'proxy',
);
proxy = {
...proxy,
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
lastError: trafficResult.source?.error
lastObservedAt: trafficObservedAt || proxy.lastObservedAt,
lastError: trafficSourceError
|| (nextProxyRebaseline.size ? proxy.lastError : null),
baselinesByMac: nextProxyBaselines,
totalsByMac: nextProxyTotals,
@@ -955,15 +1203,15 @@ export function createDeviceInventoryService({
};
globalProxy = nextGlobalProxy;
} catch (error) {
proxy = { ...proxy, lastError: error.message || String(error) };
proxy = { ...proxy, lastError: errorMessage(error) };
}
}
traffic = {
...traffic,
epoch: trafficResult.epoch,
generation: trafficResult.generation || traffic.generation,
lastObservedAt: trafficResult.observedAt || traffic.lastObservedAt,
lastError: trafficResult.source?.error
epoch: trafficEpoch,
generation: trafficGeneration || traffic.generation,
lastObservedAt: trafficObservedAt || traffic.lastObservedAt,
lastError: trafficSourceError
|| (rebaselineMacs.size ? traffic.lastError : null),
baselinesByMac,
totalsByMac,
@@ -972,7 +1220,7 @@ export function createDeviceInventoryService({
global: { gateway: globalGateway, proxy: globalProxy },
};
} catch (error) {
traffic = { ...traffic, lastError: error.message || String(error) };
traffic = { ...traffic, lastError: errorMessage(error) };
}
}
}
@@ -980,13 +1228,15 @@ export function createDeviceInventoryService({
...state,
revision: state.revision + 1,
lastObservedAt: observedAt,
lastError: result?.error || null,
lastError: typeof result.error === 'string' ? result.error : null,
traffic,
devices,
};
});
captureTrafficHistory(nextState);
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
if (typeof policyResult?.transportError === 'string') {
commitPolicyFailure(new Error(policyResult.transportError));
}
return reconcileLocked(policyResult, false);
});
}
@@ -1000,52 +1250,59 @@ export function createDeviceInventoryService({
return refreshPromise;
}
function update(id, patch, expectedRevision) {
function update(id: string, patch: unknown, expectedRevision: unknown) {
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new HarborError('REQUEST_INVALID');
}
const aliasProvided = Object.hasOwn(patch, 'alias');
const pinProvided = Object.hasOwn(patch, 'pinned');
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0
const value = record(patch);
const aliasProvided = Object.hasOwn(value, 'alias');
const pinProvided = Object.hasOwn(value, 'pinned');
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|| (!aliasProvided && !pinProvided)
|| (aliasProvided && (typeof patch.alias !== 'string' || patch.alias.length > 64))
|| (pinProvided && typeof patch.pinned !== 'boolean')) {
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|| (pinProvided && typeof value.pinned !== 'boolean')) {
throw new HarborError('REQUEST_INVALID');
}
const revision = expectedRevision;
const alias = typeof value.alias === 'string' ? value.alias : '';
const pinned = value.pinned === true;
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const devices = [...state.devices];
devices[index] = {
...devices[index],
...(aliasProvided ? { alias: patch.alias.trim() } : {}),
...(pinProvided ? { pinned: patch.pinned } : {}),
...(aliasProvided ? { alias: alias.trim() } : {}),
...(pinProvided ? { pinned } : {}),
};
return { ...state, revision: state.revision + 1, devices };
});
return snapshot();
}
function setPolicy(id, mode, expectedRevision) {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
function setPolicy(id: string, mode: unknown, expectedRevision: unknown) {
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision)
|| expectedRevision < 0 || !POLICY_MODES.has(mode)) {
throw new HarborError('REQUEST_INVALID');
}
const revision = expectedRevision;
const desiredMode = mode as DevicePolicyMode;
return serializePolicy(async () => {
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
const device = state.devices.find((candidate) => candidate.id === id);
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
if (desiredMode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
const current = policyFor(state, device.mac);
if (current.desired === mode && current.status === 'applied') return state;
const byMac = {
if (current.desired === desiredMode && current.status === 'applied') return state;
const byMac: Record<string, DevicePolicyEntry> = {
...state.policy.byMac,
[device.mac]: {
...current,
desired: mode,
desired: desiredMode,
status: 'applying',
error: null,
operationId: crypto.randomUUID(),
@@ -1,33 +1,56 @@
import crypto from 'node:crypto';
import net from 'node:net';
import { spawnSync } from 'node:child_process';
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import { isDeviceInterface } from '../adapters/neighbors.js';
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
const COMMAND_OPTIONS = { encoding: 'utf8' as const, timeout: 2_000, killSignal: 'SIGKILL' as const };
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
const MAX_DEVICES = 512;
const childChain = (chain, slot) => `${chain}_${slot}`;
const fingerprint = (devices) => crypto.createHash('sha256')
export interface DirectDevice {
id: string;
ip: string;
mac: string;
interface: string;
}
interface PolicySnapshot {
epoch: string;
generation: string;
fingerprint: string;
observedAt: string;
appliedIds: string[];
changed: boolean;
}
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
const fingerprint = (devices: readonly DirectDevice[]) => crypto.createHash('sha256')
.update(JSON.stringify(devices))
.digest('hex');
function commandError(command, result) {
function commandError(command: string, result: SpawnSyncReturns<string>) {
return new Error(String(
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
).trim());
}
export function normalizeDirectDevices(value) {
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function normalizeDirectDevices(value: unknown): DirectDevice[] {
if (!Array.isArray(value) || value.length > MAX_DEVICES) {
throw new Error('Некорректный набор device policy');
}
const ids = new Set();
const tuples = new Set();
const devices = value.map((device) => {
const ids = new Set<string>();
const tuples = new Set<string>();
const devices = value.map((value) => {
const device = record(value);
const normalized = {
id: String(device?.id || ''),
ip: String(device?.ip || ''),
@@ -47,9 +70,15 @@ export function normalizeDirectDevices(value) {
return devices.sort((left, right) => left.id.localeCompare(right.id));
}
export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value));
export const fingerprintDirectDevices = (value: unknown) => fingerprint(normalizeDirectDevices(value));
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) {
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }: {
devices: readonly DirectDevice[];
chain: string;
slot: string;
tproxyPort: number;
tproxyMark: string;
}) {
const child = childChain(chain, slot);
const rules = ['*mangle', `-F ${child}`];
for (const device of devices) {
@@ -71,6 +100,13 @@ export function createDevicePolicyService({
run = spawnSync,
now = () => new Date(),
nextGeneration = () => crypto.randomUUID(),
}: {
chain: string;
tproxyPort: number;
tproxyMark: string;
run?: typeof spawnSync;
now?: () => Date;
nextGeneration?: () => string;
}) {
if (!CHAIN_PATTERN.test(String(chain || ''))
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
@@ -78,19 +114,19 @@ export function createDevicePolicyService({
throw new Error('Некорректная конфигурация device policy');
}
const epoch = nextGeneration();
let activeSlot = 'A';
let activeSlot: 'A' | 'B' = 'A';
let activeSignature = JSON.stringify([]);
let generation = epoch;
let appliedDevices = [];
let appliedDevices: DirectDevice[] = [];
let observedAt = now().toISOString();
let queue = Promise.resolve();
let queue: Promise<unknown> = Promise.resolve();
function execute(command, args, input) {
function execute(command: string, args: string[], input?: string) {
const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input });
if (result.error || result.status !== 0) throw commandError(command, result);
}
function snapshot(changed = false) {
function snapshot(changed = false): PolicySnapshot {
return {
epoch,
generation,
@@ -101,7 +137,7 @@ export function createDevicePolicyService({
};
}
function performApply(value) {
function performApply(value: unknown) {
const devices = normalizeDirectDevices(value);
const signature = JSON.stringify(devices);
if (signature === activeSignature) return snapshot(false);
@@ -124,7 +160,7 @@ export function createDevicePolicyService({
return snapshot(true);
}
function apply(devices) {
function apply(devices: unknown): Promise<PolicySnapshot> {
const result = queue.then(() => performApply(devices));
queue = result.catch(() => {});
return result;
@@ -1,9 +1,51 @@
import crypto from 'node:crypto';
import net from 'node:net';
import { spawn } from 'node:child_process';
import { isDeviceInterface } from '../adapters/neighbors.js';
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { isDeviceInterface, type NeighborObservation } from '../adapters/neighbors.js';
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
interface CommandOptions {
encoding: BufferEncoding;
timeout: number;
killSignal: NodeJS.Signals;
input?: string;
}
interface CommandResult {
status: number | null;
stdout: string;
stderr: string;
error?: unknown;
}
interface TrafficDevice {
ip: string;
mac: string;
interface: string;
key: string;
}
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
type CounterValues = Record<CounterField, bigint>;
interface RetiredCounters {
slot: 'A' | 'B';
devices: TrafficDevice[];
counters: Map<string, string>;
}
interface TrafficSnapshot {
epoch: string;
generation: string;
observedAt: string | null;
source: { error: string | null };
devices: Record<string, unknown>[];
}
type RunCommand = (command: string, args: string[], options?: CommandOptions) => Promise<CommandResult>;
const COMMAND_OPTIONS: CommandOptions = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
const COUNTERS = [
@@ -11,37 +53,38 @@ const COUNTERS = [
['download', 'download', 'downloadBytes'],
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
];
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
const childChain = (chain, slot) => `${chain}_${slot}`;
const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`;
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
const proxyChildChain = (chain: string, slot: string) => `${childChain(chain, slot)}_P`;
const counterKey = ({ ip, mac, interface: deviceInterface }: Omit<TrafficDevice, 'key'>) => crypto
.createHash('sha256')
.update(`${ip}|${mac}|${deviceInterface}`)
.digest('hex')
.slice(0, 16);
function commandError(command, result) {
function commandError(command: string, result: CommandResult) {
const cause = result.error instanceof Error ? result.error.message : result.error;
return new Error(String(
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
result.stderr || result.stdout || cause || `${command} завершился с ошибкой`,
).trim());
}
function runCommand(command, args, options = COMMAND_OPTIONS) {
function runCommand(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS): Promise<CommandResult> {
return new Promise((resolve) => {
let child;
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
} catch (error) {
resolve({ status: null, stdout: '', stderr: '', error });
return;
}
const stdout = [];
const stderr = [];
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let settled = false;
let timedOut = false;
let timer;
const finish = (result) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (result: Pick<CommandResult, 'status'> & { error?: unknown }) => {
if (settled) return;
settled = true;
clearTimeout(timer);
@@ -51,8 +94,8 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
...result,
});
};
child.stdout.on('data', (chunk) => stdout.push(chunk));
child.stderr.on('data', (chunk) => stderr.push(chunk));
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.on('error', (error) => finish({ status: null, error }));
child.on('close', (status) => finish({
status,
@@ -67,36 +110,43 @@ function runCommand(command, args, options = COMMAND_OPTIONS) {
});
}
function isIpv4Cidr(value) {
function isIpv4Cidr(value: unknown) {
const [address, prefix, extra] = String(value).split('/');
const size = Number(prefix);
return extra === undefined && net.isIPv4(address)
&& Number.isInteger(size) && size >= 0 && size <= 32;
}
const zeroCounters = () => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
export function selectTrafficDevices(observations) {
const candidates = new Map();
const ipsByMac = new Map();
const locationsByIp = new Map();
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
for (const observation of Array.isArray(observations) ? observations : []) {
const ip = String(observation?.ip || '');
const mac = String(observation?.mac || '').toLowerCase();
const deviceInterface = String(observation?.interface || '');
export function selectTrafficDevices(observations: unknown): TrafficDevice[] {
const candidates = new Map<string, Omit<TrafficDevice, 'key'>>();
const ipsByMac = new Map<string, Set<string>>();
const locationsByIp = new Map<string, Set<string>>();
for (const value of Array.isArray(observations) ? observations : []) {
const observation = record(value);
const ip = String(observation.ip || '');
const mac = String(observation.mac || '').toLowerCase();
const deviceInterface = String(observation.interface || '');
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
const location = `${mac}|${deviceInterface}`;
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
ipsByMac.get(mac).add(ip);
ipsByMac.get(mac)?.add(ip);
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
locationsByIp.get(ip).add(location);
locationsByIp.get(ip)?.add(location);
}
return [...candidates.values()]
.filter(({ ip, mac }) => ipsByMac.get(mac).size === 1 && locationsByIp.get(ip).size === 1)
.filter(({ ip, mac }) => ipsByMac.get(mac)?.size === 1 && locationsByIp.get(ip)?.size === 1)
.map((device) => ({ ...device, key: counterKey(device) }))
.sort((left, right) => (
left.ip.localeCompare(right.ip)
@@ -112,6 +162,13 @@ export function buildTrafficRestore({
downloadChain,
slot,
proxyPort,
}: {
devices: readonly TrafficDevice[];
bypassCidrs: readonly string[];
uploadChain: string;
downloadChain: string;
slot: string;
proxyPort: number;
}) {
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
@@ -160,12 +217,12 @@ export function buildTrafficRestore({
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
}
export function parseTrafficCounters(text, chain) {
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linePattern = new RegExp(
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
);
const counters = new Map();
const counters = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) {
const match = line.match(linePattern);
if (!match) continue;
@@ -183,17 +240,25 @@ export function createDeviceTrafficService({
proxyPort,
run = runCommand,
nextGeneration = () => crypto.randomUUID(),
}: {
observe: () => Promise<unknown> | unknown;
uploadChain: string;
downloadChain: string;
bypassCidrs: string[];
proxyPort: number;
run?: RunCommand;
nextGeneration?: () => string;
}) {
const epoch = nextGeneration();
let activeSlot = null;
let activeDevices = [];
let activeSlot: 'A' | 'B' | null = null;
let activeDevices: TrafficDevice[] = [];
let activeSignature = '';
let activeCounters = new Map();
let pendingRetired = null;
let refreshPromise = null;
const finalized = new Map();
const devicesByKey = new Map();
let current = {
let activeCounters = new Map<string, string>();
let pendingRetired: RetiredCounters | null = null;
let refreshPromise: Promise<TrafficSnapshot> | null = null;
const finalized = new Map<string, CounterValues>();
const devicesByKey = new Map<string, TrafficDevice>();
let current: TrafficSnapshot = {
epoch,
generation: epoch,
observedAt: null,
@@ -201,13 +266,13 @@ export function createDeviceTrafficService({
devices: [],
};
async function execute(command, args, options = COMMAND_OPTIONS) {
async function execute(command: string, args: string[], options: CommandOptions = COMMAND_OPTIONS) {
const result = await run(command, args, options);
if (result.error || result.status !== 0) throw commandError(command, result);
return String(result.stdout || '');
}
async function prepare(slot, devices) {
async function prepare(slot: 'A' | 'B', devices: TrafficDevice[]) {
const input = buildTrafficRestore({
devices,
bypassCidrs,
@@ -219,7 +284,7 @@ export function createDeviceTrafficService({
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
}
async function switchTo(slot) {
async function switchTo(slot: 'A' | 'B') {
const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot);
const replace = activeSlot ? '-R' : '-A';
@@ -242,8 +307,8 @@ export function createDeviceTrafficService({
}
}
async function readCounters(devices, slot) {
if (!slot) return new Map();
async function readCounters(devices: TrafficDevice[], slot: 'A' | 'B' | null): Promise<Map<string, string>> {
if (!slot) return new Map<string, string>();
const [raw, mangle] = await Promise.all([
execute('iptables-save', ['-c', '-t', 'raw']),
execute('iptables-save', ['-c', '-t', 'mangle']),
@@ -254,7 +319,7 @@ export function createDeviceTrafficService({
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
};
const counters = new Map();
const counters = new Map<string, string>();
for (const { key } of devices) {
for (const [kind, field] of COUNTERS) {
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
@@ -263,11 +328,11 @@ export function createDeviceTrafficService({
return counters;
}
function counter(counters, key, direction) {
function counter(counters: Map<string, string>, key: string, direction: CounterKind) {
return BigInt(counters.get(`${key}:${direction}`) || '0');
}
function remember(devices) {
function remember(devices: TrafficDevice[]) {
for (const device of devices) devicesByKey.set(device.key, device);
}
@@ -276,7 +341,7 @@ export function createDeviceTrafficService({
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
for (const { key } of pendingRetired.devices) {
const previous = finalized.get(key) || zeroCounters();
const next = { ...previous };
const next: CounterValues = { ...previous };
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
finalized.set(key, next);
}
@@ -286,12 +351,15 @@ export function createDeviceTrafficService({
function processTotals() {
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
const totalsByMac = new Map();
const totalsByMac = new Map<string, TrafficDevice & CounterValues>();
for (const [key, remembered] of devicesByKey) {
const base = finalized.get(key) || zeroCounters();
const pending = pendingRetired?.counters || new Map();
const previous = totalsByMac.get(remembered.mac) || zeroCounters();
const total = { ...(activeByMac.get(remembered.mac) || remembered) };
const previous = totalsByMac.get(remembered.mac) || { ...remembered, ...zeroCounters() };
const total: TrafficDevice & CounterValues = {
...(activeByMac.get(remembered.mac) || remembered),
...zeroCounters(),
};
for (const [kind, field] of COUNTERS) {
total[field] = previous[field] + base[field]
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
@@ -299,22 +367,28 @@ export function createDeviceTrafficService({
totalsByMac.set(remembered.mac, total);
}
return [...totalsByMac.values()]
.map((total) => Object.fromEntries([
...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)),
...COUNTERS.map(([, field, output]) => [output, total[field].toString()]),
]))
.map((total) => {
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
return {
...device,
uploadBytes: upload.toString(),
downloadBytes: download.toString(),
proxyUploadBytes: proxyUpload.toString(),
proxyDownloadBytes: proxyDownload.toString(),
};
})
.sort((left, right) => left.mac.localeCompare(right.mac));
}
async function performRefresh() {
let observed;
let observed: Record<string, unknown>;
try {
observed = await observe();
observed = record(await observe());
} catch (error) {
observed = { observedAt: new Date().toISOString(), observations: [], error: error.message || String(error) };
observed = { observedAt: new Date().toISOString(), observations: [], error: error instanceof Error ? error.message : String(error) };
}
let sourceError = observed?.error || null;
let sourceError = observed.error ? String(observed.error) : null;
const nextDevices = sourceError
? activeDevices
: selectTrafficDevices(observed?.observations);
@@ -325,7 +399,7 @@ export function createDeviceTrafficService({
try {
countersRead = await finalizeRetired() || countersRead;
} catch (error) {
sourceError = sourceError || error.message || String(error);
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
}
}
@@ -348,7 +422,7 @@ export function createDeviceTrafficService({
current.generation = nextGeneration();
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
} catch (error) {
sourceError = error.message || String(error);
sourceError = error instanceof Error ? error.message : String(error);
}
}
@@ -356,12 +430,14 @@ export function createDeviceTrafficService({
activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true;
} catch (error) {
sourceError = sourceError || error.message || String(error);
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
}
current = {
epoch,
generation: current.generation,
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
observedAt: countersRead && typeof observed.observedAt === 'string'
? observed.observedAt
: current.observedAt,
source: { error: sourceError },
devices: countersRead ? processTotals() : current.devices,
};
@@ -7,15 +7,67 @@ import { deviceId } from './deviceInventoryService.js';
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
const DEFAULT_MAX_SERIES = 4096;
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'];
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
const SERVICE_DOMAINS = [
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
];
] as const;
const matchesDomain = (domain, suffix) => domain === suffix || domain.endsWith(`.${suffix}`);
interface ParsedBaseConnection {
id: string;
upload: bigint;
download: bigint;
}
export function classifyDomain(value) {
type ParsedConnection =
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
| (ParsedBaseConnection & {
outcome: 'classified' | 'unresolved_host';
deviceId: string;
domain: string;
service: string;
source: 'gateway' | 'proxy';
});
interface PreviousConnection {
outcome: AttributionOutcome | 'classified';
key?: string;
requestedKey?: string;
countedUpload: bigint | null;
countedDownload: bigint | null;
}
interface DomainSeriesTotal {
deviceId: string;
domain: string;
service: string;
source: string;
uploadBytes: bigint;
downloadBytes: bigint;
}
interface DomainTrafficSnapshot {
epoch: string;
observedAt: string | null;
source: { error: string | null };
overflowConnections: string;
attributionEvents: Record<AttributionOutcome, string>;
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
}>;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
if (domain.startsWith('www.')) domain = domain.slice(4);
const labels = domain.split('.');
@@ -28,19 +80,20 @@ export function classifyDomain(value) {
return { domain, service: domain };
}
function sourceFor(type) {
function sourceFor(type: string): 'gateway' | 'proxy' | null {
if (type === 'tproxy/tproxy-in') return 'gateway';
if (type === 'mixed/mixed-in') return 'proxy';
return null;
}
function parseConnection(connection, devicesByIp) {
const id = String(connection?.id || '');
const metadata = connection?.metadata;
const upload = connection?.upload;
const download = connection?.download;
if (!id || !Number.isSafeInteger(upload) || upload < 0
|| !Number.isSafeInteger(download) || download < 0) {
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
const connection = record(value);
const id = String(connection.id || '');
const metadata = record(connection.metadata);
const upload = connection.upload;
const download = connection.download;
if (!id || typeof upload !== 'number' || !Number.isSafeInteger(upload) || upload < 0
|| typeof download !== 'number' || !Number.isSafeInteger(download) || download < 0) {
throw new Error('Sing-box вернул невалидный domain traffic counter');
}
const parsed = {
@@ -48,11 +101,11 @@ function parseConnection(connection, devicesByIp) {
upload: BigInt(upload),
download: BigInt(download),
};
const source = sourceFor(String(metadata?.type || ''));
const source = sourceFor(String(metadata.type || ''));
if (!source) return { ...parsed, outcome: 'unsupported_source' };
const currentDeviceId = devicesByIp.get(String(metadata?.sourceIP || ''));
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
const classifiedDomain = classifyDomain(metadata?.host);
const classifiedDomain = classifyDomain(metadata.host);
const domain = classifiedDomain || UNKNOWN_DOMAIN;
return {
...parsed,
@@ -63,13 +116,13 @@ function parseConnection(connection, devicesByIp) {
};
}
export function readSingboxConnections(port, timeoutMs = 1500) {
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
return new Promise((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
const chunks = [];
const chunks: Buffer[] = [];
let size = 0;
let tooLarge = false;
response.on('data', (chunk) => {
response.on('data', (chunk: Buffer) => {
if (tooLarge) return;
size += chunk.length;
if (size > MAX_RESPONSE_BYTES) {
@@ -102,26 +155,35 @@ export function createDomainTrafficService({
devices,
now = () => new Date(),
maxSeries = DEFAULT_MAX_SERIES,
}: {
observe: () => Promise<unknown> | unknown;
devices: () => unknown;
now?: () => Date;
maxSeries?: number;
}) {
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
const epoch = crypto.randomUUID();
const totals = new Map();
const totals = new Map<string, DomainSeriesTotal>();
const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0;
let previousConnections = new Map();
let previousConnections = new Map<string, PreviousConnection>();
let overflowConnections = 0n;
const attributionEvents = Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, 0n]));
let refreshPromise = null;
let current = {
const attributionEvents: Record<AttributionOutcome, bigint> = {
unresolved_host: 0n,
unknown_device: 0n,
unsupported_source: 0n,
};
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
let current: DomainTrafficSnapshot = {
epoch,
observedAt: null,
source: { error: null },
overflowConnections: '0',
attributionEvents: Object.fromEntries(ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, '0'])),
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
series: [],
};
function buildSnapshot(error = null) {
function buildSnapshot(error: string | null = null): DomainTrafficSnapshot {
return {
epoch,
observedAt: current.observedAt,
@@ -129,7 +191,7 @@ export function createDomainTrafficService({
overflowConnections: overflowConnections.toString(),
attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
),
) as Record<AttributionOutcome, string>,
series: [...totals.values()]
.map((entry) => ({
...entry,
@@ -147,17 +209,18 @@ export function createDomainTrafficService({
async function performRefresh() {
try {
const response = await observe();
if (!Array.isArray(response?.connections)) throw new Error('Sing-box не вернул connections array');
const devicesByIp = new Map();
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 observedDevices = devices();
for (const device of Array.isArray(observedDevices) ? observedDevices : []) {
const ip = String(device?.ip || '');
const id = typeof device?.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
const device = record(value);
const ip = String(device.ip || '');
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);
}
const activeConnections = new Map();
const activeConnections = new Map<string, PreviousConnection>();
for (const rawConnection of response.connections) {
const connection = parseConnection(rawConnection, devicesByIp);
const previous = previousConnections.get(connection.id);
@@ -172,8 +235,9 @@ export function createDomainTrafficService({
});
continue;
}
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
let key = previous?.requestedKey === requestedKey ? previous.key : requestedKey;
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
let domain = connection.domain;
let service = connection.service;
if (key !== requestedKey) {
@@ -217,7 +281,7 @@ export function createDomainTrafficService({
current = buildSnapshot();
return current;
} catch (error) {
current = buildSnapshot(error.message || String(error));
current = buildSnapshot(error instanceof Error ? error.message : String(error));
throw error;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { HarborError } from '../../shared/errors.js';
export interface RollbackStep {
run(): unknown | Promise<unknown>;
runtime?: boolean;
}
export async function finishRollback(
originalError: unknown,
steps: RollbackStep[],
message: string,
): Promise<never> {
const rollbackErrors: unknown[] = [];
let runtimeRollbackFailed = false;
for (const step of steps) {
try {
await step.run();
} catch (rollbackError) {
rollbackErrors.push(rollbackError);
runtimeRollbackFailed ||= Boolean(step.runtime);
}
}
if (rollbackErrors.length) {
const cause = new AggregateError([originalError, ...rollbackErrors], message);
if (runtimeRollbackFailed) throw new HarborError('PROCESS_START_FAILED', { cause });
throw cause;
}
throw originalError;
}
-154
View File
@@ -1,154 +0,0 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 4;
const clone = (value) => structuredClone(value);
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
function syncDirectory(directory) {
let descriptor;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch (error) {
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(error.code)) throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function atomicWriteFile(filePath, contents, { beforeRename, mode } = {}) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
let descriptor;
try {
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
beforeRename?.(temporaryPath, filePath);
fs.renameSync(temporaryPath, filePath);
syncDirectory(directory);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(temporaryPath, { force: true });
}
}
export function atomicWriteJson(filePath, value, options) {
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(value) {
const stored = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const version = Number.isSafeInteger(stored.schemaVersion) ? stored.schemaVersion : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
return {
...normalizeStoredState({ ...stored, routeRules }),
schemaVersion: STATE_SCHEMA_VERSION,
};
}
export function createJsonStore({
filePath,
defaultValue,
migrate = (value) => value,
initializeMissing = false,
backupWhen = () => false,
now = () => new Date(),
} = {}) {
let recovery = null;
let migration = null;
function write(value, options) {
const migrated = migrate(clone(value));
atomicWriteJson(filePath, migrated, options);
return clone(migrated);
}
function read() {
if (!fs.existsSync(filePath)) {
const initial = migrate(clone(defaultValue));
return initializeMissing ? write(initial) : clone(initial);
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed;
try {
parsed = JSON.parse(raw);
} catch (cause) {
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
fs.renameSync(filePath, backupPath);
try {
const recovered = write(defaultValue);
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
return recovered;
} catch (error) {
fs.renameSync(backupPath, filePath);
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
}
}
const migrated = migrate(clone(parsed));
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
if (backupWhen(parsed, migrated)) {
const fromVersion = Number.isSafeInteger(parsed?.schemaVersion) ? parsed.schemaVersion : 0;
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
migration = {
fromVersion,
toVersion: migrated.schemaVersion,
backupPath,
migratedAt: now().toISOString(),
};
}
atomicWriteJson(filePath, migrated);
}
return clone(migrated);
}
function update(mutator) {
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
const next = mutator(read());
if (next && typeof next.then === 'function') {
throw new TypeError('State store mutator must be synchronous');
}
return write(next);
}
return {
read,
write,
update,
remove: () => fs.rmSync(filePath, { force: true }),
get recovery() { return recovery; },
get migration() { return migration; },
};
}
export function createStateStore(filePath, options = {}) {
return createJsonStore({
filePath,
defaultValue: {},
migrate: migrateStoredState,
initializeMissing: true,
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
...options,
});
}
+217
View File
@@ -0,0 +1,217 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeStoredState, type StoredState } from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
export const STATE_SCHEMA_VERSION = 4;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
mode?: number;
}
interface RecoveryState {
kind: 'corrupt-json';
backupPath: string;
recoveredAt: string;
}
interface MigrationState {
fromVersion: number;
toVersion: unknown;
backupPath: string;
migratedAt: string;
}
interface JsonStoreBaseOptions {
filePath: string;
initializeMissing?: boolean;
backupWhen?: (before: unknown, after: unknown) => boolean;
now?: () => Date;
}
export interface JsonStoreOptions<T> extends JsonStoreBaseOptions {
defaultValue: T;
migrate: (value: unknown) => T;
}
export interface RawJsonStoreOptions extends JsonStoreBaseOptions {
defaultValue: unknown;
migrate?: never;
}
export interface JsonStore<T> {
read(): T;
write(value: T, options?: AtomicWriteOptions): T;
update(mutator: (value: T) => T): T;
remove(): void;
readonly recovery: RecoveryState | null;
readonly migration: MigrationState | null;
}
const clone = <T>(value: T): T => structuredClone(value);
const stamp = (value: Date) => value.toISOString().replace(/[:.]/g, '-');
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function syncDirectory(directory: string) {
let descriptor: number | undefined;
try {
descriptor = fs.openSync(directory, 'r');
fs.fsyncSync(descriptor);
} catch (error) {
const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
if (!['EINVAL', 'ENOTSUP', 'EPERM'].includes(code)) throw error;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
export function atomicWriteFile(
filePath: string,
contents: string | NodeJS.ArrayBufferView,
{ beforeRename, mode }: AtomicWriteOptions = {},
) {
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true });
const temporaryPath = path.join(
directory,
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
const fileMode = mode ?? (fs.existsSync(filePath) ? fs.statSync(filePath).mode & 0o777 : 0o666);
let descriptor: number | undefined;
try {
descriptor = fs.openSync(temporaryPath, 'wx', fileMode);
fs.writeFileSync(descriptor, contents, 'utf8');
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
beforeRename?.(temporaryPath, filePath);
fs.renameSync(temporaryPath, filePath);
syncDirectory(directory);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
fs.rmSync(temporaryPath, { force: true });
}
}
export function atomicWriteJson(filePath: string, value: unknown, options?: AtomicWriteOptions) {
atomicWriteFile(filePath, JSON.stringify(value, null, 2), options);
}
export function migrateStoredState(value: unknown): StoredState & { schemaVersion: number } {
const stored = record(value);
const version = Number.isSafeInteger(stored.schemaVersion) ? Number(stored.schemaVersion) : 0;
if (version < 0 || version > STATE_SCHEMA_VERSION) {
throw new Error(`Unsupported Harbor state schemaVersion: ${version}`);
}
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
return {
...normalizeStoredState({ ...stored, routeRules }),
schemaVersion: STATE_SCHEMA_VERSION,
};
}
export function createJsonStore<T>(options: JsonStoreOptions<T>): JsonStore<T>;
export function createJsonStore(options: RawJsonStoreOptions): JsonStore<unknown>;
export function createJsonStore(options: JsonStoreOptions<unknown> | RawJsonStoreOptions): JsonStore<unknown> {
const {
filePath,
defaultValue,
initializeMissing = false,
backupWhen = () => false,
now = () => new Date(),
} = options;
const migrate = options.migrate || ((value: unknown) => value);
let recovery: RecoveryState | null = null;
let migration: MigrationState | null = null;
function write(value: unknown, writeOptions?: AtomicWriteOptions): unknown {
const migrated = migrate(clone(value));
atomicWriteJson(filePath, migrated, writeOptions);
return clone(migrated);
}
function read(): unknown {
if (!fs.existsSync(filePath)) {
const initial = migrate(clone(defaultValue));
return initializeMissing ? write(initial) : clone(initial);
}
const raw = fs.readFileSync(filePath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
const backupPath = `${filePath}.corrupt-${stamp(now())}`;
fs.renameSync(filePath, backupPath);
try {
const recovered = write(defaultValue);
recovery = { kind: 'corrupt-json', backupPath, recoveredAt: now().toISOString() };
return recovered;
} catch (error) {
fs.renameSync(backupPath, filePath);
throw new AggregateError([cause, error], `Failed to recover corrupt JSON: ${filePath}`);
}
}
const migrated = migrate(clone(parsed));
if (JSON.stringify(migrated) !== JSON.stringify(parsed)) {
if (backupWhen(parsed, migrated)) {
const parsedRecord = record(parsed);
const fromVersion = Number.isSafeInteger(parsedRecord.schemaVersion) ? Number(parsedRecord.schemaVersion) : 0;
const migratedRecord = record(migrated);
const backupPath = `${filePath}.backup-v${fromVersion}-${stamp(now())}`;
atomicWriteFile(backupPath, raw, { mode: fs.statSync(filePath).mode & 0o777 });
migration = {
fromVersion,
toVersion: migratedRecord.schemaVersion,
backupPath,
migratedAt: now().toISOString(),
};
}
atomicWriteJson(filePath, migrated);
}
return clone(migrated);
}
function update(mutator: (value: unknown) => unknown): unknown {
// ponytail: sync mutators serialize in Node's event loop; add a queue only if updates must await I/O.
const next = mutator(read());
if (next && typeof next === 'object' && 'then' in next) {
throw new TypeError('State store mutator must be synchronous');
}
return write(next);
}
return {
read,
write,
update,
remove: () => fs.rmSync(filePath, { force: true }),
get recovery() { return recovery; },
get migration() { return migration; },
};
}
export function createStateStore(
filePath: string,
options: Partial<Omit<JsonStoreOptions<StoredState & { schemaVersion: number }>, 'filePath' | 'defaultValue' | 'migrate'>> = {},
) {
return createJsonStore<StoredState & { schemaVersion: number }>({
filePath,
defaultValue: migrateStoredState({}),
migrate: migrateStoredState,
initializeMissing: true,
backupWhen: (before, after) => record(before).schemaVersion !== record(after).schemaVersion,
...options,
});
}