Add per-device VPN and direct routing policies
This commit is contained in:
@@ -111,7 +111,7 @@ jobs:
|
|||||||
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
||||||
UPDATE_DATAPLANE=false
|
UPDATE_DATAPLANE=false
|
||||||
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
|
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
|
||||||
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/deviceTrafficService)\.js|src/shared/errors\.js)$'; then
|
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/(deviceTrafficService|devicePolicyService))\.js|src/shared/errors\.js)$'; then
|
||||||
UPDATE_DATAPLANE=true
|
UPDATE_DATAPLANE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ http://АДРЕС-GATEWAY:3456
|
|||||||
|
|
||||||
### Устройства Gateway
|
### Устройства Gateway
|
||||||
|
|
||||||
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей, показывает IP, MAC, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список от большего объёма к меньшему или наоборот.
|
После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённые значения полученного/отданного интернет-трафика. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные traffic totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список от большего объёма к меньшему или наоборот.
|
||||||
|
|
||||||
|
У закреплённого и однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую`. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут; перед откреплением устройство нужно вернуть в `VPN`.
|
||||||
|
|
||||||
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. Локальные, приватные и multicast-пакеты в traffic totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. Локальные, приватные и multicast-пакеты в traffic totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -6,6 +6,7 @@ TPROXY_PORT="${TPROXY_PORT:-7895}"
|
|||||||
TPROXY_MARK="${TPROXY_MARK:-1}"
|
TPROXY_MARK="${TPROXY_MARK:-1}"
|
||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
|
DEVICE_POLICY_CHAIN="${DEVICE_POLICY_CHAIN:-VPN_PROXY_DEVICE_POLICY}"
|
||||||
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
|
||||||
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
|
||||||
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
|
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
|
||||||
@@ -17,7 +18,7 @@ PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
|
|||||||
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
|
||||||
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
export TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS
|
export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[gateway-entrypoint] %s\n' "$*"
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
@@ -50,6 +51,25 @@ cleanup_tproxy() {
|
|||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_device_policy() {
|
||||||
|
ipt -t mangle -F "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt -t mangle -F "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt -t mangle -X "${DEVICE_POLICY_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt -t mangle -X "$DEVICE_POLICY_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_policy() {
|
||||||
|
cleanup_device_policy
|
||||||
|
ipt -t mangle -N "$DEVICE_POLICY_CHAIN" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
ipt -t mangle -N "${DEVICE_POLICY_CHAIN}_B" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "${DEVICE_POLICY_CHAIN}_A" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" || return 1
|
||||||
|
ipt -t mangle -A "$DEVICE_POLICY_CHAIN" -j "${DEVICE_POLICY_CHAIN}_A" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_gateway_forwarding() {
|
cleanup_gateway_forwarding() {
|
||||||
ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true
|
||||||
ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
@@ -129,6 +149,10 @@ setup_gateway_forwarding() {
|
|||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
log "setup tproxy on port ${TPROXY_PORT}"
|
log "setup tproxy on port ${TPROXY_PORT}"
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
if ! setup_device_policy; then
|
||||||
|
log "device policy unavailable; using global VPN fallback"
|
||||||
|
cleanup_device_policy
|
||||||
|
fi
|
||||||
enable_ip_forwarding
|
enable_ip_forwarding
|
||||||
|
|
||||||
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule add fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
@@ -143,8 +167,12 @@ setup_tproxy() {
|
|||||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
done
|
done
|
||||||
|
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
|
||||||
|
else
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_gateway_forwarding
|
setup_gateway_forwarding
|
||||||
@@ -168,6 +196,7 @@ shutdown() {
|
|||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
cleanup_device_traffic
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
cleanup_gateway_forwarding
|
cleanup_gateway_forwarding
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,5 +206,6 @@ STATUS=$?
|
|||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
cleanup_device_traffic
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
|
cleanup_device_policy
|
||||||
cleanup_gateway_forwarding
|
cleanup_gateway_forwarding
|
||||||
exit "$STATUS"
|
exit "$STATUS"
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export const settings = {
|
|||||||
port: parsePort(process.env.PORT, 3456),
|
port: parsePort(process.env.PORT, 3456),
|
||||||
proxyPort,
|
proxyPort,
|
||||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
|
tproxyMark: process.env.TPROXY_MARK || "1",
|
||||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
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",
|
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
|
||||||
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
||||||
bypassCidrs: (process.env.BYPASS_CIDRS
|
bypassCidrs: (process.env.BYPASS_CIDRS
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createSingboxRuntime } from './singboxRuntime.js';
|
|||||||
import { buildVersionInfo } from './version.js';
|
import { buildVersionInfo } from './version.js';
|
||||||
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
import { readNeighborSnapshot } from './adapters/neighbors.js';
|
||||||
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
|
|
||||||
const socketPath = settings.dataplaneSocket;
|
const socketPath = settings.dataplaneSocket;
|
||||||
const runtime = createSingboxRuntime({
|
const runtime = createSingboxRuntime({
|
||||||
@@ -20,8 +21,40 @@ const traffic = createDeviceTrafficService({
|
|||||||
downloadChain: settings.trafficDownloadChain,
|
downloadChain: settings.trafficDownloadChain,
|
||||||
bypassCidrs: settings.bypassCidrs,
|
bypassCidrs: settings.bypassCidrs,
|
||||||
});
|
});
|
||||||
|
const devicePolicy = createDevicePolicyService({
|
||||||
|
chain: settings.devicePolicyChain,
|
||||||
|
tproxyPort: settings.tproxyPort,
|
||||||
|
tproxyMark: settings.tproxyMark,
|
||||||
|
});
|
||||||
let ready = false;
|
let ready = false;
|
||||||
let trafficTimer = null;
|
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) {
|
function sendJson(res, statusCode, payload) {
|
||||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||||
@@ -35,6 +68,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
...await runtime.refresh(),
|
...await runtime.refresh(),
|
||||||
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
gatewayBackendVersion: versionInfo.components.gatewayBackend,
|
||||||
singBoxVersion: versionInfo.runtime.singBox,
|
singBoxVersion: versionInfo.runtime.singBox,
|
||||||
|
devicePolicy: devicePolicy.snapshot(),
|
||||||
ready,
|
ready,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -44,6 +78,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET' && req.url === '/device-traffic') {
|
if (req.method === 'GET' && req.url === '/device-traffic') {
|
||||||
return sendJson(res, 200, traffic.snapshot());
|
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') {
|
if (req.method === 'POST' && req.url === '/apply') {
|
||||||
return sendJson(res, 200, await runtime.apply());
|
return sendJson(res, 200, await runtime.apply());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { HarborError } from '../shared/errors.js';
|
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) => {
|
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 = [];
|
const chunks = [];
|
||||||
res.on('data', (chunk) => chunks.push(chunk));
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
@@ -21,7 +30,7 @@ function request(socketPath, pathname, method = 'GET') {
|
|||||||
});
|
});
|
||||||
req.on('error', reject);
|
req.on('error', reject);
|
||||||
req.setTimeout(6000, () => req.destroy(new Error('Dataplane не ответил за 6 секунд')));
|
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'),
|
refresh: () => update('/status', 'GET'),
|
||||||
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||||
observeTraffic: () => send(socketPath, '/device-traffic', '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'),
|
apply: () => update('/apply', 'POST'),
|
||||||
restart: () => update('/restart', 'POST'),
|
restart: () => update('/restart', 'POST'),
|
||||||
stop: () => update('/stop', 'POST'),
|
stop: () => update('/stop', 'POST'),
|
||||||
|
|||||||
+32
-1
@@ -39,6 +39,7 @@ import {
|
|||||||
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
import { HarborError, normalizeHarborError } from '../shared/errors.js';
|
||||||
import { normalizeRouteRules } from '../shared/routingRules.js';
|
import { normalizeRouteRules } from '../shared/routingRules.js';
|
||||||
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
import { createJsonStore, createStateStore } from './services/stateStore.js';
|
||||||
|
import { createDevicePolicyService } from './services/devicePolicyService.js';
|
||||||
import {
|
import {
|
||||||
createDeviceInventoryService,
|
createDeviceInventoryService,
|
||||||
createVendorLookup,
|
createVendorLookup,
|
||||||
@@ -108,6 +109,13 @@ const singboxRuntime = remoteDataplane
|
|||||||
gateway: settings.appMode === 'gateway',
|
gateway: settings.appMode === 'gateway',
|
||||||
tproxyChain: settings.tproxyChain,
|
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'
|
const deviceInventory = settings.appMode === 'gateway'
|
||||||
? createDeviceInventoryService({
|
? createDeviceInventoryService({
|
||||||
store: deviceStore,
|
store: deviceStore,
|
||||||
@@ -117,6 +125,12 @@ const deviceInventory = settings.appMode === 'gateway'
|
|||||||
observeTraffic: remoteDataplane
|
observeTraffic: remoteDataplane
|
||||||
? () => singboxRuntime.observeTraffic()
|
? () => singboxRuntime.observeTraffic()
|
||||||
: null,
|
: null,
|
||||||
|
observePolicy: remoteDataplane
|
||||||
|
? () => singboxRuntime.observeDevicePolicy()
|
||||||
|
: () => localDevicePolicy.snapshot(),
|
||||||
|
applyPolicies: remoteDataplane
|
||||||
|
? (devices) => singboxRuntime.applyDevicePolicies(devices)
|
||||||
|
: (devices) => localDevicePolicy.apply(devices),
|
||||||
vendor: createVendorLookup(),
|
vendor: createVendorLookup(),
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -644,7 +658,19 @@ async function handleApi(req, res) {
|
|||||||
if (deviceMatch && req.method === 'PUT') {
|
if (deviceMatch && req.method === 'PUT') {
|
||||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
const body = await readBody(req);
|
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') {
|
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
||||||
@@ -847,6 +873,11 @@ await startSingbox()
|
|||||||
})
|
})
|
||||||
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
.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', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,19 +2,46 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { HarborError } from '../../shared/errors.js';
|
import { HarborError } from '../../shared/errors.js';
|
||||||
|
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||||
|
|
||||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
||||||
const ONLINE_MS = 2 * 60 * 1000;
|
const ONLINE_MS = 2 * 60 * 1000;
|
||||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const COUNTER_PATTERN = /^\d+$/;
|
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 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 = {
|
const DEFAULT_STATE = {
|
||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
lastObservedAt: null,
|
lastObservedAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
policy: DEFAULT_POLICY_STATE,
|
||||||
traffic: {
|
traffic: {
|
||||||
epoch: null,
|
epoch: null,
|
||||||
generation: null,
|
generation: null,
|
||||||
@@ -38,6 +65,49 @@ const parseStoredCounter = (value) => {
|
|||||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
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) {
|
export function parseOuiVendors(text) {
|
||||||
const vendors = new Map();
|
const vendors = new Map();
|
||||||
for (const line of String(text || '').split(/\r?\n/)) {
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
@@ -122,6 +192,7 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
...state,
|
...state,
|
||||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||||
|
policy: normalizePolicyState(state.policy),
|
||||||
traffic: {
|
traffic: {
|
||||||
...DEFAULT_STATE.traffic,
|
...DEFAULT_STATE.traffic,
|
||||||
...traffic,
|
...traffic,
|
||||||
@@ -147,10 +218,57 @@ export function createDeviceInventoryService({
|
|||||||
store,
|
store,
|
||||||
observe,
|
observe,
|
||||||
observeTraffic = null,
|
observeTraffic = null,
|
||||||
|
observePolicy = null,
|
||||||
|
applyPolicies = null,
|
||||||
vendor = () => null,
|
vendor = () => null,
|
||||||
now = () => new Date(),
|
now = () => new Date(),
|
||||||
}) {
|
}) {
|
||||||
let refreshPromise = null;
|
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() {
|
function snapshot() {
|
||||||
const state = migrateDeviceInventoryState(store.read());
|
const state = migrateDeviceInventoryState(store.read());
|
||||||
@@ -158,12 +276,18 @@ export function createDeviceInventoryService({
|
|||||||
const rank = { online: 0, recent: 1, offline: 2 };
|
const rank = { online: 0, recent: 1, offline: 2 };
|
||||||
const devices = state.devices.map((device) => {
|
const devices = state.devices.map((device) => {
|
||||||
const traffic = state.traffic.totalsByMac[device.mac];
|
const traffic = state.traffic.totalsByMac[device.mac];
|
||||||
|
const policy = policyFor(state, device.mac);
|
||||||
return {
|
return {
|
||||||
...device,
|
...device,
|
||||||
status: deviceStatus(device.lastSeenAt, current),
|
status: deviceStatus(device.lastSeenAt, current),
|
||||||
uploadBytes: traffic?.uploadBytes || '0',
|
uploadBytes: traffic?.uploadBytes || '0',
|
||||||
downloadBytes: traffic?.downloadBytes || '0',
|
downloadBytes: traffic?.downloadBytes || '0',
|
||||||
trafficObservedAt: traffic?.observedAt || null,
|
trafficObservedAt: traffic?.observedAt || null,
|
||||||
|
desiredPolicy: policy.desired,
|
||||||
|
appliedPolicy: policy.applied,
|
||||||
|
policyStatus: policy.status,
|
||||||
|
policyAppliedAt: policy.appliedAt,
|
||||||
|
policyError: policy.error,
|
||||||
};
|
};
|
||||||
}).sort((left, right) => (
|
}).sort((left, right) => (
|
||||||
Number(right.pinned) - Number(left.pinned)
|
Number(right.pinned) - Number(left.pinned)
|
||||||
@@ -180,13 +304,127 @@ export function createDeviceInventoryService({
|
|||||||
lastObservedAt: state.traffic.lastObservedAt,
|
lastObservedAt: state.traffic.lastObservedAt,
|
||||||
error: state.traffic.lastError,
|
error: state.traffic.lastError,
|
||||||
},
|
},
|
||||||
|
policy: {
|
||||||
|
lastAppliedAt: state.policy.lastAppliedAt,
|
||||||
|
error: state.policy.lastError,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
devices,
|
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() {
|
async function performRefresh() {
|
||||||
const [result, trafficResult] = await Promise.all([
|
const [result, trafficResult, policyResult] = await Promise.all([
|
||||||
Promise.resolve().then(() => observe()).catch((error) => ({
|
Promise.resolve().then(() => observe()).catch((error) => ({
|
||||||
observedAt: now().toISOString(),
|
observedAt: now().toISOString(),
|
||||||
observations: [],
|
observations: [],
|
||||||
@@ -196,49 +434,54 @@ export function createDeviceInventoryService({
|
|||||||
? Promise.resolve().then(() => observeTraffic())
|
? Promise.resolve().then(() => observeTraffic())
|
||||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||||
: null,
|
: null,
|
||||||
|
observePolicy
|
||||||
|
? Promise.resolve().then(() => observePolicy())
|
||||||
|
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||||
|
: null,
|
||||||
]);
|
]);
|
||||||
const observedAt = result?.observedAt || now().toISOString();
|
const observedAt = result?.observedAt || now().toISOString();
|
||||||
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
||||||
const ipsByMac = new Map();
|
const identitiesByMac = new Map();
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
const mac = normalizeMac(observation.mac);
|
const mac = normalizeMac(observation.mac);
|
||||||
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
||||||
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
|
||||||
ipsByMac.get(mac).add(String(observation.ip));
|
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
||||||
}
|
}
|
||||||
store.update((stored) => {
|
return serializePolicy(async () => {
|
||||||
const state = migrateDeviceInventoryState(stored);
|
store.update((stored) => {
|
||||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const state = migrateDeviceInventoryState(stored);
|
||||||
for (const observation of observations) {
|
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
const mac = normalizeMac(observation.mac);
|
for (const observation of observations) {
|
||||||
if (!mac) continue;
|
const mac = normalizeMac(observation.mac);
|
||||||
const previous = byMac.get(mac);
|
if (!mac) continue;
|
||||||
const lastSeenAt = observation.active || !previous
|
const previous = byMac.get(mac);
|
||||||
? observation.observedAt || observedAt
|
const lastSeenAt = observation.active || !previous
|
||||||
: previous.lastSeenAt;
|
? observation.observedAt || observedAt
|
||||||
byMac.set(mac, {
|
: previous.lastSeenAt;
|
||||||
id: previous?.id || deviceId(mac),
|
byMac.set(mac, {
|
||||||
alias: previous?.alias || '',
|
id: previous?.id || deviceId(mac),
|
||||||
pinned: previous?.pinned === true,
|
alias: previous?.alias || '',
|
||||||
hostname: previous?.hostname || null,
|
pinned: previous?.pinned === true,
|
||||||
manufacturer: previous?.manufacturer || vendor(mac),
|
hostname: previous?.hostname || null,
|
||||||
mac,
|
manufacturer: previous?.manufacturer || vendor(mac),
|
||||||
ip: String(observation.ip || previous?.ip || ''),
|
mac,
|
||||||
interface: String(observation.interface || previous?.interface || ''),
|
ip: String(observation.ip || previous?.ip || ''),
|
||||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
interface: String(observation.interface || previous?.interface || ''),
|
||||||
lastSeenAt,
|
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
||||||
source: 'neighbor',
|
lastSeenAt,
|
||||||
confidence: ipsByMac.get(mac)?.size > 1
|
source: 'neighbor',
|
||||||
? 'ambiguous'
|
confidence: identitiesByMac.get(mac)?.size > 1
|
||||||
: isPrivateMac(mac) ? 'medium' : 'high',
|
? 'ambiguous'
|
||||||
});
|
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||||
}
|
});
|
||||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
}
|
||||||
const devices = [...byMac.values()].filter((device) => (
|
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
const devices = [...byMac.values()].filter((device) => (
|
||||||
));
|
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||||
let traffic = state.traffic;
|
));
|
||||||
if (trafficResult) {
|
let traffic = state.traffic;
|
||||||
|
if (trafficResult) {
|
||||||
if (trafficResult.transportError) {
|
if (trafficResult.transportError) {
|
||||||
traffic = { ...traffic, lastError: trafficResult.transportError };
|
traffic = { ...traffic, lastError: trafficResult.transportError };
|
||||||
} else {
|
} else {
|
||||||
@@ -318,17 +561,19 @@ export function createDeviceInventoryService({
|
|||||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
revision: state.revision + 1,
|
revision: state.revision + 1,
|
||||||
lastObservedAt: observedAt,
|
lastObservedAt: observedAt,
|
||||||
lastError: result?.error || null,
|
lastError: result?.error || null,
|
||||||
traffic,
|
traffic,
|
||||||
devices,
|
devices,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
||||||
|
return reconcileLocked(policyResult, false);
|
||||||
});
|
});
|
||||||
return snapshot();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -357,6 +602,11 @@ export function createDeviceInventoryService({
|
|||||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||||
const index = state.devices.findIndex((device) => device.id === id);
|
const index = state.devices.findIndex((device) => device.id === id);
|
||||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
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];
|
const devices = [...state.devices];
|
||||||
devices[index] = {
|
devices[index] = {
|
||||||
...devices[index],
|
...devices[index],
|
||||||
@@ -368,5 +618,46 @@ export function createDeviceInventoryService({
|
|||||||
return snapshot();
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ export const ERROR_DEFINITIONS = Object.freeze({
|
|||||||
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
|
||||||
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
|
||||||
DEVICE_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 },
|
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
|
||||||
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
PROCESS_START_FAILED: { status: 503, message: 'Не удалось запустить VPN-процесс.', retryable: true },
|
||||||
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
OPERATION_IN_PROGRESS: { status: 409, message: 'Другая операция ещё выполняется.', retryable: true },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.11.0',
|
macClient: '0.12.0',
|
||||||
gatewayClient: '0.11.0',
|
gatewayClient: '0.12.0',
|
||||||
gatewayBackend: '0.11.0',
|
gatewayBackend: '0.12.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||||
}),
|
}),
|
||||||
|
setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ mode, expectedRevision }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
|||||||
@@ -130,6 +130,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
setEditingId('');
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -197,6 +228,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{snapshot?.source?.policy?.error && (
|
||||||
|
<p className="client-devices-source" role="status">
|
||||||
|
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="client-devices-error" role="alert">
|
<div className="client-devices-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{error.message}</span>
|
||||||
@@ -217,6 +253,29 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const uncertainIdentity = device.confidence !== 'high';
|
const uncertainIdentity = device.confidence !== 'high';
|
||||||
const download = formatByteString(device.downloadBytes);
|
const download = formatByteString(device.downloadBytes);
|
||||||
const upload = formatByteString(device.uploadBytes);
|
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
|
return <article
|
||||||
ref={(node) => {
|
ref={(node) => {
|
||||||
if (node) deviceNodes.current.set(device.id, node);
|
if (node) deviceNodes.current.set(device.id, node);
|
||||||
@@ -260,53 +319,66 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||||
<button
|
<button
|
||||||
className="client-device-pin"
|
className="client-device-pin"
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={device.pinned}
|
aria-pressed={device.pinned}
|
||||||
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
||||||
disabled={saving}
|
disabled={saving || device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'}
|
||||||
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
<Tooltip>{device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
|
||||||
|
? 'Сначала верните маршрут через Gateway'
|
||||||
|
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="client-device-meta">
|
<div className="client-device-meta">
|
||||||
<div className="client-device-addresses">
|
<div className="client-device-addresses">
|
||||||
{title !== device.ip && device.ip && <span>{device.ip}</span>}
|
{title !== device.ip && device.ip && <span>{device.ip}</span>}
|
||||||
{device.mac && <span className="client-device-mac">
|
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
|
||||||
{device.mac}
|
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
|
||||||
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
|
|
||||||
ⓘ
|
ⓘ
|
||||||
<Tooltip>{device.confidence === 'medium'
|
<Tooltip>{device.confidence === 'medium'
|
||||||
? 'Устройство использует приватный MAC, производитель может не определиться'
|
? 'Устройство использует приватный MAC, производитель может не определиться'
|
||||||
: device.confidence === 'ambiguous'
|
: device.confidence === 'ambiguous'
|
||||||
? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными'
|
? 'Gateway видит это устройство с несколькими IP или интерфейсами, поэтому индивидуальное правило небезопасно'
|
||||||
: 'Устройство определено приблизительно'}</Tooltip>
|
: 'Устройство определено приблизительно'}</Tooltip>
|
||||||
</span>}
|
</span>}
|
||||||
</span>}
|
|
||||||
</div>
|
</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">
|
<span className="client-device-last-seen" tabIndex="0">
|
||||||
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
|
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
|
||||||
<TextMorph from={seen.label} to={seen.relative} />
|
<TextMorph from={seen.label} to={seen.relative} />
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+68
-31
@@ -878,17 +878,17 @@ p {
|
|||||||
|
|
||||||
.client-device {
|
.client-device {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 7px;
|
gap: 3px;
|
||||||
padding: 14px 8px;
|
padding: 10px 8px;
|
||||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-heading {
|
.client-device-heading {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 68px minmax(0, 1fr) 32px;
|
grid-template-columns: 60px minmax(0, 1fr) auto 32px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
min-height: 32px;
|
min-height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-title {
|
.client-device-title {
|
||||||
@@ -910,7 +910,7 @@ p {
|
|||||||
.client-device-status {
|
.client-device-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 6px;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -992,11 +992,11 @@ p {
|
|||||||
|
|
||||||
.client-device-meta {
|
.client-device-meta {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: minmax(0, 1fr) 72px auto;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: 10px 16px;
|
gap: 8px;
|
||||||
margin-left: 76px;
|
margin-left: 66px;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -1005,7 +1005,7 @@ p {
|
|||||||
.client-device-addresses {
|
.client-device-addresses {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
overflow: hidden;
|
||||||
gap: 3px 0;
|
gap: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,11 +1019,6 @@ p {
|
|||||||
content: '·';
|
content: '·';
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-mac {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-identity {
|
.client-device-identity {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
@@ -1032,8 +1027,6 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-last-seen {
|
.client-device-last-seen {
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1100,17 +1093,6 @@ p {
|
|||||||
outline-offset: 3px;
|
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 {
|
.client-device-manufacturer {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1120,12 +1102,66 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic {
|
.client-device-traffic {
|
||||||
margin-left: auto;
|
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
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 {
|
.client-device-alias {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||||
@@ -4097,6 +4133,7 @@ p {
|
|||||||
.client-device,
|
.client-device,
|
||||||
.client-device-pin,
|
.client-device-pin,
|
||||||
.client-device-pin svg,
|
.client-device-pin svg,
|
||||||
|
.client-device-policy,
|
||||||
.client-device-edit,
|
.client-device-edit,
|
||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { createDataplaneClient } from '../../src/server/dataplaneClient.js';
|
|||||||
|
|
||||||
test('control uses the dataplane socket protocol', async () => {
|
test('control uses the dataplane socket protocol', async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const send = async (socketPath, pathname, method) => {
|
const send = async (socketPath, pathname, method, body) => {
|
||||||
requests.push(`${method} ${pathname} ${socketPath}`);
|
requests.push({ method, pathname, socketPath, body });
|
||||||
return {
|
return {
|
||||||
running: pathname !== '/stop',
|
running: pathname !== '/stop',
|
||||||
startedAt: 'now',
|
startedAt: 'now',
|
||||||
@@ -24,15 +24,20 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
assert.equal(devices.running, true);
|
assert.equal(devices.running, true);
|
||||||
const traffic = await client.observeTraffic();
|
const traffic = await client.observeTraffic();
|
||||||
assert.equal(traffic.running, true);
|
assert.equal(traffic.running, true);
|
||||||
|
await client.observeDevicePolicy();
|
||||||
|
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
||||||
assert.equal(client.running, true);
|
assert.equal(client.running, true);
|
||||||
await client.restart();
|
await client.restart();
|
||||||
assert.equal((await client.stop()).running, false);
|
assert.equal((await client.stop()).running, false);
|
||||||
assert.deepEqual(requests, [
|
assert.deepEqual(requests.map(({ method, pathname, socketPath }) => `${method} ${pathname} ${socketPath}`), [
|
||||||
'GET /status /run/dataplane.sock',
|
'GET /status /run/dataplane.sock',
|
||||||
'POST /apply /run/dataplane.sock',
|
'POST /apply /run/dataplane.sock',
|
||||||
'GET /devices /run/dataplane.sock',
|
'GET /devices /run/dataplane.sock',
|
||||||
'GET /device-traffic /run/dataplane.sock',
|
'GET /device-traffic /run/dataplane.sock',
|
||||||
|
'GET /device-policy /run/dataplane.sock',
|
||||||
|
'PUT /device-policy /run/dataplane.sock',
|
||||||
'POST /restart /run/dataplane.sock',
|
'POST /restart /run/dataplane.sock',
|
||||||
'POST /stop /run/dataplane.sock',
|
'POST /stop /run/dataplane.sock',
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(requests[5].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
|||||||
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
|
assert.match(deploy, /up -d --no-deps --wait[^\n]+vpn-proxy-control/);
|
||||||
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
|
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
|
||||||
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singboxRuntime\|version\)/);
|
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singboxRuntime\|version\)/);
|
||||||
assert.match(workflow, /src\/server\/\(adapters\/neighbors\|services\/deviceTrafficService\)/);
|
assert.match(workflow, /src\/server\/\(adapters\/neighbors\|services\/\(deviceTrafficService\|devicePolicyService\)\)/);
|
||||||
assert.match(workflow, /src\/shared\/errors/);
|
assert.match(workflow, /src\/shared\/errors/);
|
||||||
assert.doesNotMatch(workflow, /dataplaneClient/);
|
assert.doesNotMatch(workflow, /dataplaneClient/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||||
migrateDeviceInventoryState,
|
migrateDeviceInventoryState,
|
||||||
} from '../../src/server/services/deviceInventoryService.js';
|
} from '../../src/server/services/deviceInventoryService.js';
|
||||||
|
import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.js';
|
||||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||||
|
|
||||||
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
||||||
@@ -196,6 +197,143 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
|||||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('pinned device policy persists, reconciles the full set, and keeps the last applied mode on failure', async (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath: path.join(directory, 'devices.json'),
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
initializeMissing: true,
|
||||||
|
backupWhen: () => true,
|
||||||
|
});
|
||||||
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
const mac = '00:11:22:33:44:55';
|
||||||
|
let observations = [{
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac,
|
||||||
|
interface: 'eth0',
|
||||||
|
observedAt,
|
||||||
|
active: true,
|
||||||
|
}];
|
||||||
|
let activeDevices = [];
|
||||||
|
let generation = 0;
|
||||||
|
let policyEpoch = 'policy-epoch-a';
|
||||||
|
let failApply = false;
|
||||||
|
let malformedAck = false;
|
||||||
|
const appliedSets = [];
|
||||||
|
const policySnapshot = () => ({
|
||||||
|
epoch: policyEpoch,
|
||||||
|
generation: `policy-rules-${generation}`,
|
||||||
|
fingerprint: fingerprintDirectDevices(activeDevices),
|
||||||
|
observedAt,
|
||||||
|
appliedIds: activeDevices.map(({ id }) => id),
|
||||||
|
});
|
||||||
|
const createService = () => createDeviceInventoryService({
|
||||||
|
store,
|
||||||
|
observe: () => ({ observedAt, observations, error: null }),
|
||||||
|
observePolicy: policySnapshot,
|
||||||
|
applyPolicies: async (devices) => {
|
||||||
|
appliedSets.push(structuredClone(devices));
|
||||||
|
if (failApply) throw new Error('iptables unavailable');
|
||||||
|
if (malformedAck) return { ...policySnapshot(), fingerprint: 'invalid' };
|
||||||
|
activeDevices = structuredClone(devices);
|
||||||
|
generation += 1;
|
||||||
|
return policySnapshot();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let service = createService();
|
||||||
|
let snapshot = await service.refresh();
|
||||||
|
const id = snapshot.devices[0].id;
|
||||||
|
assert.equal(snapshot.devices[0].desiredPolicy, 'vpn');
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||||
|
|
||||||
|
snapshot = service.update(id, { pinned: true }, snapshot.revision);
|
||||||
|
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
||||||
|
assert.deepEqual(appliedSets.at(-1), [{ id, ip: '192.168.50.7', mac, interface: 'eth0' }]);
|
||||||
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||||
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||||
|
assert.equal(store.read().schemaVersion, 2);
|
||||||
|
assert.equal(store.read().policy.schemaVersion, 1);
|
||||||
|
|
||||||
|
service = createService();
|
||||||
|
snapshot = await service.reconcilePolicies();
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||||
|
assert.throws(
|
||||||
|
() => service.update(id, { pinned: false }, snapshot.revision),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
|
||||||
|
policyEpoch = 'policy-epoch-b';
|
||||||
|
activeDevices = [];
|
||||||
|
failApply = true;
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||||
|
assert.equal(snapshot.devices[0].policyStatus, 'failed');
|
||||||
|
failApply = false;
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||||
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||||
|
|
||||||
|
observations = [
|
||||||
|
{ ...observations[0], interface: 'eth0' },
|
||||||
|
{ ...observations[0], interface: 'eth1' },
|
||||||
|
];
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.deepEqual(appliedSets.at(-1), []);
|
||||||
|
assert.equal(snapshot.devices[0].confidence, 'ambiguous');
|
||||||
|
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||||
|
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||||
|
assert.equal(snapshot.devices[0].policyStatus, 'pending');
|
||||||
|
|
||||||
|
snapshot = await service.setPolicy(id, 'vpn', snapshot.revision);
|
||||||
|
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||||
|
observations = [{ ...observations[0], interface: 'eth0' }];
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
||||||
|
const secondMac = '00:11:22:33:44:66';
|
||||||
|
observations.push({
|
||||||
|
ip: '192.168.50.8',
|
||||||
|
mac: secondMac,
|
||||||
|
interface: 'eth0',
|
||||||
|
observedAt,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
const secondId = snapshot.devices.find((device) => device.mac === secondMac).id;
|
||||||
|
snapshot = service.update(secondId, { pinned: true }, snapshot.revision);
|
||||||
|
failApply = true;
|
||||||
|
await assert.rejects(
|
||||||
|
service.setPolicy(secondId, 'direct', snapshot.revision),
|
||||||
|
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
||||||
|
);
|
||||||
|
snapshot = service.snapshot();
|
||||||
|
const byId = new Map(snapshot.devices.map((device) => [device.id, device]));
|
||||||
|
assert.equal(byId.get(id).desiredPolicy, 'direct');
|
||||||
|
assert.equal(byId.get(id).appliedPolicy, 'direct');
|
||||||
|
assert.equal(byId.get(id).policyStatus, 'applied');
|
||||||
|
assert.equal(byId.get(secondId).desiredPolicy, 'direct');
|
||||||
|
assert.equal(byId.get(secondId).appliedPolicy, 'vpn');
|
||||||
|
assert.equal(byId.get(secondId).policyStatus, 'failed');
|
||||||
|
assert.equal(snapshot.source.policy.error, 'iptables unavailable');
|
||||||
|
failApply = false;
|
||||||
|
malformedAck = true;
|
||||||
|
await assert.rejects(
|
||||||
|
service.setPolicy(secondId, 'direct', snapshot.revision),
|
||||||
|
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
||||||
|
);
|
||||||
|
snapshot = service.snapshot();
|
||||||
|
assert.equal(snapshot.devices.find((device) => device.id === secondId).appliedPolicy, 'vpn');
|
||||||
|
assert.match(snapshot.source.policy.error, /acknowledgement/);
|
||||||
|
assert.throws(
|
||||||
|
() => service.setPolicy(secondId, 'block', snapshot.revision),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('device inventory v1 migration creates a versioned backup', (t) => {
|
test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
@@ -210,11 +348,47 @@ test('device inventory v1 migration creates a versioned backup', (t) => {
|
|||||||
|
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||||
|
assert.equal(migrated.policy.schemaVersion, 1);
|
||||||
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
||||||
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
||||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('device inventory backs up and normalizes a malformed additive policy checkpoint', (t) => {
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-migration-'));
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const filePath = path.join(directory, 'devices.json');
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
revision: 3,
|
||||||
|
devices: [],
|
||||||
|
traffic: { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [] },
|
||||||
|
policy: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
defaultMode: 'vpn',
|
||||||
|
dataplaneEpoch: 42,
|
||||||
|
byMac: {
|
||||||
|
broken: { desired: 'direct', applied: 'vpn', status: 'failed' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const store = createJsonStore({
|
||||||
|
filePath,
|
||||||
|
defaultValue: {},
|
||||||
|
migrate: migrateDeviceInventoryState,
|
||||||
|
backupWhen: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const migrated = store.read();
|
||||||
|
assert.equal(migrated.schemaVersion, 2);
|
||||||
|
assert.equal(migrated.policy.schemaVersion, 1);
|
||||||
|
assert.equal(migrated.policy.dataplaneEpoch, null);
|
||||||
|
assert.deepEqual(migrated.policy.byMac, {});
|
||||||
|
assert.match(migrated.policy.lastError, /device policy checkpoint/);
|
||||||
|
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
|
||||||
|
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||||
|
});
|
||||||
|
|
||||||
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
|
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildDevicePolicyRestore,
|
||||||
|
createDevicePolicyService,
|
||||||
|
fingerprintDirectDevices,
|
||||||
|
normalizeDirectDevices,
|
||||||
|
} from '../../src/server/services/devicePolicyService.js';
|
||||||
|
|
||||||
|
const directDevice = {
|
||||||
|
id: 'dev_0011223344556677',
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac: '00:11:22:33:44:55',
|
||||||
|
interface: 'eth0',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('device policy rules match the full identity before the TPROXY fallback', () => {
|
||||||
|
assert.deepEqual(normalizeDirectDevices([{ ...directDevice, mac: directDevice.mac.toUpperCase() }]), [directDevice]);
|
||||||
|
assert.throws(() => normalizeDirectDevices([directDevice, directDevice]), /повторяющаяся/);
|
||||||
|
assert.throws(() => normalizeDirectDevices([{ ...directDevice, interface: 'br-user' }]), /identity/);
|
||||||
|
|
||||||
|
const restore = buildDevicePolicyRestore({
|
||||||
|
devices: [directDevice],
|
||||||
|
chain: 'VPN_PROXY_DEVICE_POLICY',
|
||||||
|
slot: 'B',
|
||||||
|
tproxyPort: 7895,
|
||||||
|
tproxyMark: '1',
|
||||||
|
});
|
||||||
|
assert.equal(restore, [
|
||||||
|
'*mangle',
|
||||||
|
'-F VPN_PROXY_DEVICE_POLICY_B',
|
||||||
|
'-A VPN_PROXY_DEVICE_POLICY_B -i eth0 -s 192.168.50.7 -m mac --mac-source 00:11:22:33:44:55 -m comment --comment harbor-policy:dev_0011223344556677:direct -j RETURN',
|
||||||
|
'-A VPN_PROXY_DEVICE_POLICY_B -p tcp -j TPROXY --on-port 7895 --tproxy-mark 1/1',
|
||||||
|
'-A VPN_PROXY_DEVICE_POLICY_B -p udp -j TPROXY --on-port 7895 --tproxy-mark 1/1',
|
||||||
|
'COMMIT',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
assert.match(fingerprintDirectDevices([directDevice]), /^[a-f0-9]{64}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('device policy swaps one prepared slot and preserves the active slot on failure', async () => {
|
||||||
|
const commands = [];
|
||||||
|
let failSwap = false;
|
||||||
|
const generations = ['epoch-a', 'rules-b', 'rules-a'];
|
||||||
|
const service = createDevicePolicyService({
|
||||||
|
chain: 'VPN_PROXY_DEVICE_POLICY',
|
||||||
|
tproxyPort: 7895,
|
||||||
|
tproxyMark: '1',
|
||||||
|
nextGeneration: () => generations.shift(),
|
||||||
|
now: () => new Date('2026-08-07T12:00:00.000Z'),
|
||||||
|
run: (command, args, options) => {
|
||||||
|
commands.push({ command, args, options });
|
||||||
|
if (failSwap && args.includes('-R')) return { status: 1, stderr: 'swap failed' };
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = service.snapshot();
|
||||||
|
assert.equal((await service.apply([])).generation, initial.generation);
|
||||||
|
assert.equal(commands.length, 0);
|
||||||
|
|
||||||
|
const applied = await service.apply([directDevice]);
|
||||||
|
assert.deepEqual(applied.appliedIds, [directDevice.id]);
|
||||||
|
assert.equal(applied.changed, true);
|
||||||
|
assert.deepEqual(commands.at(-1).args, [
|
||||||
|
'-w', '1', '-t', 'mangle', '-R', 'VPN_PROXY_DEVICE_POLICY', '1', '-j', 'VPN_PROXY_DEVICE_POLICY_B',
|
||||||
|
]);
|
||||||
|
assert.equal(commands[0].command, 'iptables-restore');
|
||||||
|
assert.match(commands[0].options.input, /--mac-source 00:11:22:33:44:55/);
|
||||||
|
assert.equal(commands.length, 2);
|
||||||
|
assert.equal(commands.every(({ options }) => options.timeout === 2_000), true);
|
||||||
|
|
||||||
|
const commandCount = commands.length;
|
||||||
|
assert.equal((await service.apply([directDevice])).changed, false);
|
||||||
|
assert.equal(commands.length, commandCount);
|
||||||
|
|
||||||
|
failSwap = true;
|
||||||
|
await assert.rejects(service.apply([]), /swap failed/);
|
||||||
|
assert.deepEqual(service.snapshot().appliedIds, [directDevice.id]);
|
||||||
|
assert.equal(service.snapshot().generation, applied.generation);
|
||||||
|
|
||||||
|
const prepareCommands = [];
|
||||||
|
const prepareFailure = createDevicePolicyService({
|
||||||
|
chain: 'VPN_PROXY_DEVICE_POLICY',
|
||||||
|
tproxyPort: 7895,
|
||||||
|
tproxyMark: '1',
|
||||||
|
nextGeneration: () => 'prepare-epoch',
|
||||||
|
run: (command, args, options) => {
|
||||||
|
prepareCommands.push({ command, args, options });
|
||||||
|
return command === 'iptables-restore'
|
||||||
|
? { status: 1, stderr: 'prepare failed' }
|
||||||
|
: { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(prepareFailure.apply([directDevice]), /prepare failed/);
|
||||||
|
assert.equal(prepareCommands.some(({ args }) => args.includes('-R')), false);
|
||||||
|
assert.deepEqual(prepareFailure.snapshot().appliedIds, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the maximum policy set stays inside the control request deadline', async () => {
|
||||||
|
const devices = Array.from({ length: 512 }, (_, index) => {
|
||||||
|
const mac = index.toString(16).padStart(12, '0').match(/../g).join(':');
|
||||||
|
return {
|
||||||
|
id: `dev_${index.toString(16).padStart(16, '0')}`,
|
||||||
|
ip: `192.168.${50 + Math.floor(index / 254)}.${(index % 254) + 1}`,
|
||||||
|
mac,
|
||||||
|
interface: 'eth0',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const calls = [];
|
||||||
|
const service = createDevicePolicyService({
|
||||||
|
chain: 'VPN_PROXY_DEVICE_POLICY',
|
||||||
|
tproxyPort: 7895,
|
||||||
|
tproxyMark: '1',
|
||||||
|
nextGeneration: () => 'generation',
|
||||||
|
run: (command, args, options) => {
|
||||||
|
calls.push({ command, args, options });
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.apply(devices);
|
||||||
|
assert.deepEqual(calls.map(({ command }) => command), ['iptables-restore', 'iptables']);
|
||||||
|
assert.equal(calls.reduce((sum, { options }) => sum + options.timeout, 0), 4_000);
|
||||||
|
});
|
||||||
@@ -18,9 +18,12 @@ test('gateway keeps direct forwarding active while TProxy interception is switch
|
|||||||
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
|
||||||
assert.match(entrypoint, /-t raw -I PREROUTING 1 -j "\$TRAFFIC_UPLOAD_CHAIN"/);
|
assert.match(entrypoint, /-t raw -I PREROUTING 1 -j "\$TRAFFIC_UPLOAD_CHAIN"/);
|
||||||
assert.match(entrypoint, /-t mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/);
|
assert.match(entrypoint, /-t mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/);
|
||||||
|
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -j "\$DEVICE_POLICY_CHAIN"/);
|
||||||
|
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p tcp -j TPROXY/);
|
||||||
|
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p udp -j TPROXY/);
|
||||||
assert.match(entrypoint, /setup_tproxy\s+if ! setup_device_traffic/);
|
assert.match(entrypoint, /setup_tproxy\s+if ! setup_device_traffic/);
|
||||||
assert.match(entrypoint, /if ! setup_device_traffic; then[\s\S]*VPN routing remains active/);
|
assert.match(entrypoint, /if ! setup_device_traffic; then[\s\S]*VPN routing remains active/);
|
||||||
assert.match(entrypoint, /export TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS/);
|
assert.match(entrypoint, /export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS/);
|
||||||
assert.match(entrypoint, /ipt_traffic\(\) \{\s+iptables -w 1/);
|
assert.match(entrypoint, /ipt_traffic\(\) \{\s+iptables -w 1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,27 +25,38 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||||
|
assert.match(api, /setPolicy: \(id, mode, expectedRevision\) => request\(`\/api\/devices\/\$\{id\}\/policy`/);
|
||||||
assert.match(server, /requestUrl\.pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
assert.match(server, /requestUrl\.pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||||
|
assert.match(server, /\/api\\\/devices\\\/\(dev_\[a-f0-9\]\{16\}\)\\\/policy\$[\s\S]*deviceInventory\.setPolicy/);
|
||||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||||
assert.match(panel, /<TextMorph from=\{seen\.label\} to=\{seen\.relative\} \/>/);
|
assert.match(panel, /<TextMorph from=\{seen\.label\} to=\{seen\.relative\} \/>/);
|
||||||
assert.doesNotMatch(panel, /client-text-morph-goo/);
|
assert.doesNotMatch(panel, /client-text-morph-goo/);
|
||||||
assert.match(panel, /client-device-addresses/);
|
assert.match(panel, /client-device-addresses/);
|
||||||
assert.doesNotMatch(panel, /device\.interface/);
|
assert.doesNotMatch(panel, /device\.interface/);
|
||||||
|
assert.doesNotMatch(panel, /device\.mac/);
|
||||||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||||||
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
|
||||||
assert.match(panel, /Трафик временно не обновляется/);
|
assert.match(panel, /Трафик временно не обновляется/);
|
||||||
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
|
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
|
||||||
assert.match(panel, /client-device-traffic/);
|
assert.match(panel, /client-device-traffic-slot[\s\S]*client-device-pin-wrap/);
|
||||||
|
assert.doesNotMatch(panel, /client-device-details/);
|
||||||
|
assert.match(panel, /api\.devices\.setPolicy\(device\.id, mode, snapshot\.revision\)/);
|
||||||
|
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*latestDevice\.desiredPolicy !== device\.desiredPolicy[\s\S]*api\.devices\.setPolicy\(device\.id, mode, latest\.revision\)/);
|
||||||
|
assert.match(panel, /className=\{`client-device-policy is-\$\{displayPolicy\}/);
|
||||||
|
assert.match(panel, /Полностью обходит sing-box/);
|
||||||
assert.match(panel, /client-drawer client-instructions client-devices/);
|
assert.match(panel, /client-drawer client-instructions client-devices/);
|
||||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||||
assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/);
|
assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/);
|
||||||
|
assert.match(styles, /\.client-device \{[\s\S]*gap: 3px;[\s\S]*padding: 10px 8px/);
|
||||||
|
assert.match(styles, /\.client-device-heading \{[\s\S]*grid-template-columns: 60px minmax\(0, 1fr\) auto 32px/);
|
||||||
|
assert.match(styles, /\.client-device-meta \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 72px auto/);
|
||||||
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
|
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
|
||||||
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
|
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
|
||||||
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
||||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy[\s\S]*\.client-text-morph-value/);
|
||||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user