Add device traffic counters and ambiguous MAC detection
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/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)\.js|src/shared/errors\.js)$'; then
|
||||||
UPDATE_DATAPLANE=true
|
UPDATE_DATAPLANE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
|||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
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_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}"
|
||||||
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
|
||||||
PROXY_PORT="${PROXY_PORT:-8080}"
|
PROXY_PORT="${PROXY_PORT:-8080}"
|
||||||
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
|
||||||
@@ -15,6 +17,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
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[gateway-entrypoint] %s\n' "$*"
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
@@ -28,6 +31,10 @@ ipt() {
|
|||||||
iptables -w "$@"
|
iptables -w "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ipt_traffic() {
|
||||||
|
iptables -w 1 "$@"
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_proxy_firewall() {
|
cleanup_proxy_firewall() {
|
||||||
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
ipt -D INPUT -p tcp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
ipt -D INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" 2>/dev/null || true
|
||||||
@@ -52,6 +59,34 @@ cleanup_gateway_forwarding() {
|
|||||||
ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanup_device_traffic() {
|
||||||
|
ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -D POSTROUTING -j "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -F "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_device_traffic() {
|
||||||
|
log "setup raw device traffic counters"
|
||||||
|
cleanup_device_traffic
|
||||||
|
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
for slot in A B; do
|
||||||
|
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1
|
||||||
|
done
|
||||||
|
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1
|
||||||
|
ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
|
||||||
|
}
|
||||||
|
|
||||||
enable_ip_forwarding() {
|
enable_ip_forwarding() {
|
||||||
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||||
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
printf '1' > /proc/sys/net/ipv4/ip_forward || true
|
||||||
@@ -114,6 +149,10 @@ setup_tproxy() {
|
|||||||
|
|
||||||
setup_gateway_forwarding
|
setup_gateway_forwarding
|
||||||
setup_tproxy
|
setup_tproxy
|
||||||
|
if ! setup_device_traffic; then
|
||||||
|
log "device traffic counters unavailable; VPN routing remains active"
|
||||||
|
cleanup_device_traffic
|
||||||
|
fi
|
||||||
setup_proxy_firewall
|
setup_proxy_firewall
|
||||||
|
|
||||||
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
|
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
|
||||||
@@ -127,6 +166,7 @@ shutdown() {
|
|||||||
kill "$APP_PID" 2>/dev/null || true
|
kill "$APP_PID" 2>/dev/null || true
|
||||||
wait "$APP_PID" 2>/dev/null || true
|
wait "$APP_PID" 2>/dev/null || true
|
||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
cleanup_gateway_forwarding
|
cleanup_gateway_forwarding
|
||||||
}
|
}
|
||||||
@@ -135,6 +175,7 @@ trap 'shutdown; exit 0' SIGTERM SIGINT
|
|||||||
wait "$APP_PID"
|
wait "$APP_PID"
|
||||||
STATUS=$?
|
STATUS=$?
|
||||||
cleanup_proxy_firewall
|
cleanup_proxy_firewall
|
||||||
|
cleanup_device_traffic
|
||||||
cleanup_tproxy
|
cleanup_tproxy
|
||||||
cleanup_gateway_forwarding
|
cleanup_gateway_forwarding
|
||||||
exit "$STATUS"
|
exit "$STATUS"
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const settings = {
|
|||||||
proxyPort,
|
proxyPort,
|
||||||
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
|
||||||
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
|
||||||
|
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
|
||||||
|
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
|
||||||
|
bypassCidrs: (process.env.BYPASS_CIDRS
|
||||||
|
|| "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")
|
||||||
|
.trim().split(/\s+/).filter(Boolean),
|
||||||
dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock",
|
dataplaneSocket: process.env.DATAPLANE_SOCKET || "/run/vpn-proxy/dataplane.sock",
|
||||||
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
|
||||||
dataDir,
|
dataDir,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { settings } from './config.js';
|
|||||||
import { createSingboxRuntime } from './singboxRuntime.js';
|
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';
|
||||||
|
|
||||||
const socketPath = settings.dataplaneSocket;
|
const socketPath = settings.dataplaneSocket;
|
||||||
const runtime = createSingboxRuntime({
|
const runtime = createSingboxRuntime({
|
||||||
@@ -13,7 +14,14 @@ const runtime = createSingboxRuntime({
|
|||||||
tproxyChain: settings.tproxyChain,
|
tproxyChain: settings.tproxyChain,
|
||||||
});
|
});
|
||||||
const versionInfo = buildVersionInfo('gateway');
|
const versionInfo = buildVersionInfo('gateway');
|
||||||
|
const traffic = createDeviceTrafficService({
|
||||||
|
observe: () => readNeighborSnapshot(),
|
||||||
|
uploadChain: settings.trafficUploadChain,
|
||||||
|
downloadChain: settings.trafficDownloadChain,
|
||||||
|
bypassCidrs: settings.bypassCidrs,
|
||||||
|
});
|
||||||
let ready = false;
|
let ready = false;
|
||||||
|
let trafficTimer = null;
|
||||||
|
|
||||||
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' });
|
||||||
@@ -33,6 +41,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (req.method === 'GET' && req.url === '/devices') {
|
if (req.method === 'GET' && req.url === '/devices') {
|
||||||
return sendJson(res, 200, readNeighborSnapshot());
|
return sendJson(res, 200, readNeighborSnapshot());
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && req.url === '/device-traffic') {
|
||||||
|
return sendJson(res, 200, traffic.snapshot());
|
||||||
|
}
|
||||||
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());
|
||||||
}
|
}
|
||||||
@@ -58,6 +69,14 @@ server.listen(socketPath, async () => {
|
|||||||
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
|
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
ready = true;
|
ready = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
traffic.refresh()
|
||||||
|
.catch((error) => console.warn(`[dataplane] traffic counters не запущены: ${error.message}`));
|
||||||
|
});
|
||||||
|
trafficTimer = setInterval(() => {
|
||||||
|
traffic.refresh().catch((error) => console.warn(`[dataplane] traffic counters не обновлены: ${error.message}`));
|
||||||
|
}, 15_000);
|
||||||
|
trafficTimer.unref();
|
||||||
console.log(`[dataplane] control socket: ${socketPath}`);
|
console.log(`[dataplane] control socket: ${socketPath}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -67,6 +86,7 @@ async function shutdown() {
|
|||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
ready = false;
|
ready = false;
|
||||||
|
if (trafficTimer) clearInterval(trafficTimer);
|
||||||
await runtime.shutdown();
|
await runtime.shutdown();
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
fs.rmSync(socketPath, { force: true });
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function createDataplaneClient(socketPath, send = request) {
|
|||||||
get startedAt() { return current.startedAt || null; },
|
get startedAt() { return current.startedAt || null; },
|
||||||
refresh: () => update('/status', 'GET'),
|
refresh: () => update('/status', 'GET'),
|
||||||
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
observeDevices: () => send(socketPath, '/devices', 'GET'),
|
||||||
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
apply: () => update('/apply', 'POST'),
|
apply: () => update('/apply', 'POST'),
|
||||||
restart: () => update('/restart', 'POST'),
|
restart: () => update('/restart', 'POST'),
|
||||||
stop: () => update('/stop', 'POST'),
|
stop: () => update('/stop', 'POST'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
import { HarborError } from '../../shared/errors.js';
|
import { HarborError } from '../../shared/errors.js';
|
||||||
|
|
||||||
const SCHEMA_VERSION = 1;
|
const SCHEMA_VERSION = 1;
|
||||||
@@ -100,6 +101,13 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
}
|
}
|
||||||
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();
|
||||||
|
for (const observation of observations) {
|
||||||
|
const mac = normalizeMac(observation.mac);
|
||||||
|
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
||||||
|
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||||
|
ipsByMac.get(mac).add(String(observation.ip));
|
||||||
|
}
|
||||||
store.update((stored) => {
|
store.update((stored) => {
|
||||||
const state = migrate(stored);
|
const state = migrate(stored);
|
||||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||||
@@ -122,7 +130,9 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
||||||
lastSeenAt,
|
lastSeenAt,
|
||||||
source: 'neighbor',
|
source: 'neighbor',
|
||||||
confidence: isPrivateMac(mac) ? 'medium' : 'high',
|
confidence: ipsByMac.get(mac)?.size > 1
|
||||||
|
? 'ambiguous'
|
||||||
|
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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 MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
|
const INTERFACE_PATTERN = /^[a-z0-9_.:+-]{1,15}$/i;
|
||||||
|
|
||||||
|
const childChain = (chain, slot) => `${chain}_${slot}`;
|
||||||
|
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(`${ip}|${mac}|${deviceInterface}`)
|
||||||
|
.digest('hex')
|
||||||
|
.slice(0, 16);
|
||||||
|
|
||||||
|
function commandError(command, result) {
|
||||||
|
return new Error(String(
|
||||||
|
result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`,
|
||||||
|
).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectTrafficDevices(observations) {
|
||||||
|
const candidates = new Map();
|
||||||
|
const ipsByMac = new Map();
|
||||||
|
const locationsByIp = new Map();
|
||||||
|
|
||||||
|
for (const observation of Array.isArray(observations) ? observations : []) {
|
||||||
|
const ip = String(observation?.ip || '');
|
||||||
|
const mac = String(observation?.mac || '').toLowerCase();
|
||||||
|
const deviceInterface = String(observation?.interface || '');
|
||||||
|
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !INTERFACE_PATTERN.test(deviceInterface)
|
||||||
|
|| deviceInterface.startsWith('br-')) continue;
|
||||||
|
|
||||||
|
const location = `${mac}|${deviceInterface}`;
|
||||||
|
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
||||||
|
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||||
|
ipsByMac.get(mac).add(ip);
|
||||||
|
if (!locationsByIp.has(ip)) locationsByIp.set(ip, new Set());
|
||||||
|
locationsByIp.get(ip).add(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates.values()]
|
||||||
|
.filter(({ ip, mac }) => ipsByMac.get(mac).size === 1 && locationsByIp.get(ip).size === 1)
|
||||||
|
.map((device) => ({ ...device, key: counterKey(device) }))
|
||||||
|
.sort((left, right) => (
|
||||||
|
left.ip.localeCompare(right.ip)
|
||||||
|
|| left.mac.localeCompare(right.mac)
|
||||||
|
|| left.interface.localeCompare(right.interface)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrafficRuleCommands({
|
||||||
|
devices,
|
||||||
|
bypassCidrs,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
slot,
|
||||||
|
}) {
|
||||||
|
const uploadChild = childChain(uploadChain, slot);
|
||||||
|
const downloadChild = childChain(downloadChain, slot);
|
||||||
|
const commands = [
|
||||||
|
['iptables', ['-w', '1', '-t', 'raw', '-F', uploadChild]],
|
||||||
|
['iptables', ['-w', '1', '-t', 'mangle', '-F', downloadChild]],
|
||||||
|
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-i', 'br-+', '-j', 'RETURN']],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const cidr of bypassCidrs) {
|
||||||
|
commands.push(
|
||||||
|
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-d', cidr, '-j', 'RETURN']],
|
||||||
|
['iptables', ['-w', '1', '-t', 'mangle', '-A', downloadChild, '-s', cidr, '-j', 'RETURN']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
commands.push(
|
||||||
|
['iptables', [
|
||||||
|
'-w', '1', '-t', 'raw', '-A', uploadChild,
|
||||||
|
'-i', device.interface, '-s', device.ip,
|
||||||
|
'-m', 'mac', '--mac-source', device.mac,
|
||||||
|
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:upload`,
|
||||||
|
'-j', 'RETURN',
|
||||||
|
]],
|
||||||
|
['iptables', [
|
||||||
|
'-w', '1', '-t', 'mangle', '-A', downloadChild,
|
||||||
|
'-o', device.interface, '-d', device.ip,
|
||||||
|
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:download`,
|
||||||
|
'-j', 'RETURN',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrafficCounters(text, chain) {
|
||||||
|
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const linePattern = new RegExp(
|
||||||
|
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download)"?`,
|
||||||
|
);
|
||||||
|
const counters = new Map();
|
||||||
|
for (const line of String(text || '').split(/\r?\n/)) {
|
||||||
|
const match = line.match(linePattern);
|
||||||
|
if (!match) continue;
|
||||||
|
counters.set(`${match[3]}:${match[4]}`, match[2]);
|
||||||
|
}
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceTrafficService({
|
||||||
|
observe,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
bypassCidrs,
|
||||||
|
run = spawnSync,
|
||||||
|
nextGeneration = () => crypto.randomUUID(),
|
||||||
|
}) {
|
||||||
|
let activeSlot = null;
|
||||||
|
let activeDevices = [];
|
||||||
|
let activeSignature = '';
|
||||||
|
let refreshPromise = null;
|
||||||
|
let current = {
|
||||||
|
generation: nextGeneration(),
|
||||||
|
observedAt: null,
|
||||||
|
source: { error: null },
|
||||||
|
devices: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function execute(command, args) {
|
||||||
|
const result = run(command, args, COMMAND_OPTIONS);
|
||||||
|
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||||
|
return String(result.stdout || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepare(slot, devices) {
|
||||||
|
for (const [command, args] of buildTrafficRuleCommands({
|
||||||
|
devices,
|
||||||
|
bypassCidrs,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
slot,
|
||||||
|
})) execute(command, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(slot) {
|
||||||
|
const uploadChild = childChain(uploadChain, slot);
|
||||||
|
const downloadChild = childChain(downloadChain, slot);
|
||||||
|
const replace = activeSlot ? '-R' : '-A';
|
||||||
|
const uploadArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
|
||||||
|
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
|
||||||
|
const downloadArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
|
||||||
|
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
|
||||||
|
|
||||||
|
execute('iptables', uploadArgs);
|
||||||
|
try {
|
||||||
|
execute('iptables', downloadArgs);
|
||||||
|
} catch (error) {
|
||||||
|
const rollbackArgs = activeSlot
|
||||||
|
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
|
||||||
|
: ['-w', '1', '-t', 'raw', '-F', uploadChain];
|
||||||
|
execute('iptables', rollbackArgs);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCounters(devices) {
|
||||||
|
if (!activeSlot) return [];
|
||||||
|
const upload = parseTrafficCounters(
|
||||||
|
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||||
|
childChain(uploadChain, activeSlot),
|
||||||
|
);
|
||||||
|
const download = parseTrafficCounters(
|
||||||
|
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||||
|
childChain(downloadChain, activeSlot),
|
||||||
|
);
|
||||||
|
return devices.map(({ key, ...device }) => ({
|
||||||
|
...device,
|
||||||
|
uploadBytes: upload.get(`${key}:upload`) || '0',
|
||||||
|
downloadBytes: download.get(`${key}:download`) || '0',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performRefresh() {
|
||||||
|
let observed;
|
||||||
|
try {
|
||||||
|
observed = await observe();
|
||||||
|
} catch (error) {
|
||||||
|
observed = { observedAt: new Date().toISOString(), observations: [], error: error.message || String(error) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceError = observed?.error || null;
|
||||||
|
const nextDevices = sourceError
|
||||||
|
? activeDevices
|
||||||
|
: selectTrafficDevices(observed?.observations);
|
||||||
|
const nextSignature = JSON.stringify(nextDevices);
|
||||||
|
|
||||||
|
if (!sourceError && nextSignature !== activeSignature) {
|
||||||
|
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||||
|
try {
|
||||||
|
prepare(nextSlot, nextDevices);
|
||||||
|
switchTo(nextSlot);
|
||||||
|
activeSlot = nextSlot;
|
||||||
|
activeDevices = nextDevices;
|
||||||
|
activeSignature = nextSignature;
|
||||||
|
current.generation = nextGeneration();
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = error.message || String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = current.devices;
|
||||||
|
let countersRead = false;
|
||||||
|
try {
|
||||||
|
devices = readCounters(activeDevices);
|
||||||
|
countersRead = true;
|
||||||
|
} catch (error) {
|
||||||
|
sourceError = sourceError || error.message || String(error);
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
generation: current.generation,
|
||||||
|
observedAt: countersRead ? observed?.observedAt || current.observedAt : current.observedAt,
|
||||||
|
source: { error: sourceError },
|
||||||
|
devices,
|
||||||
|
};
|
||||||
|
return structuredClone(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
refresh,
|
||||||
|
snapshot: () => structuredClone(current),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.9.4',
|
macClient: '0.10.0',
|
||||||
gatewayClient: '0.9.4',
|
gatewayClient: '0.10.0',
|
||||||
gatewayBackend: '0.9.1',
|
gatewayBackend: '0.10.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -248,10 +248,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
ⓘ
|
ⓘ
|
||||||
<Tooltip>{device.confidence === 'medium'
|
<Tooltip>{device.confidence === 'medium'
|
||||||
? 'Устройство использует приватный MAC, производитель может не определиться'
|
? 'Устройство использует приватный MAC, производитель может не определиться'
|
||||||
: 'Устройство определено приблизительно'}</Tooltip>
|
: device.confidence === 'ambiguous'
|
||||||
|
? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными'
|
||||||
|
: 'Устройство определено приблизительно'}</Tooltip>
|
||||||
</span>}
|
</span>}
|
||||||
</span>}
|
</span>}
|
||||||
{device.interface && <span>{device.interface}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<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}>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
await client.apply();
|
await client.apply();
|
||||||
const devices = await client.observeDevices();
|
const devices = await client.observeDevices();
|
||||||
assert.equal(devices.running, true);
|
assert.equal(devices.running, true);
|
||||||
|
const traffic = await client.observeTraffic();
|
||||||
|
assert.equal(traffic.running, true);
|
||||||
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);
|
||||||
@@ -29,6 +31,7 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'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',
|
||||||
'POST /restart /run/dataplane.sock',
|
'POST /restart /run/dataplane.sock',
|
||||||
'POST /stop /run/dataplane.sock',
|
'POST /stop /run/dataplane.sock',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,6 +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\/shared\/errors/);
|
assert.match(workflow, /src\/shared\/errors/);
|
||||||
assert.doesNotMatch(workflow, /dataplaneClient/);
|
assert.doesNotMatch(workflow, /dataplaneClient/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
let observation = {
|
let observation = {
|
||||||
observedAt: current.toISOString(),
|
observedAt: current.toISOString(),
|
||||||
observations: parseNeighborSnapshot([
|
observations: parseNeighborSnapshot([
|
||||||
|
{ dst: '2001:db8::10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
|
||||||
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
|
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
|
||||||
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
|
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
|
||||||
{ dst: '192.168.50.11', dev: 'br0', state: ['INCOMPLETE'] },
|
{ dst: '192.168.50.11', dev: 'br0', state: ['INCOMPLETE'] },
|
||||||
@@ -49,6 +50,19 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
|
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
|
||||||
assert.equal(snapshot.devices[0].status, 'online');
|
assert.equal(snapshot.devices[0].status, 'online');
|
||||||
assert.equal(snapshot.devices[0].confidence, 'high');
|
assert.equal(snapshot.devices[0].confidence, 'high');
|
||||||
|
assert.equal(snapshot.devices[0].interface, 'br0');
|
||||||
|
|
||||||
|
observation = {
|
||||||
|
observedAt: current.toISOString(),
|
||||||
|
observations: parseNeighborSnapshot([
|
||||||
|
{ dst: '192.168.50.10', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['REACHABLE'] },
|
||||||
|
{ dst: '192.168.50.12', dev: 'br0', lladdr: '00:11:22:33:44:55', state: ['STALE'] },
|
||||||
|
], current.toISOString()),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices.length, 1);
|
||||||
|
assert.equal(snapshot.devices[0].confidence, 'ambiguous');
|
||||||
|
|
||||||
current = new Date('2026-07-01T10:05:00.000Z');
|
current = new Date('2026-07-01T10:05:00.000Z');
|
||||||
observation = {
|
observation = {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildTrafficRuleCommands,
|
||||||
|
createDeviceTrafficService,
|
||||||
|
parseTrafficCounters,
|
||||||
|
selectTrafficDevices,
|
||||||
|
} from '../../src/server/services/deviceTrafficService.js';
|
||||||
|
|
||||||
|
const uploadChain = 'VPN_PROXY_TRAFFIC_UP';
|
||||||
|
const downloadChain = 'VPN_PROXY_TRAFFIC_DOWN';
|
||||||
|
const dataplaneSource = fs.readFileSync(
|
||||||
|
path.resolve(import.meta.dirname, '../../src/server/dataplane.js'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') => ({
|
||||||
|
ip,
|
||||||
|
mac,
|
||||||
|
interface: deviceInterface,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataplane exposes cached traffic snapshots without making accounting a readiness dependency', () => {
|
||||||
|
assert.match(dataplaneSource, /req\.method === 'GET' && req\.url === '\/device-traffic'[\s\S]*traffic\.snapshot\(\)/);
|
||||||
|
assert.match(dataplaneSource, /ready = true;[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/);
|
||||||
|
assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
||||||
|
const selected = selectTrafficDevices([
|
||||||
|
observation('192.168.50.7'),
|
||||||
|
observation('192.168.50.7'),
|
||||||
|
observation('192.168.50.8', '00:11:22:33:44:66'),
|
||||||
|
observation('192.168.50.9', '00:11:22:33:44:66'),
|
||||||
|
observation('192.168.50.10', '00:11:22:33:44:77', 'bad interface'),
|
||||||
|
observation('192.168.50.11', '00:11:22:33:44:88', 'br-docker0'),
|
||||||
|
observation('2001:db8::7', '00:11:22:33:44:88'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(selected.length, 1);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ip: selected[0].ip, mac: selected[0].mac, interface: selected[0].interface },
|
||||||
|
observation('192.168.50.7'),
|
||||||
|
);
|
||||||
|
assert.match(selected[0].key, /^[a-f0-9]{16}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic rules mirror public upload and download semantics without routing targets', () => {
|
||||||
|
const [device] = selectTrafficDevices([observation('192.168.50.7')]);
|
||||||
|
const commands = buildTrafficRuleCommands({
|
||||||
|
devices: [device],
|
||||||
|
bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'],
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
slot: 'A',
|
||||||
|
});
|
||||||
|
const args = commands.map(([, commandArgs]) => commandArgs);
|
||||||
|
|
||||||
|
assert.ok(args.some((value) => value.join(' ') === '-w 1 -t raw -A VPN_PROXY_TRAFFIC_UP_A -d 10.0.0.0/8 -j RETURN'));
|
||||||
|
assert.ok(args.some((value) => value.join(' ') === '-w 1 -t mangle -A VPN_PROXY_TRAFFIC_DOWN_A -s 10.0.0.0/8 -j RETURN'));
|
||||||
|
assert.ok(args.some((value) => (
|
||||||
|
value.includes('--mac-source') && value.includes('00:11:22:33:44:55')
|
||||||
|
&& value.includes('-i') && value.includes('eth0') && value.includes('-s')
|
||||||
|
)));
|
||||||
|
assert.ok(args.some((value) => (
|
||||||
|
value.some((part) => part.startsWith('harbor-traffic:')) && value.includes('-o')
|
||||||
|
&& value.includes('eth0') && value.includes('-d') && value.includes('192.168.50.7')
|
||||||
|
)));
|
||||||
|
assert.ok(args.filter((value) => value.includes('-j')).every((value) => value[value.indexOf('-j') + 1] === 'RETURN'));
|
||||||
|
const uploadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':upload')));
|
||||||
|
const downloadDeviceIndex = args.findIndex((value) => value.some((part) => part.endsWith(':download')));
|
||||||
|
assert.ok(args.filter((value) => value.includes('-d') && value.includes('10.0.0.0/8'))
|
||||||
|
.every((value) => args.indexOf(value) < uploadDeviceIndex));
|
||||||
|
assert.ok(args.filter((value) => value.includes('-s') && value.includes('10.0.0.0/8'))
|
||||||
|
.every((value) => args.indexOf(value) < downloadDeviceIndex));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('counter parser preserves exact uint64 byte strings', () => {
|
||||||
|
const counters = parseTrafficCounters(
|
||||||
|
'[7:9007199254740993] -A VPN_PROXY_TRAFFIC_UP_A -s 192.168.50.7/32 -m comment --comment "harbor-traffic:0123456789abcdef:upload" -j RETURN\n',
|
||||||
|
'VPN_PROXY_TRAFFIC_UP_A',
|
||||||
|
);
|
||||||
|
assert.equal(counters.get('0123456789abcdef:upload'), '9007199254740993');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('traffic service preserves active rules and snapshot when replacement fails', async () => {
|
||||||
|
let observed = {
|
||||||
|
observedAt: '2026-08-07T12:00:00.000Z',
|
||||||
|
observations: [observation('192.168.50.7')],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const firstDevice = selectTrafficDevices(observed.observations)[0];
|
||||||
|
const calls = [];
|
||||||
|
let failReplacement = false;
|
||||||
|
let failCounters = false;
|
||||||
|
const generations = ['boot', 'rules-a', 'rules-b'];
|
||||||
|
const run = (command, args, options) => {
|
||||||
|
calls.push([command, args, options]);
|
||||||
|
if (command === 'iptables-save') {
|
||||||
|
if (failCounters) return { status: null, stdout: '', stderr: '', error: new Error('counter read timed out') };
|
||||||
|
const direction = args.includes('raw') ? 'upload' : 'download';
|
||||||
|
const chain = direction === 'upload' ? `${uploadChain}_A` : `${downloadChain}_A`;
|
||||||
|
const bytes = direction === 'upload' ? '1200' : '3400';
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n`,
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (failReplacement && args.includes('-R') && args.includes(downloadChain)) {
|
||||||
|
return { status: 1, stdout: '', stderr: 'cannot switch download rules' };
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: '', stderr: '' };
|
||||||
|
};
|
||||||
|
const service = createDeviceTrafficService({
|
||||||
|
observe: async () => observed,
|
||||||
|
uploadChain,
|
||||||
|
downloadChain,
|
||||||
|
bypassCidrs: ['10.0.0.0/8'],
|
||||||
|
run,
|
||||||
|
nextGeneration: () => generations.shift(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await service.refresh();
|
||||||
|
assert.equal(first.generation, 'rules-a');
|
||||||
|
assert.deepEqual(first.devices, [{
|
||||||
|
ip: '192.168.50.7',
|
||||||
|
mac: '00:11:22:33:44:55',
|
||||||
|
interface: 'eth0',
|
||||||
|
uploadBytes: '1200',
|
||||||
|
downloadBytes: '3400',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length;
|
||||||
|
await service.refresh();
|
||||||
|
const switchCallsAfter = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length;
|
||||||
|
assert.equal(switchCallsAfter, switchCallsBefore);
|
||||||
|
|
||||||
|
observed = {
|
||||||
|
observedAt: '2026-08-07T12:01:00.000Z',
|
||||||
|
observations: [observation('192.168.50.8')],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
failReplacement = true;
|
||||||
|
const failed = await service.refresh();
|
||||||
|
assert.equal(failed.generation, 'rules-a');
|
||||||
|
assert.match(failed.source.error, /cannot switch download rules/);
|
||||||
|
assert.deepEqual(failed.devices, first.devices);
|
||||||
|
const uploadSwitches = calls
|
||||||
|
.filter(([, args]) => args.includes('-R') && args.includes(uploadChain))
|
||||||
|
.map(([, args]) => args.at(-1));
|
||||||
|
assert.deepEqual(uploadSwitches, [`${uploadChain}_B`, `${uploadChain}_A`]);
|
||||||
|
|
||||||
|
failReplacement = false;
|
||||||
|
observed = {
|
||||||
|
observedAt: '2026-08-07T12:02:00.000Z',
|
||||||
|
observations: [],
|
||||||
|
error: 'neighbors unavailable',
|
||||||
|
};
|
||||||
|
const stale = await service.refresh();
|
||||||
|
assert.equal(stale.generation, 'rules-a');
|
||||||
|
assert.equal(stale.source.error, 'neighbors unavailable');
|
||||||
|
assert.deepEqual(stale.devices, first.devices);
|
||||||
|
|
||||||
|
failCounters = true;
|
||||||
|
observed = {
|
||||||
|
observedAt: '2026-08-07T12:03:00.000Z',
|
||||||
|
observations: [observation('192.168.50.7')],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
const timedOut = await service.refresh();
|
||||||
|
assert.equal(timedOut.observedAt, '2026-08-07T12:02:00.000Z');
|
||||||
|
assert.match(timedOut.source.error, /counter read timed out/);
|
||||||
|
assert.deepEqual(timedOut.devices, first.devices);
|
||||||
|
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
|
||||||
|
});
|
||||||
@@ -16,6 +16,12 @@ test('gateway keeps direct forwarding active while TProxy interception is switch
|
|||||||
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -i 'br-\+' -j RETURN/);
|
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -i 'br-\+' -j RETURN/);
|
||||||
assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/);
|
assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/);
|
||||||
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 mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/);
|
||||||
|
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, /export TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS/);
|
||||||
|
assert.match(entrypoint, /ipt_traffic\(\) \{\s+iptables -w 1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('control bypasses host routing while dataplane owns it', () => {
|
test('control bypasses host routing while dataplane owns it', () => {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
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.match(panel, /device\.confidence === 'ambiguous'/);
|
||||||
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/);
|
||||||
|
|||||||
Reference in New Issue
Block a user