Add per-device VPN and direct routing policies
Build and Deploy Gateway / build-and-push (push) Successful in 10s
Build and Deploy Gateway / deploy (push) Successful in 16s

This commit is contained in:
2026-08-07 16:18:31 +03:00
parent 307ad02cd7
commit 560c243047
20 changed files with 1090 additions and 114 deletions
+2
View File
@@ -15,7 +15,9 @@ export const settings = {
port: parsePort(process.env.PORT, 3456),
proxyPort,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyMark: process.env.TPROXY_MARK || "1",
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
bypassCidrs: (process.env.BYPASS_CIDRS
+41
View File
@@ -6,6 +6,7 @@ import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js';
import { readNeighborSnapshot } from './adapters/neighbors.js';
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
import { createDevicePolicyService } from './services/devicePolicyService.js';
const socketPath = settings.dataplaneSocket;
const runtime = createSingboxRuntime({
@@ -20,8 +21,40 @@ const traffic = createDeviceTrafficService({
downloadChain: settings.trafficDownloadChain,
bypassCidrs: settings.bypassCidrs,
});
const devicePolicy = createDevicePolicyService({
chain: settings.devicePolicyChain,
tproxyPort: settings.tproxyPort,
tproxyMark: settings.tproxyMark,
});
let ready = false;
let trafficTimer = null;
const MAX_POLICY_BODY_BYTES = 256 * 1024;
function readJson(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooLarge = false;
req.on('data', (chunk) => {
size += chunk.length;
if (!tooLarge && size > MAX_POLICY_BODY_BYTES) {
tooLarge = true;
reject(new Error('Device policy request слишком большой'));
return;
}
if (!tooLarge) chunks.push(chunk);
});
req.on('end', () => {
if (tooLarge) return;
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
} catch {
reject(new Error('Device policy request содержит невалидный JSON'));
}
});
req.on('error', reject);
});
}
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
@@ -35,6 +68,7 @@ const server = http.createServer(async (req, res) => {
...await runtime.refresh(),
gatewayBackendVersion: versionInfo.components.gatewayBackend,
singBoxVersion: versionInfo.runtime.singBox,
devicePolicy: devicePolicy.snapshot(),
ready,
});
}
@@ -44,6 +78,13 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/device-traffic') {
return sendJson(res, 200, traffic.snapshot());
}
if (req.method === 'GET' && req.url === '/device-policy') {
return sendJson(res, 200, devicePolicy.snapshot());
}
if (req.method === 'PUT' && req.url === '/device-policy') {
const body = await readJson(req);
return sendJson(res, 200, await devicePolicy.apply(body.devices));
}
if (req.method === 'POST' && req.url === '/apply') {
return sendJson(res, 200, await runtime.apply());
}
+14 -3
View File
@@ -1,9 +1,18 @@
import http from 'node:http';
import { HarborError } from '../shared/errors.js';
function request(socketPath, pathname, method = 'GET') {
function request(socketPath, pathname, method = 'GET', body = null) {
return new Promise((resolve, reject) => {
const req = http.request({ socketPath, path: pathname, method }, (res) => {
const encoded = body == null ? null : JSON.stringify(body);
const req = http.request({
socketPath,
path: pathname,
method,
headers: encoded ? {
'content-type': 'application/json',
'content-length': Buffer.byteLength(encoded),
} : {},
}, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
@@ -21,7 +30,7 @@ function request(socketPath, pathname, method = 'GET') {
});
req.on('error', reject);
req.setTimeout(6000, () => req.destroy(new Error('Dataplane не ответил за 6 секунд')));
req.end();
req.end(encoded);
});
}
@@ -45,6 +54,8 @@ export function createDataplaneClient(socketPath, send = request) {
refresh: () => update('/status', 'GET'),
observeDevices: () => send(socketPath, '/devices', 'GET'),
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'),
stop: () => update('/stop', 'POST'),
+32 -1
View File
@@ -39,6 +39,7 @@ import {
import { HarborError, normalizeHarborError } from '../shared/errors.js';
import { normalizeRouteRules } from '../shared/routingRules.js';
import { createJsonStore, createStateStore } from './services/stateStore.js';
import { createDevicePolicyService } from './services/devicePolicyService.js';
import {
createDeviceInventoryService,
createVendorLookup,
@@ -108,6 +109,13 @@ const singboxRuntime = remoteDataplane
gateway: settings.appMode === 'gateway',
tproxyChain: settings.tproxyChain,
});
const localDevicePolicy = settings.appMode === 'gateway' && !remoteDataplane
? createDevicePolicyService({
chain: settings.devicePolicyChain,
tproxyPort: settings.tproxyPort,
tproxyMark: settings.tproxyMark,
})
: null;
const deviceInventory = settings.appMode === 'gateway'
? createDeviceInventoryService({
store: deviceStore,
@@ -117,6 +125,12 @@ const deviceInventory = settings.appMode === 'gateway'
observeTraffic: remoteDataplane
? () => singboxRuntime.observeTraffic()
: null,
observePolicy: remoteDataplane
? () => singboxRuntime.observeDevicePolicy()
: () => localDevicePolicy.snapshot(),
applyPolicies: remoteDataplane
? (devices) => singboxRuntime.applyDevicePolicies(devices)
: (devices) => localDevicePolicy.apply(devices),
vendor: createVendorLookup(),
})
: null;
@@ -644,7 +658,19 @@ async function handleApi(req, res) {
if (deviceMatch && req.method === 'PUT') {
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
const body = await readBody(req);
return sendJson(res, 200, deviceInventory.update(deviceMatch[1], body, body.expectedRevision));
const { expectedRevision, ...patch } = body;
return sendJson(res, 200, deviceInventory.update(deviceMatch[1], patch, expectedRevision));
}
const devicePolicyMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})\/policy$/);
if (devicePolicyMatch && req.method === 'PUT') {
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
const body = await readBody(req);
return sendJson(res, 200, await deviceInventory.setPolicy(
devicePolicyMatch[1],
body.mode,
body.expectedRevision,
));
}
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
@@ -847,6 +873,11 @@ await startSingbox()
})
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
if (deviceInventory) {
await deviceInventory.reconcilePolicies()
.catch((error) => console.warn(`[control] device policy не применена: ${error.message}`));
}
server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
});
+339 -48
View File
@@ -2,19 +2,46 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import net from 'node:net';
import { HarborError } from '../../shared/errors.js';
import { fingerprintDirectDevices } from './devicePolicyService.js';
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
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 INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
const POLICY_MODES = new Set(['vpn', 'direct']);
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
const DEFAULT_DEVICE_POLICY = Object.freeze({
desired: 'vpn',
applied: 'vpn',
status: 'applied',
appliedAt: null,
error: null,
operationId: null,
});
const DEFAULT_POLICY_STATE = {
schemaVersion: 1,
defaultMode: 'vpn',
dataplaneEpoch: null,
generation: null,
fingerprint: null,
lastAppliedAt: null,
lastError: null,
byMac: {},
};
const DEFAULT_STATE = {
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: 0,
lastObservedAt: null,
lastError: null,
policy: DEFAULT_POLICY_STATE,
traffic: {
epoch: null,
generation: null,
@@ -38,6 +65,49 @@ const parseStoredCounter = (value) => {
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
};
function normalizePolicyState(value) {
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const byMac = {};
let recovered = value !== undefined && (
policy.schemaVersion !== 1
|| policy.defaultMode !== 'vpn'
|| !policy.byMac || typeof policy.byMac !== 'object' || Array.isArray(policy.byMac)
|| (policy.dataplaneEpoch != null && typeof policy.dataplaneEpoch !== 'string')
|| (policy.generation != null && typeof policy.generation !== 'string')
|| (policy.fingerprint != null && typeof policy.fingerprint !== 'string')
|| (policy.lastAppliedAt != null && typeof policy.lastAppliedAt !== 'string')
);
for (const [rawMac, entry] of recordEntries(policy.byMac)) {
const mac = normalizeMac(rawMac);
if (!MAC_PATTERN.test(mac) || !POLICY_MODES.has(entry?.desired)
|| !POLICY_MODES.has(entry?.applied) || !POLICY_STATUSES.has(entry?.status)) {
recovered = true;
continue;
}
byMac[mac] = {
desired: entry.desired,
applied: entry.applied,
status: entry.status,
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
error: typeof entry.error === 'string' ? entry.error : null,
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
};
}
return {
...DEFAULT_POLICY_STATE,
dataplaneEpoch: typeof policy.dataplaneEpoch === 'string' ? policy.dataplaneEpoch : null,
generation: typeof policy.generation === 'string' ? policy.generation : null,
fingerprint: typeof policy.fingerprint === 'string' ? policy.fingerprint : null,
lastAppliedAt: typeof policy.lastAppliedAt === 'string' ? policy.lastAppliedAt : null,
schemaVersion: 1,
defaultMode: 'vpn',
lastError: recovered
? 'Повреждённый device policy checkpoint восстановлен из корректных данных'
: typeof policy.lastError === 'string' ? policy.lastError : null,
byMac,
};
}
export function parseOuiVendors(text) {
const vendors = new Map();
for (const line of String(text || '').split(/\r?\n/)) {
@@ -122,6 +192,7 @@ export function migrateDeviceInventoryState(value) {
...state,
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
policy: normalizePolicyState(state.policy),
traffic: {
...DEFAULT_STATE.traffic,
...traffic,
@@ -147,10 +218,57 @@ export function createDeviceInventoryService({
store,
observe,
observeTraffic = null,
observePolicy = null,
applyPolicies = null,
vendor = () => null,
now = () => new Date(),
}) {
let refreshPromise = null;
let policyQueue = Promise.resolve();
function serializePolicy(action) {
const result = policyQueue.then(action, action);
policyQueue = result.catch(() => {});
return result;
}
function policyFor(state, mac) {
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
}
function policyIdentity(device) {
return device?.pinned && device.confidence !== 'ambiguous'
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
&& INTERFACE_PATTERN.test(String(device.interface || ''))
&& !String(device.interface).startsWith('br-');
}
function directDevices(state) {
return state.devices
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
.map(({ id, ip, mac, interface: deviceInterface }) => ({
id,
ip,
mac,
interface: deviceInterface,
}));
}
function validatePolicyAck(result, requested) {
const appliedIds = Array.isArray(result?.appliedIds) ? result.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)
|| appliedIds.length !== expectedIds.size
|| new Set(appliedIds).size !== appliedIds.length
|| appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
}
return result;
}
function snapshot() {
const state = migrateDeviceInventoryState(store.read());
@@ -158,12 +276,18 @@ export function createDeviceInventoryService({
const rank = { online: 0, recent: 1, offline: 2 };
const devices = state.devices.map((device) => {
const traffic = state.traffic.totalsByMac[device.mac];
const policy = policyFor(state, device.mac);
return {
...device,
status: deviceStatus(device.lastSeenAt, current),
uploadBytes: traffic?.uploadBytes || '0',
downloadBytes: traffic?.downloadBytes || '0',
trafficObservedAt: traffic?.observedAt || null,
desiredPolicy: policy.desired,
appliedPolicy: policy.applied,
policyStatus: policy.status,
policyAppliedAt: policy.appliedAt,
policyError: policy.error,
};
}).sort((left, right) => (
Number(right.pinned) - Number(left.pinned)
@@ -180,13 +304,127 @@ export function createDeviceInventoryService({
lastObservedAt: state.traffic.lastObservedAt,
error: state.traffic.lastError,
},
policy: {
lastAppliedAt: state.policy.lastAppliedAt,
error: state.policy.lastError,
},
},
devices,
};
}
function markPolicyEpoch(observed) {
if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return;
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state;
const appliedIds = new Set(observed.appliedIds);
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
const byMac = {};
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';
if (entry.desired === 'vpn' && applied === 'vpn') continue;
byMac[mac] = {
...entry,
applied,
status: entry.desired === applied ? 'applied' : 'pending',
appliedAt: observed.observedAt || entry.appliedAt,
error: entry.desired === applied
? null
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
operationId: null,
};
}
return {
...state,
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,
lastError: null,
byMac,
},
};
});
}
function commitPolicySuccess(result) {
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 = {};
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
const device = devicesByMac.get(mac);
if (!device) continue;
if (entry.desired === 'vpn' && !appliedIds.has(device?.id)) continue;
const applied = appliedIds.has(device?.id) ? 'direct' : 'vpn';
byMac[mac] = {
...entry,
applied,
status: entry.desired === applied ? 'applied' : 'pending',
appliedAt: result.observedAt || state.policy.lastAppliedAt,
error: entry.desired === applied
? null
: 'Gateway должен однозначно распознать устройство',
operationId: null,
};
}
const policy = {
...state.policy,
dataplaneEpoch: result.epoch,
generation: result.generation,
fingerprint: result.fingerprint,
lastAppliedAt: result.observedAt || state.policy.lastAppliedAt,
lastError: null,
byMac,
};
if (JSON.stringify(policy) === JSON.stringify(state.policy)) return state;
return { ...state, revision: state.revision + 1, policy };
});
}
function commitPolicyFailure(error) {
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, {
...entry,
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
operationId: null,
}]));
return {
...state,
revision: state.revision + 1,
policy: { ...state.policy, lastError: message, byMac },
};
});
}
async function reconcileLocked(observedPolicy, throwOnError) {
if (!applyPolicies) return snapshot();
markPolicyEpoch(observedPolicy);
const state = migrateDeviceInventoryState(store.read());
try {
const requested = directDevices(state);
const result = validatePolicyAck(await applyPolicies(requested), requested);
commitPolicySuccess(result);
return snapshot();
} catch (cause) {
commitPolicyFailure(cause);
if (throwOnError) throw new HarborError('DEVICE_POLICY_APPLY_FAILED', { cause });
return snapshot();
}
}
async function performRefresh() {
const [result, trafficResult] = await Promise.all([
const [result, trafficResult, policyResult] = await Promise.all([
Promise.resolve().then(() => observe()).catch((error) => ({
observedAt: now().toISOString(),
observations: [],
@@ -196,49 +434,54 @@ export function createDeviceInventoryService({
? Promise.resolve().then(() => observeTraffic())
.catch((error) => ({ transportError: error.message || String(error) }))
: null,
observePolicy
? Promise.resolve().then(() => observePolicy())
.catch((error) => ({ transportError: error.message || String(error) }))
: null,
]);
const observedAt = result?.observedAt || now().toISOString();
const observations = Array.isArray(result?.observations) ? result.observations : [];
const ipsByMac = new Map();
const identitiesByMac = new Map();
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
ipsByMac.get(mac).add(String(observation.ip));
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
}
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac) continue;
const previous = byMac.get(mac);
const lastSeenAt = observation.active || !previous
? observation.observedAt || observedAt
: previous.lastSeenAt;
byMac.set(mac, {
id: previous?.id || deviceId(mac),
alias: previous?.alias || '',
pinned: previous?.pinned === true,
hostname: previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac),
mac,
ip: String(observation.ip || previous?.ip || ''),
interface: String(observation.interface || previous?.interface || ''),
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
lastSeenAt,
source: 'neighbor',
confidence: ipsByMac.get(mac)?.size > 1
? 'ambiguous'
: isPrivateMac(mac) ? 'medium' : 'high',
});
}
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const devices = [...byMac.values()].filter((device) => (
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
));
let traffic = state.traffic;
if (trafficResult) {
return serializePolicy(async () => {
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
for (const observation of observations) {
const mac = normalizeMac(observation.mac);
if (!mac) continue;
const previous = byMac.get(mac);
const lastSeenAt = observation.active || !previous
? observation.observedAt || observedAt
: previous.lastSeenAt;
byMac.set(mac, {
id: previous?.id || deviceId(mac),
alias: previous?.alias || '',
pinned: previous?.pinned === true,
hostname: previous?.hostname || null,
manufacturer: previous?.manufacturer || vendor(mac),
mac,
ip: String(observation.ip || previous?.ip || ''),
interface: String(observation.interface || previous?.interface || ''),
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
lastSeenAt,
source: 'neighbor',
confidence: identitiesByMac.get(mac)?.size > 1
? 'ambiguous'
: isPrivateMac(mac) ? 'medium' : 'high',
});
}
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
const devices = [...byMac.values()].filter((device) => (
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
));
let traffic = state.traffic;
if (trafficResult) {
if (trafficResult.transportError) {
traffic = { ...traffic, lastError: trafficResult.transportError };
} else {
@@ -318,17 +561,19 @@ export function createDeviceInventoryService({
traffic = { ...traffic, lastError: error.message || String(error) };
}
}
}
return {
...state,
revision: state.revision + 1,
lastObservedAt: observedAt,
lastError: result?.error || null,
traffic,
devices,
};
}
return {
...state,
revision: state.revision + 1,
lastObservedAt: observedAt,
lastError: result?.error || null,
traffic,
devices,
};
});
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
return reconcileLocked(policyResult, false);
});
return snapshot();
}
function refresh() {
@@ -357,6 +602,11 @@ export function createDeviceInventoryService({
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const currentPolicy = policyFor(state, state.devices[index].mac);
if (pinProvided && patch.pinned === false
&& (currentPolicy.desired === 'direct' || currentPolicy.applied === 'direct')) {
throw new HarborError('REQUEST_INVALID');
}
const devices = [...state.devices];
devices[index] = {
...devices[index],
@@ -368,5 +618,46 @@ export function createDeviceInventoryService({
return snapshot();
}
return { snapshot, refresh, update };
function setPolicy(id, mode, expectedRevision) {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
throw new HarborError('REQUEST_INVALID');
}
return serializePolicy(async () => {
store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
if (state.revision !== expectedRevision) 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' && !device.pinned) throw new HarborError('DEVICE_POLICY_REQUIRES_PIN');
if (mode === '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 = {
...state.policy.byMac,
[device.mac]: {
...current,
desired: mode,
status: 'applying',
error: null,
operationId: crypto.randomUUID(),
},
};
return {
...state,
revision: state.revision + 1,
policy: { ...state.policy, lastError: null, byMac },
};
});
return reconcileLocked(null, true);
});
}
async function reconcilePolicies() {
const observed = observePolicy
? await Promise.resolve().then(() => observePolicy()).catch(() => null)
: null;
return serializePolicy(() => reconcileLocked(observed, true));
}
return { snapshot, refresh, update, setPolicy, reconcilePolicies };
}
+134
View File
@@ -0,0 +1,134 @@
import crypto from 'node:crypto';
import net from 'node:net';
import { spawnSync } from 'node:child_process';
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
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')
.update(JSON.stringify(devices))
.digest('hex');
function commandError(command, result) {
return new Error(String(
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
).trim());
}
export function normalizeDirectDevices(value) {
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 normalized = {
id: String(device?.id || ''),
ip: String(device?.ip || ''),
mac: String(device?.mac || '').toLowerCase(),
interface: String(device?.interface || ''),
};
const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`;
if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip)
|| !MAC_PATTERN.test(normalized.mac) || !INTERFACE_PATTERN.test(normalized.interface)
|| normalized.interface.startsWith('br-') || ids.has(normalized.id) || tuples.has(tuple)) {
throw new Error('Некорректная или повторяющаяся device policy identity');
}
ids.add(normalized.id);
tuples.add(tuple);
return normalized;
});
return devices.sort((left, right) => left.id.localeCompare(right.id));
}
export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value));
export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) {
const child = childChain(chain, slot);
const rules = ['*mangle', `-F ${child}`];
for (const device of devices) {
rules.push(`-A ${child} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-policy:${device.id}:direct -j RETURN`);
}
rules.push(
`-A ${child} -p tcp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
`-A ${child} -p udp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`,
'COMMIT',
'',
);
return rules.join('\n');
}
export function createDevicePolicyService({
chain,
tproxyPort,
tproxyMark,
run = spawnSync,
now = () => new Date(),
nextGeneration = () => crypto.randomUUID(),
}) {
if (!CHAIN_PATTERN.test(String(chain || ''))
|| !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535
|| !MARK_PATTERN.test(String(tproxyMark || ''))) {
throw new Error('Некорректная конфигурация device policy');
}
const epoch = nextGeneration();
let activeSlot = 'A';
let activeSignature = JSON.stringify([]);
let generation = epoch;
let appliedDevices = [];
let observedAt = now().toISOString();
let queue = Promise.resolve();
function execute(command, args, input) {
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) {
return {
epoch,
generation,
fingerprint: fingerprint(appliedDevices),
observedAt,
appliedIds: appliedDevices.map(({ id }) => id),
changed,
};
}
function performApply(value) {
const devices = normalizeDirectDevices(value);
const signature = JSON.stringify(devices);
if (signature === activeSignature) return snapshot(false);
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
execute('iptables-restore', ['-w', '1', '--noflush'], buildDevicePolicyRestore({
devices,
chain,
slot: nextSlot,
tproxyPort,
tproxyMark,
}));
execute('iptables', [
'-w', '1', '-t', 'mangle', '-R', chain, '1', '-j', childChain(chain, nextSlot),
]);
activeSlot = nextSlot;
activeSignature = signature;
appliedDevices = devices;
generation = nextGeneration();
observedAt = now().toISOString();
return snapshot(true);
}
function apply(devices) {
const result = queue.then(() => performApply(devices));
queue = result.catch(() => {});
return result;
}
return { apply, snapshot: () => snapshot(false) };
}
+3
View File
@@ -11,6 +11,9 @@ export const ERROR_DEFINITIONS = Object.freeze({
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
DEVICE_POLICY_REQUIRES_PIN: { status: 409, message: 'Сначала закрепите устройство.', retryable: false },
DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.11.0',
gatewayClient: '0.11.0',
gatewayBackend: '0.11.0',
macClient: '0.12.0',
gatewayClient: '0.12.0',
gatewayBackend: '0.12.0',
});
export function parseVersion(value) {
+4
View File
@@ -86,6 +86,10 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ ...patch, expectedRevision }),
}),
setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, {
method: 'PUT',
body: JSON.stringify({ mode, expectedRevision }),
}),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
+88 -16
View File
@@ -130,6 +130,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
setEditingId('');
}
async function updatePolicy(device, mode) {
setSavingId(device.id);
try {
let next;
try {
next = await api.devices.setPolicy(device.id, mode, snapshot.revision);
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
next = await api.devices.setPolicy(device.id, mode, latest.revision);
}
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
setError(null);
} catch (requestError) {
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
const latest = await api.devices.list();
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
} catch {
// Keep the policy error as the actionable result.
}
}
setError(requestError);
} finally {
setSavingId('');
}
}
return (
<aside
ref={panelRef}
@@ -197,6 +228,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
Трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.policy?.error && (
<p className="client-devices-source" role="status">
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
</p>
)}
{error && (
<div className="client-devices-error" role="alert">
<span>{error.message}</span>
@@ -217,6 +253,29 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const uncertainIdentity = device.confidence !== 'high';
const download = formatByteString(device.downloadBytes);
const upload = formatByteString(device.uploadBytes);
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending';
const displayPolicy = device.appliedPolicy;
const policyTarget = device.policyStatus === 'applied'
? device.appliedPolicy === 'direct' ? 'vpn' : 'direct'
: device.appliedPolicy;
const cannotEnableDirect = device.policyStatus === 'applied'
&& device.appliedPolicy !== 'direct'
&& (!device.pinned || device.confidence === 'ambiguous');
const policyTooltip = policyBusy
? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}`
: policyFailed
? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут`
: policyPending
? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание'
: !device.pinned
? 'Закрепите устройство, чтобы изменить маршрут'
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
: displayPolicy === 'direct'
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
return <article
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
@@ -260,53 +319,66 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</span>
</div>
)}
<span className="client-device-traffic-slot">
{device.trafficObservedAt && <span
className="client-device-traffic"
aria-label={`Получено ${download}, отдано ${upload}`}
>
<span aria-hidden="true"> {download} · {upload}</span>
</span>}
</span>
<span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
aria-pressed={device.pinned}
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
disabled={saving}
disabled={saving || device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'}
onClick={() => updateDevice(device, { pinned: !device.pinned })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
</svg>
</button>
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
<Tooltip>{device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
? 'Сначала верните маршрут через Gateway'
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</div>
<div className="client-device-meta">
<div className="client-device-addresses">
{title !== device.ip && device.ip && <span>{device.ip}</span>}
{device.mac && <span className="client-device-mac">
{device.mac}
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
<Tooltip>{device.confidence === 'medium'
? 'Устройство использует приватный MAC, производитель может не определиться'
: device.confidence === 'ambiguous'
? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными'
? 'Gateway видит это устройство с несколькими IP или интерфейсами, поэтому индивидуальное правило небезопасно'
: 'Устройство определено приблизительно'}</Tooltip>
</span>}
</span>}
</div>
<span className="client-device-policy-wrap client-tooltip-anchor">
<button
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
type="button"
aria-label={`Маршрут устройства: ${displayPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}. ${policyTooltip}`}
aria-pressed={displayPolicy === 'direct'}
aria-busy={policyBusy}
disabled={saving || policyBusy || cannotEnableDirect}
onClick={() => updatePolicy(device, policyTarget)}
>
{displayPolicy === 'direct' ? 'Напрямую' : 'VPN'}
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>
<span className="client-device-last-seen" tabIndex="0">
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
<TextMorph from={seen.label} to={seen.relative} />
</time>
</span>
</div>
{(device.manufacturer || device.trafficObservedAt) && <div className="client-device-details">
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
{device.trafficObservedAt && <span
className="client-device-traffic"
aria-label={`Получено ${download}, отдано ${upload}`}
>
<span aria-hidden="true"> {download} · {upload}</span>
</span>}
</div>}
</article>;
})}
</div>
+68 -31
View File
@@ -878,17 +878,17 @@ p {
.client-device {
display: grid;
gap: 7px;
padding: 14px 8px;
gap: 3px;
padding: 10px 8px;
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
}
.client-device-heading {
display: grid;
grid-template-columns: 68px minmax(0, 1fr) 32px;
grid-template-columns: 60px minmax(0, 1fr) auto 32px;
align-items: center;
gap: 8px;
min-height: 32px;
gap: 6px;
min-height: 28px;
}
.client-device-title {
@@ -910,7 +910,7 @@ p {
.client-device-status {
display: flex;
align-items: center;
gap: 7px;
gap: 6px;
color: var(--client-muted);
font-size: 9px;
white-space: nowrap;
@@ -992,11 +992,11 @@ p {
.client-device-meta {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 16px;
margin-left: 76px;
display: grid;
grid-template-columns: minmax(0, 1fr) 72px auto;
align-items: center;
gap: 8px;
margin-left: 66px;
color: var(--client-muted);
font-size: 9px;
line-height: 1.5;
@@ -1005,7 +1005,7 @@ p {
.client-device-addresses {
min-width: 0;
display: flex;
flex-wrap: wrap;
overflow: hidden;
gap: 3px 0;
}
@@ -1019,11 +1019,6 @@ p {
content: '·';
}
.client-device-mac {
display: inline-flex;
align-items: center;
}
.client-device-identity {
position: relative;
margin-left: 4px;
@@ -1032,8 +1027,6 @@ p {
}
.client-device-last-seen {
flex: 0 0 auto;
margin-left: auto;
color: var(--client-text);
cursor: default;
white-space: nowrap;
@@ -1100,17 +1093,6 @@ p {
outline-offset: 3px;
}
.client-device-details {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 4px 12px;
margin-left: 76px;
font-size: 9px;
}
.client-device-manufacturer {
min-width: 0;
overflow: hidden;
@@ -1120,12 +1102,66 @@ p {
}
.client-device-traffic {
margin-left: auto;
color: var(--client-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.client-device-traffic-slot {
min-width: 0;
font-size: 9px;
}
.client-device-policy-wrap {
width: 72px;
position: relative;
}
.client-device-policy {
width: 72px;
min-height: 22px;
padding: 0 5px;
border: 1px solid color-mix(in oklch, var(--client-border) 82%, transparent);
border-radius: 5px;
background: transparent;
color: var(--client-muted);
font: 700 8px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-transform: uppercase;
transition: border-color 180ms ease, color 180ms ease, opacity 180ms ease;
}
.client-device-policy.is-direct {
border-color: color-mix(in oklch, var(--client-accent) 52%, var(--client-border));
color: var(--client-accent);
}
.client-device-policy.is-failed {
border-color: oklch(0.62 0.13 28);
color: oklch(0.7 0.12 28);
}
.client-device-policy.is-pending {
border-style: dashed;
}
.client-device-policy:hover:not(:disabled),
.client-device-policy:focus-visible {
border-color: var(--client-accent);
color: var(--client-accent);
}
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip {
right: 0;
left: auto;
text-transform: none;
transform: translate(0, 2px);
}
.client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip,
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0);
}
.client-device-alias {
display: grid;
grid-template-columns: minmax(0, 1fr) 28px 28px;
@@ -4097,6 +4133,7 @@ p {
.client-device,
.client-device-pin,
.client-device-pin svg,
.client-device-policy,
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,