diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml
index 438a1a0..1de7368 100644
--- a/.gitea/workflows/gateway-build.yml
+++ b/.gitea/workflows/gateway-build.yml
@@ -111,7 +111,7 @@ jobs:
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
UPDATE_DATAPLANE=false
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
fi
diff --git a/entrypoint.sh b/entrypoint.sh
index 31eb0bb..a76a126 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -8,6 +8,8 @@ TPROXY_TABLE="${TPROXY_TABLE:-100}"
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
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}"
PROXY_PORT="${PROXY_PORT:-8080}"
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_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}"
+export TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS
log() {
printf '[gateway-entrypoint] %s\n' "$*"
@@ -28,6 +31,10 @@ ipt() {
iptables -w "$@"
}
+ipt_traffic() {
+ iptables -w 1 "$@"
+}
+
cleanup_proxy_firewall() {
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
@@ -52,6 +59,34 @@ cleanup_gateway_forwarding() {
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() {
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
printf '1' > /proc/sys/net/ipv4/ip_forward || true
@@ -114,6 +149,10 @@ setup_tproxy() {
setup_gateway_forwarding
setup_tproxy
+if ! setup_device_traffic; then
+ log "device traffic counters unavailable; VPN routing remains active"
+ cleanup_device_traffic
+fi
setup_proxy_firewall
if [[ "$APP_COMPONENT" == "dataplane" ]]; then
@@ -127,6 +166,7 @@ shutdown() {
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
cleanup_proxy_firewall
+ cleanup_device_traffic
cleanup_tproxy
cleanup_gateway_forwarding
}
@@ -135,6 +175,7 @@ trap 'shutdown; exit 0' SIGTERM SIGINT
wait "$APP_PID"
STATUS=$?
cleanup_proxy_firewall
+cleanup_device_traffic
cleanup_tproxy
cleanup_gateway_forwarding
exit "$STATUS"
diff --git a/src/server/config.js b/src/server/config.js
index b54dcf2..2e6b846 100644
--- a/src/server/config.js
+++ b/src/server/config.js
@@ -16,6 +16,11 @@ export const settings = {
proxyPort,
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
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",
bindIp: process.env.PROXY_BIND_IP || "0.0.0.0",
dataDir,
diff --git a/src/server/dataplane.js b/src/server/dataplane.js
index 4c6193d..dd236a1 100644
--- a/src/server/dataplane.js
+++ b/src/server/dataplane.js
@@ -5,6 +5,7 @@ import { settings } from './config.js';
import { createSingboxRuntime } from './singboxRuntime.js';
import { buildVersionInfo } from './version.js';
import { readNeighborSnapshot } from './adapters/neighbors.js';
+import { createDeviceTrafficService } from './services/deviceTrafficService.js';
const socketPath = settings.dataplaneSocket;
const runtime = createSingboxRuntime({
@@ -13,7 +14,14 @@ const runtime = createSingboxRuntime({
tproxyChain: settings.tproxyChain,
});
const versionInfo = buildVersionInfo('gateway');
+const traffic = createDeviceTrafficService({
+ observe: () => readNeighborSnapshot(),
+ uploadChain: settings.trafficUploadChain,
+ downloadChain: settings.trafficDownloadChain,
+ bypassCidrs: settings.bypassCidrs,
+});
let ready = false;
+let trafficTimer = null;
function sendJson(res, statusCode, payload) {
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') {
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') {
return sendJson(res, 200, await runtime.apply());
}
@@ -58,6 +69,14 @@ server.listen(socketPath, async () => {
console.warn(`[dataplane] sing-box не запущен: ${error.message}`);
} finally {
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}`);
}
});
@@ -67,6 +86,7 @@ async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
ready = false;
+ if (trafficTimer) clearInterval(trafficTimer);
await runtime.shutdown();
server.close(() => {
fs.rmSync(socketPath, { force: true });
diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js
index 48c0d56..9d09394 100644
--- a/src/server/dataplaneClient.js
+++ b/src/server/dataplaneClient.js
@@ -44,6 +44,7 @@ export function createDataplaneClient(socketPath, send = request) {
get startedAt() { return current.startedAt || null; },
refresh: () => update('/status', 'GET'),
observeDevices: () => send(socketPath, '/devices', 'GET'),
+ observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'),
stop: () => update('/stop', 'POST'),
diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js
index dd6a769..db32548 100644
--- a/src/server/services/deviceInventoryService.js
+++ b/src/server/services/deviceInventoryService.js
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
+import net from 'node:net';
import { HarborError } from '../../shared/errors.js';
const SCHEMA_VERSION = 1;
@@ -100,6 +101,13 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
}
const observedAt = result?.observedAt || now().toISOString();
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) => {
const state = migrate(stored);
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,
lastSeenAt,
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;
diff --git a/src/server/services/deviceTrafficService.js b/src/server/services/deviceTrafficService.js
new file mode 100644
index 0000000..550e28f
--- /dev/null
+++ b/src/server/services/deviceTrafficService.js
@@ -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),
+ };
+}
diff --git a/src/shared/versions.js b/src/shared/versions.js
index 613385e..39fcbed 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.9.4',
- gatewayClient: '0.9.4',
- gatewayBackend: '0.9.1',
+ macClient: '0.10.0',
+ gatewayClient: '0.10.0',
+ gatewayBackend: '0.10.0',
});
export function parseVersion(value) {
diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx
index 3862350..7f8c4a0 100644
--- a/src/web/components/DevicesPanel.jsx
+++ b/src/web/components/DevicesPanel.jsx
@@ -248,10 +248,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
ⓘ
{device.confidence === 'medium'
? 'Устройство использует приватный MAC, производитель может не определиться'
- : 'Устройство определено приблизительно'}
+ : device.confidence === 'ambiguous'
+ ? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными'
+ : 'Устройство определено приблизительно'}
}
}
- {device.interface && {device.interface}}