diff --git a/.env.example b/.env.example index 34a04e7..305765c 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 TPROXY_CHAIN=VPN_PROXY_TPROXY +DIRECT_TRAFFIC_CHAIN=VPN_PROXY_DIRECT +DIRECT_TRAFFIC_MARK=0x40000000 GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD GATEWAY_NAT_CHAIN=VPN_PROXY_NAT GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 diff --git a/README.md b/README.md index 049b203..6e6e592 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/install.sh | | `PROXY_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения | | `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy | | `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать | +| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` | | `LOG_LEVEL` | `info` | Уровень подробности журнала | Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. @@ -274,9 +275,13 @@ scrape_configs: `harbor_traffic_bytes_total` содержит общий накопленный объём по источникам Gateway/Proxy. `harbor_device_traffic_bytes_total` содержит upload/download по стабильному `device_id`; пользовательское название и текущий IP находятся в `harbor_device_info`. `harbor_device_domain_traffic_bytes_total` добавляет наблюдённые домен, сервис, источник и направление для каждого устройства. `harbor_domain_traffic_attribution_events_total{outcome}` помогает отличить нераспознанный hostname, неизвестное устройство и неподдерживаемый inbound без динамических high-cardinality labels. -Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. +Фактический выход экспортируется отдельно. `harbor_singbox_tracked_bytes_total{source,outbound,direction}` показывает наблюдённые sing-box байты с `outbound="vpn|direct|unknown"`; вариант с префиксом `harbor_device_...` добавляет `device_id`. `harbor_direct_ipv4_packet_bytes_total{direction}` считает IPv4-пакеты, которые Gateway направил напрямую вместо sing-box, включая policy Direct и работу при остановленном VPN runtime; вариант `harbor_device_...` содержит атрибутированную детализацию. `source="gateway|proxy"` по-прежнему означает место входа, а `outbound` — выбранный sing-box выход. -Domain counters снимаются с активных соединений sing-box раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. В метрики не входит физический трафик вне Harbor, устройства с policy Direct, соединения между двумя снимками и байты после последнего снимка перед закрытием или quota провайдера. +Dashboard отделяет текущую скорость от значений за выбранный период и накопленных счётчиков. Единый фильтр `Устройства` по умолчанию охватывает все устройства, но позволяет выбрать одно; список показывает `name · ip`, сохраняя стабильный `device_id` как значение. Он управляет графиками скорости, накопленным трафиком, сервисами и доменами. Отдельный график скорости по устройствам показывает одну суммарную линию на каждое активное устройство; нулевые устройства и source/direction series скрыты. Top-10 устройств за период отсортирован по убыванию и выбирает устройство в том же фильтре. Domain table показывает только сервис, домен и трафик. В технической детализации VPN/Direct внутри sing-box и Direct IPv4-пакеты показаны на разных панелях. Автообновление настроено на 30 секунд; freshness предупреждает после 60 секунд и считает данные устаревшими после 120 секунд. + +Domain и sing-box outbound counters снимаются с активных соединений раз в 2 секунды и хранятся в памяти dataplane до его перезапуска; историю и retention хранит Prometheus. Перед routing sing-box до 1 секунды распознаёт HTTP Host, TLS SNI и QUIC Server Name. YouTube и OpenAI / ChatGPT объединяются по известным связанным доменам в label `service`, остальные значения сохраняют домен как имя сервиса. Если устройство и Harbor source известны, но hostname недоступен (например, ECH или IP-only), трафик попадает в `domain="_unknown",service="Не распознано"` и не теряется. Новые domain series сверх process limit складываются в `_other`. + +Direct IPv4 считает L3 packet bytes с IP-заголовками и retransmit, а sing-box tracker — логические TCP/UDP bytes без tunnel overhead. Эти семейства нельзя складывать в один «точный общий трафик». Snapshot polling может пропустить короткие соединения и финальный хвост; IPv6, трафик вне Gateway, назначения из `BYPASS_CIDRS` и quota провайдера не входят в новый route split. Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana». diff --git a/entrypoint.sh b/entrypoint.sh index 49a0dd3..63e494a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,6 +11,8 @@ 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}" +DIRECT_TRAFFIC_CHAIN="${DIRECT_TRAFFIC_CHAIN:-VPN_PROXY_DIRECT}" +DIRECT_TRAFFIC_MARK="${DIRECT_TRAFFIC_MARK:-0x40000000}" 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}" @@ -18,7 +20,9 @@ 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 TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS +export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN DIRECT_TRAFFIC_CHAIN DIRECT_TRAFFIC_MARK GATEWAY_CLIENT_CIDRS BYPASS_CIDRS +DEVICE_TRAFFIC_CONFIG_VALID=false +export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=false log() { printf '[gateway-entrypoint] %s\n' "$*" @@ -28,6 +32,53 @@ if [[ "$APP_COMPONENT" == "control" ]]; then exec node /app/dist/server/main.js fi +validate_device_traffic_config() { + if [[ -z "$DIRECT_TRAFFIC_CHAIN" || ${#DIRECT_TRAFFIC_CHAIN} -gt 24 + || "$DIRECT_TRAFFIC_CHAIN" =~ [^a-zA-Z0-9_] ]]; then + log "device traffic counters unavailable: invalid DIRECT_TRAFFIC_CHAIN" + return 1 + fi + local direct_names=("$DIRECT_TRAFFIC_CHAIN" "${DIRECT_TRAFFIC_CHAIN}_A" "${DIRECT_TRAFFIC_CHAIN}_B") + local reserved_names=( + PREROUTING INPUT FORWARD OUTPUT POSTROUTING + "$TPROXY_CHAIN" + "$DEVICE_POLICY_CHAIN" "${DEVICE_POLICY_CHAIN}_A" "${DEVICE_POLICY_CHAIN}_B" + "$TRAFFIC_DOWNLOAD_CHAIN" "${TRAFFIC_DOWNLOAD_CHAIN}_A" "${TRAFFIC_DOWNLOAD_CHAIN}_B" + "${TRAFFIC_DOWNLOAD_CHAIN}_A_P" "${TRAFFIC_DOWNLOAD_CHAIN}_B_P" + ) + for direct_name in "${direct_names[@]}"; do + for reserved_name in "${reserved_names[@]}"; do + if [[ "$direct_name" == "$reserved_name" ]]; then + log "device traffic counters unavailable: DIRECT_TRAFFIC_CHAIN conflicts with ${reserved_name}" + return 1 + fi + done + done + if ! [[ "$DIRECT_TRAFFIC_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$ + && "$TPROXY_MARK" =~ ^(0[xX][0-9a-fA-F]{1,8}|[0-9]{1,10})$ ]]; then + log "device traffic counters unavailable: invalid traffic mark" + return 1 + fi + local direct_mark_value tproxy_mark_value + if [[ "$DIRECT_TRAFFIC_MARK" =~ ^0[xX] ]]; then + direct_mark_value=$((16#${DIRECT_TRAFFIC_MARK:2})) + else + direct_mark_value=$((10#$DIRECT_TRAFFIC_MARK)) + fi + if [[ "$TPROXY_MARK" =~ ^0[xX] ]]; then + tproxy_mark_value=$((16#${TPROXY_MARK:2})) + else + tproxy_mark_value=$((10#$TPROXY_MARK)) + fi + if (( direct_mark_value == 0 || direct_mark_value > 0xffffffff + || (direct_mark_value & (direct_mark_value - 1)) != 0 + || (direct_mark_value & tproxy_mark_value) != 0 )); then + log "device traffic counters unavailable: DIRECT_TRAFFIC_MARK must be one bit outside TPROXY_MARK" + return 1 + fi + DEVICE_TRAFFIC_CONFIG_VALID=true +} + ipt() { iptables -w "$@" } @@ -80,37 +131,53 @@ cleanup_gateway_forwarding() { } cleanup_device_traffic() { + [[ "$DEVICE_TRAFFIC_CONFIG_VALID" == "true" ]] || return 0 + ipt_traffic -t mangle -D "$TPROXY_CHAIN" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" 2>/dev/null || true ipt_traffic -t raw -D PREROUTING -j "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true + ipt_traffic -t mangle -D PREROUTING -j "$DIRECT_TRAFFIC_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 "$DIRECT_TRAFFIC_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 -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 2>/dev/null || true ipt_traffic -t raw -X "${TRAFFIC_UPLOAD_CHAIN}_${slot}" 2>/dev/null || true + ipt_traffic -t mangle -F "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true + ipt_traffic -t mangle -X "${DIRECT_TRAFFIC_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 2>/dev/null || true ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 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 "$DIRECT_TRAFFIC_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" + log "setup device traffic counters" cleanup_device_traffic ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1 + ipt_traffic -t mangle -N "$DIRECT_TRAFFIC_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 raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || return 1 + ipt_traffic -t mangle -N "${DIRECT_TRAFFIC_CHAIN}_${slot}" || return 1 ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" || return 1 ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1 done ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1 + # sing-box inserts TPROXY at position 1 later; this jump then sees only packets not intercepted by it. + ipt_traffic -t mangle -I PREROUTING 1 -j "$DIRECT_TRAFFIC_CHAIN" || return 1 ipt_traffic -t mangle -I POSTROUTING 1 -j "$TRAFFIC_DOWNLOAD_CHAIN" || return 1 + local policy_rule=4 + for _cidr in $BYPASS_CIDRS; do + policy_rule=$((policy_rule + 1)) + done + ipt_traffic -t mangle -I "$TPROXY_CHAIN" "$policy_rule" -j CONNMARK --set-xmark "0x0/$DIRECT_TRAFFIC_MARK" || return 1 } enable_ip_forwarding() { @@ -172,7 +239,6 @@ setup_tproxy() { for cidr in $BYPASS_CIDRS; do ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN done - if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN" else @@ -183,9 +249,13 @@ setup_tproxy() { setup_gateway_forwarding setup_tproxy -if ! setup_device_traffic; then - log "device traffic counters unavailable; VPN routing remains active" - cleanup_device_traffic +if validate_device_traffic_config; then + if ! setup_device_traffic; then + log "device traffic counters unavailable; VPN routing remains active" + cleanup_device_traffic + else + export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true + fi fi setup_proxy_firewall diff --git a/monitoring/grafana/harbor-gateway.json b/monitoring/grafana/harbor-gateway.json index f9b762c..201eaa1 100644 --- a/monitoring/grafana/harbor-gateway.json +++ b/monitoring/grafana/harbor-gateway.json @@ -104,6 +104,14 @@ "legendFormat": "Домены", "range": false, "refId": "B" + }, + { + "editorMode": "code", + "expr": "time() - harbor_direct_ipv4_packet_last_observed_timestamp_seconds", + "instant": true, + "legendFormat": "Gateway Direct", + "range": false, + "refId": "C" } ], "title": "Свежесть данных в конце выбранного периода", @@ -897,6 +905,116 @@ ], "title": "Источники за выбранный период", "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Наблюдённые sing-box байты по фактически выбранному outbound и источнику входа. Короткие соединения и хвост после последнего snapshot могут отсутствовать; это не физический трафик туннеля.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 1, + "noValue": "Нет данных", + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 14, + "x": 0, + "y": 60 + }, + "id": 15, + "options": { + "displayMode": "basic", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "sizing": "auto", + "valueMode": "text" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (outbound, source) (increase(harbor_singbox_tracked_bytes_total[$__range])) > 0", + "instant": true, + "legendFormat": "{{outbound}} · {{source}}", + "range": false, + "refId": "A" + } + ], + "title": "sing-box: VPN / Direct за выбранный период", + "type": "bargauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "IPv4-пакеты, которые Gateway направил напрямую вместо sing-box: policy Direct или работа при остановленном VPN runtime. Это L3 packet bytes с заголовками и retransmit; их нельзя складывать с sing-box tracker bytes.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "decimals": 1, + "noValue": "Нет данных", + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 10, + "x": 14, + "y": 60 + }, + "id": 16, + "options": { + "displayMode": "basic", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": false, + "sizing": "auto", + "valueMode": "text" + }, + "targets": [ + { + "editorMode": "code", + "expr": "sum by (direction) (increase(harbor_direct_ipv4_packet_bytes_total[$__range])) > 0", + "instant": true, + "legendFormat": "{{direction}}", + "range": false, + "refId": "A" + } + ], + "title": "Gateway Direct: IPv4-пакеты за выбранный период", + "type": "bargauge" } ], "refresh": "30s", @@ -956,6 +1074,6 @@ "timezone": "browser", "title": "Harbor Gateway: трафик", "uid": "harbor-gateway-traffic", - "version": 7, + "version": 8, "weekStart": "monday" } diff --git a/scripts/harbor-version.mjs b/scripts/harbor-version.mjs index 8a234db..519f5f6 100644 --- a/scripts/harbor-version.mjs +++ b/scripts/harbor-version.mjs @@ -53,7 +53,7 @@ export function affectedComponents(files) { for (const file of files) { if (file === VERSION_FILE) continue; if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS); - else if (/^(src\/web\/|public\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) { + else if (/^(src\/web\/|public\/|monitoring\/grafana\/|index\.html$|tsconfig\.web\.json$|vite\.config\.[cm]?[jt]s$)/.test(file)) { add('macClient', 'gatewayClient'); } else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend'); else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) { diff --git a/src/server/config.ts b/src/server/config.ts index 2ae7192..2068571 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -22,6 +22,12 @@ export const settings = { devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY", trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP", trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN", + deviceTrafficAccountingEnabled: process.env.DEVICE_TRAFFIC_ACCOUNTING_ENABLED !== "false", + directTrafficChain: process.env.DIRECT_TRAFFIC_CHAIN || "VPN_PROXY_DIRECT", + directTrafficMark: process.env.DIRECT_TRAFFIC_MARK || "0x40000000", + gatewayClientCidrs: (process.env.GATEWAY_CLIENT_CIDRS + || "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16") + .trim().split(/\s+/).filter(Boolean), 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), diff --git a/src/server/dataplane.ts b/src/server/dataplane.ts index c3643e7..26f41c1 100644 --- a/src/server/dataplane.ts +++ b/src/server/dataplane.ts @@ -25,6 +25,10 @@ const traffic = createDeviceTrafficService({ observe: () => readNeighborSnapshot(), uploadChain: settings.trafficUploadChain, downloadChain: settings.trafficDownloadChain, + directChain: settings.directTrafficChain, + directMark: settings.directTrafficMark, + tproxyMark: settings.tproxyMark, + gatewayClientCidrs: settings.gatewayClientCidrs, bypassCidrs: settings.bypassCidrs, proxyPort: settings.proxyPort, }); @@ -147,14 +151,16 @@ server.listen(socketPath, async () => { console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); } finally { ready = true; - setImmediate(() => { - traffic.refresh() - .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); - }); - trafficTimer = setInterval(() => { - traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); - }, 15_000); - trafficTimer.unref(); + if (settings.deviceTrafficAccountingEnabled) { + setImmediate(() => { + traffic.refresh() + .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); + }); + trafficTimer = setInterval(() => { + traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); + }, 15_000); + trafficTimer.unref(); + } setImmediate(() => { domainTraffic.refresh() .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); diff --git a/src/server/gatewayRouting.ts b/src/server/gatewayRouting.ts index 8d251bd..f9c82f2 100644 --- a/src/server/gatewayRouting.ts +++ b/src/server/gatewayRouting.ts @@ -1,8 +1,10 @@ import { spawnSync } from 'node:child_process'; const options = { encoding: 'utf8' as const }; +const CHAIN_PATTERN = /^[a-z0-9_-]{1,28}$/i; export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) { + if (!CHAIN_PATTERN.test(chain)) throw new Error('Некорректная TProxy chain'); const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain]; const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0; @@ -10,7 +12,15 @@ export function setGatewayInterception(enabled: boolean, chain: string, run: typ if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options); return; } - if (exists) return; + if (exists) { + const input = `*mangle\n-D PREROUTING -j ${chain}\n-I PREROUTING 1 -j ${chain}\nCOMMIT\n`; + const result = run('iptables-restore', ['-w', '--noflush'], { ...options, input }); + if (result.status !== 0) { + // The transaction keeps the already-working jump intact; leave routing up. + return; + } + return; + } const result = run( 'iptables', diff --git a/src/server/prometheusMetrics.ts b/src/server/prometheusMetrics.ts index bb51d3e..8992e69 100644 --- a/src/server/prometheusMetrics.ts +++ b/src/server/prometheusMetrics.ts @@ -114,7 +114,88 @@ export function renderPrometheusMetrics(value: unknown) { } } + const directTraffic = record(snapshot.directTraffic); + const directSeries = Array.isArray(directTraffic.series) ? directTraffic.series.map(record) : []; + const directObservedAt = timestamp(directTraffic.observedAt); + if (directObservedAt) { + lines.push( + '# HELP harbor_direct_ipv4_packet_bytes_total IPv4 L3 packet bytes forwarded directly instead of entering sing-box; includes IP headers and retransmissions.', + '# TYPE harbor_direct_ipv4_packet_bytes_total counter', + ); + metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'download' }, counter(directTraffic.downloadBytes)); + metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'upload' }, counter(directTraffic.uploadBytes)); + } + if (directSeries.length) { + lines.push( + '# HELP harbor_device_direct_ipv4_packet_bytes_total Attributed IPv4 L3 packet bytes forwarded directly instead of entering sing-box.', + '# TYPE harbor_device_direct_ipv4_packet_bytes_total counter', + ); + for (const series of directSeries) { + for (const [direction, amount] of [ + ['download', series.downloadBytes], + ['upload', series.uploadBytes], + ]) { + metric(lines, 'harbor_device_direct_ipv4_packet_bytes_total', { + device_id: series.deviceId, + direction, + }, counter(amount)); + } + } + } + if (directObservedAt) { + lines.push( + '# HELP harbor_direct_ipv4_packet_last_observed_timestamp_seconds Unix timestamp of the last successful direct IPv4 packet observation.', + '# TYPE harbor_direct_ipv4_packet_last_observed_timestamp_seconds gauge', + ); + lines.push(`harbor_direct_ipv4_packet_last_observed_timestamp_seconds ${directObservedAt}`); + } + const domainTraffic = record(snapshot.domainTraffic); + const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : []; + if (trackedSeries.length) { + lines.push( + '# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.', + '# TYPE harbor_singbox_tracked_bytes_total counter', + ); + for (const series of trackedSeries) { + const source = String(series.source || ''); + const outbound = String(series.outbound || ''); + if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) { + throw new Error('Invalid sing-box outbound labels'); + } + for (const [direction, amount] of [ + ['download', series.downloadBytes], + ['upload', series.uploadBytes], + ]) { + metric(lines, 'harbor_singbox_tracked_bytes_total', { source, outbound, direction }, counter(amount)); + } + } + } + const routeSeries = Array.isArray(domainTraffic.routes) ? domainTraffic.routes.map(record) : []; + if (routeSeries.length) { + lines.push( + '# HELP harbor_device_singbox_tracked_bytes_total Attributed bytes observed by the sing-box TCP/UDP tracker for a selected outbound.', + '# TYPE harbor_device_singbox_tracked_bytes_total counter', + ); + for (const series of routeSeries) { + const source = String(series.source || ''); + const outbound = String(series.outbound || ''); + if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) { + throw new Error('Invalid sing-box outbound labels'); + } + for (const [direction, amount] of [ + ['download', series.downloadBytes], + ['upload', series.uploadBytes], + ]) { + metric(lines, 'harbor_device_singbox_tracked_bytes_total', { + device_id: series.deviceId, + source, + outbound, + direction, + }, counter(amount)); + } + } + } const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : []; if (domainSeries.length) { lines.push( @@ -143,6 +224,11 @@ export function renderPrometheusMetrics(value: unknown) { '# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge', ); lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`); + lines.push( + '# HELP harbor_singbox_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box traffic observation.', + '# TYPE harbor_singbox_traffic_last_observed_timestamp_seconds gauge', + `harbor_singbox_traffic_last_observed_timestamp_seconds ${domainObservedAt}`, + ); } if (domainTraffic.overflowConnections != null) { lines.push( diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index b2dc8b5..f1a1b74 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -704,6 +704,14 @@ export function createDeviceInventoryService({ }, series: [], }; + let directTrafficSnapshot: Record = { + epoch: null, + observedAt: null, + source: { error: null }, + uploadBytes: '0', + downloadBytes: '0', + series: [], + }; function captureTrafficHistory(state: InventoryState) { const knownMacs = new Set(state.devices.map(({ mac }) => mac)); @@ -877,7 +885,7 @@ export function createDeviceInventoryService({ } function metricsSnapshot() { - return { ...snapshot(), domainTraffic: domainTrafficSnapshot }; + return { ...snapshot(), domainTraffic: domainTrafficSnapshot, directTraffic: directTrafficSnapshot }; } function markPolicyEpoch(observed: unknown) { @@ -1064,6 +1072,59 @@ export function createDeviceInventoryService({ } else if (domainTrafficResult) { domainTrafficSnapshot = record(domainTrafficResult); } + if (typeof trafficResult?.transportError === 'string') { + directTrafficSnapshot = { + ...directTrafficSnapshot, + source: { error: trafficResult.transportError }, + }; + } else if (trafficResult) { + try { + const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : []; + const hasDirect = rows.some((row) => Object.hasOwn(row, 'directUploadBytes') || Object.hasOwn(row, 'directDownloadBytes')); + const direct = record(trafficResult.direct); + const hasDirectTotal = Object.hasOwn(direct, 'uploadBytes') || Object.hasOwn(direct, 'downloadBytes'); + if ((hasDirectTotal && rows.some((row) => !Object.hasOwn(row, 'directUploadBytes') || !Object.hasOwn(row, 'directDownloadBytes'))) + || (!hasDirectTotal && hasDirect) + || (hasDirectTotal && (!Object.hasOwn(direct, 'uploadBytes') || !Object.hasOwn(direct, 'downloadBytes')))) { + throw new Error('Dataplane вернул неполный direct traffic counter'); + } + const uploadBytes = String(direct.uploadBytes ?? '0'); + const downloadBytes = String(direct.downloadBytes ?? '0'); + if (hasDirectTotal && (!COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes))) { + throw new Error('Dataplane вернул невалидный global direct traffic counter'); + } + if (hasDirectTotal && (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch + || !validTimestamp(trafficResult.observedAt))) { + throw new Error('Dataplane вернул невалидную direct traffic identity'); + } + const series = hasDirectTotal ? rows.map((row) => { + const mac = normalizeMac(row.mac); + const uploadBytes = String(row.directUploadBytes ?? ''); + const downloadBytes = String(row.directDownloadBytes ?? ''); + if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) { + throw new Error('Dataplane вернул невалидный direct traffic counter'); + } + return { deviceId: deviceId(mac), uploadBytes, downloadBytes }; + }) : []; + directTrafficSnapshot = { + epoch: typeof trafficResult.epoch === 'string' ? trafficResult.epoch : null, + observedAt: hasDirectTotal && typeof trafficResult.observedAt === 'string' ? trafficResult.observedAt : null, + source: { + error: typeof record(trafficResult.source).error === 'string' + ? String(record(trafficResult.source).error) + : null, + }, + uploadBytes, + downloadBytes, + series, + }; + } catch (error) { + directTrafficSnapshot = { + ...directTrafficSnapshot, + source: { error: errorMessage(error) }, + }; + } + } const nextState = store.update((stored) => { const state = migrateDeviceInventoryState(stored); const byMac = new Map(state.devices.map((device) => [device.mac, device])); diff --git a/src/server/services/deviceTrafficService.ts b/src/server/services/deviceTrafficService.ts index a1076f0..97b56d7 100644 --- a/src/server/services/deviceTrafficService.ts +++ b/src/server/services/deviceTrafficService.ts @@ -24,9 +24,9 @@ interface TrafficDevice { key: string; } -type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download'; -type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload'; -type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes'; +type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download' | 'direct-upload' | 'direct-download'; +type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload' | 'directUpload' | 'directDownload'; +type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes' | 'directUploadBytes' | 'directDownloadBytes'; type CounterValues = Record; interface RetiredCounters { @@ -40,6 +40,7 @@ interface TrafficSnapshot { generation: string; observedAt: string | null; source: { error: string | null }; + direct: { uploadBytes: string; downloadBytes: string }; devices: Record[]; } @@ -53,6 +54,8 @@ const COUNTERS = [ ['download', 'download', 'downloadBytes'], ['proxy-upload', 'proxyUpload', 'proxyUploadBytes'], ['proxy-download', 'proxyDownload', 'proxyDownloadBytes'], + ['direct-upload', 'directUpload', 'directUploadBytes'], + ['direct-download', 'directDownload', 'directDownloadBytes'], ] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[]; const childChain = (chain: string, slot: string) => `${chain}_${slot}`; @@ -117,7 +120,37 @@ function isIpv4Cidr(value: unknown) { && Number.isInteger(size) && size >= 0 && size <= 32; } -const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n }); +function cidrRange(cidr: string) { + const [address, prefix] = cidr.split('/'); + const value = address.split('.').reduce((result, octet) => result * 256n + BigInt(octet), 0n); + const bits = BigInt(Number(prefix)); + const mask = bits === 0n ? 0n : (0xffff_ffffn << (32n - bits)) & 0xffff_ffffn; + const first = value & mask; + return [first, first | (0xffff_ffffn ^ mask)] as const; +} + +function hasOverlappingCidrs(cidrs: readonly string[]) { + const ranges = cidrs.map(cidrRange).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + return ranges.some(([start], index) => index > 0 && start <= ranges[index - 1][1]); +} + +const zeroCounters = (): CounterValues => ({ + upload: 0n, + download: 0n, + proxyUpload: 0n, + proxyDownload: 0n, + directUpload: 0n, + directDownload: 0n, +}); + +function markValue(value: unknown) { + try { + const parsed = BigInt(String(value)); + return parsed > 0n && parsed <= 0xffff_ffffn ? parsed : null; + } catch { + return null; + } +} function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) @@ -160,6 +193,10 @@ export function buildTrafficRestore({ bypassCidrs, uploadChain, downloadChain, + directChain, + directMark, + tproxyMark, + gatewayClientCidrs, slot, proxyPort, }: { @@ -167,17 +204,28 @@ export function buildTrafficRestore({ bypassCidrs: readonly string[]; uploadChain: string; downloadChain: string; + directChain: string; + directMark: string; + tproxyMark: string; + gatewayClientCidrs: readonly string[]; slot: string; proxyPort: number; }) { - if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain) + const parsedDirectMark = markValue(directMark); + const parsedTproxyMark = markValue(tproxyMark); + if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain) || !CHAIN_PATTERN.test(directChain) || !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65_535 - || !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) { + || parsedDirectMark == null || (parsedDirectMark & (parsedDirectMark - 1n)) !== 0n + || parsedTproxyMark == null || (parsedDirectMark & parsedTproxyMark) !== 0n + || !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr)) + || !Array.isArray(gatewayClientCidrs) || gatewayClientCidrs.some((cidr) => !isIpv4Cidr(cidr)) + || hasOverlappingCidrs(gatewayClientCidrs)) { throw new Error('Некорректная конфигурация traffic accounting'); } const uploadChild = childChain(uploadChain, slot); const downloadChild = childChain(downloadChain, slot); + const directChild = childChain(directChain, slot); const proxyUploadChild = proxyChildChain(uploadChain, slot); const proxyDownloadChild = proxyChildChain(downloadChain, slot); const raw = [ @@ -192,8 +240,12 @@ export function buildTrafficRestore({ ]; const mangle = [ '*mangle', + `-F ${directChild}`, `-F ${downloadChild}`, `-F ${proxyDownloadChild}`, + `-A ${directChild} -m addrtype --dst-type LOCAL -j RETURN`, + `-A ${directChild} -m mark --mark ${tproxyMark}/${tproxyMark} -j RETURN`, + `-A ${directChild} -i br-+ -j RETURN`, `-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`, `-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`, `-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`, @@ -208,9 +260,16 @@ export function buildTrafficRestore({ } for (const cidr of bypassCidrs) { raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`); + mangle.push(`-A ${directChild} -d ${cidr} -j RETURN`); mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`); } + for (const cidr of gatewayClientCidrs) { + mangle.push(`-A ${directChild} -s ${cidr} -m comment --comment harbor-traffic:global:direct-upload -j CONNMARK --set-xmark ${directMark}/${directMark}`); + mangle.push(`-A ${downloadChild} -d ${cidr} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:global:direct-download`); + } for (const device of devices) { + mangle.push(`-A ${directChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-upload`); + mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-download`); raw.push(`-A ${uploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-traffic:${device.key}:upload -j RETURN`); mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m comment --comment harbor-traffic:${device.key}:download -j RETURN`); } @@ -220,7 +279,7 @@ export function buildTrafficRestore({ export function parseTrafficCounters(text: unknown, chain: string): Map { const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const linePattern = new RegExp( - `^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`, + `^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}|global):(upload|download|proxy-upload|proxy-download|direct-upload|direct-download)"?`, ); const counters = new Map(); for (const line of String(text || '').split(/\r?\n/)) { @@ -236,6 +295,10 @@ export function createDeviceTrafficService({ observe, uploadChain, downloadChain, + directChain, + directMark, + tproxyMark, + gatewayClientCidrs, bypassCidrs, proxyPort, run = runCommand, @@ -244,6 +307,10 @@ export function createDeviceTrafficService({ observe: () => Promise | unknown; uploadChain: string; downloadChain: string; + directChain: string; + directMark: string; + tproxyMark: string; + gatewayClientCidrs: string[]; bypassCidrs: string[]; proxyPort: number; run?: RunCommand; @@ -257,12 +324,14 @@ export function createDeviceTrafficService({ let pendingRetired: RetiredCounters | null = null; let refreshPromise: Promise | null = null; const finalized = new Map(); + const finalizedDirect = { upload: 0n, download: 0n }; const devicesByKey = new Map(); let current: TrafficSnapshot = { epoch, generation: epoch, observedAt: null, source: { error: null }, + direct: { uploadBytes: '0', downloadBytes: '0' }, devices: [], }; @@ -278,6 +347,10 @@ export function createDeviceTrafficService({ bypassCidrs, uploadChain, downloadChain, + directChain, + directMark, + tproxyMark, + gatewayClientCidrs, slot, proxyPort, }); @@ -287,22 +360,37 @@ export function createDeviceTrafficService({ async function switchTo(slot: 'A' | 'B') { const uploadChild = childChain(uploadChain, slot); const downloadChild = childChain(downloadChain, slot); + const directChild = childChain(directChain, 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]; + const mangleInput = [ + '*mangle', + activeSlot + ? `-R ${downloadChain} 1 -j ${downloadChild}` + : `-A ${downloadChain} -j ${downloadChild}`, + activeSlot + ? `-R ${directChain} 1 -j ${directChild}` + : `-A ${directChain} -j ${directChild}`, + 'COMMIT', + '', + ].join('\n'); await execute('iptables', uploadArgs); try { - await execute('iptables', downloadArgs); + await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input: mangleInput }); } catch (error) { - const rollbackArgs = activeSlot + const uploadRollback = activeSlot ? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)] : ['-w', '1', '-t', 'raw', '-F', uploadChain]; - await execute('iptables', rollbackArgs); + try { + await execute('iptables', uploadRollback); + } catch (rollbackError) { + const original = error instanceof Error ? error.message : String(error); + const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error(`${original}; rollback: ${rollback}`, { cause: error }); + } throw error; } } @@ -318,6 +406,8 @@ export function createDeviceTrafficService({ download: parseTrafficCounters(mangle, childChain(downloadChain, slot)), proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)), proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)), + directUpload: parseTrafficCounters(mangle, childChain(directChain, slot)), + directDownload: parseTrafficCounters(mangle, childChain(downloadChain, slot)), }; const counters = new Map(); for (const { key } of devices) { @@ -325,6 +415,8 @@ export function createDeviceTrafficService({ counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0'); } } + counters.set('global:direct-upload', parsed.directUpload.get('global:direct-upload') || '0'); + counters.set('global:direct-download', parsed.directDownload.get('global:direct-download') || '0'); return counters; } @@ -345,6 +437,8 @@ export function createDeviceTrafficService({ for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind); finalized.set(key, next); } + finalizedDirect.upload += counter(counters, 'global', 'direct-upload'); + finalizedDirect.download += counter(counters, 'global', 'direct-download'); pendingRetired = null; return true; } @@ -368,18 +462,41 @@ export function createDeviceTrafficService({ } return [...totalsByMac.values()] .map((total) => { - const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total; + const { + key: _key, + upload, + download, + proxyUpload, + proxyDownload, + directUpload, + directDownload, + ...device + } = total; return { ...device, uploadBytes: upload.toString(), downloadBytes: download.toString(), proxyUploadBytes: proxyUpload.toString(), proxyDownloadBytes: proxyDownload.toString(), + directUploadBytes: directUpload.toString(), + directDownloadBytes: directDownload.toString(), }; }) .sort((left, right) => left.mac.localeCompare(right.mac)); } + function directTotals() { + const pending = pendingRetired?.counters || new Map(); + return { + uploadBytes: (finalizedDirect.upload + + counter(pending, 'global', 'direct-upload') + + counter(activeCounters, 'global', 'direct-upload')).toString(), + downloadBytes: (finalizedDirect.download + + counter(pending, 'global', 'direct-download') + + counter(activeCounters, 'global', 'direct-download')).toString(), + }; + } + async function performRefresh() { let observed: Record; try { @@ -426,11 +543,13 @@ export function createDeviceTrafficService({ } } - try { - activeCounters = await readCounters(activeDevices, activeSlot); - countersRead = true; - } catch (error) { - sourceError = sourceError || (error instanceof Error ? error.message : String(error)); + if (activeSlot) { + try { + activeCounters = await readCounters(activeDevices, activeSlot); + countersRead = true; + } catch (error) { + sourceError = sourceError || (error instanceof Error ? error.message : String(error)); + } } current = { epoch, @@ -439,6 +558,7 @@ export function createDeviceTrafficService({ ? observed.observedAt : current.observedAt, source: { error: sourceError }, + direct: countersRead ? directTotals() : current.direct, devices: countersRead ? processTotals() : current.devices, }; return structuredClone(current); diff --git a/src/server/services/domainTrafficService.ts b/src/server/services/domainTrafficService.ts index 73aaffc..45a3702 100644 --- a/src/server/services/domainTrafficService.ts +++ b/src/server/services/domainTrafficService.ts @@ -9,6 +9,7 @@ const DEFAULT_MAX_SERIES = 4096; const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' }; const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const; type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number]; +type TrafficRoute = 'vpn' | 'direct' | 'unknown'; const SERVICE_DOMAINS = [ ['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']], ['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']], @@ -21,13 +22,19 @@ interface ParsedBaseConnection { } type ParsedConnection = - | (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' }) + | (ParsedBaseConnection & { outcome: 'unsupported_source' }) + | (ParsedBaseConnection & { + outcome: 'unknown_device'; + source: 'gateway' | 'proxy'; + outbound: TrafficRoute; + }) | (ParsedBaseConnection & { outcome: 'classified' | 'unresolved_host'; deviceId: string; domain: string; service: string; source: 'gateway' | 'proxy'; + outbound: TrafficRoute; }); interface PreviousConnection { @@ -36,6 +43,8 @@ interface PreviousConnection { requestedKey?: string; countedUpload: bigint | null; countedDownload: bigint | null; + trackedUpload: bigint | null; + trackedDownload: bigint | null; } interface DomainSeriesTotal { @@ -47,12 +56,28 @@ interface DomainSeriesTotal { downloadBytes: bigint; } +interface RouteSeriesTotal { + deviceId?: string; + source: 'gateway' | 'proxy'; + outbound: TrafficRoute; + uploadBytes: bigint; + downloadBytes: bigint; +} + interface DomainTrafficSnapshot { epoch: string; observedAt: string | null; source: { error: string | null }; overflowConnections: string; attributionEvents: Record; + tracked: Array & { + uploadBytes: string; + downloadBytes: string; + }>; + routes: Array & { + uploadBytes: string; + downloadBytes: string; + }>; series: Array & { uploadBytes: string; downloadBytes: string; @@ -86,6 +111,12 @@ function sourceFor(type: string): 'gateway' | 'proxy' | null { return null; } +function routeFor(value: unknown): TrafficRoute { + if (!Array.isArray(value) || !value.length + || value.some((entry) => typeof entry !== 'string' || !entry.trim())) return 'unknown'; + return value[0].trim() === 'direct' ? 'direct' : 'vpn'; +} + function parseConnection(value: unknown, devicesByIp: Map): ParsedConnection { const connection = record(value); const id = String(connection.id || ''); @@ -103,8 +134,9 @@ function parseConnection(value: unknown, devicesByIp: Map }; const source = sourceFor(String(metadata.type || '')); if (!source) return { ...parsed, outcome: 'unsupported_source' }; + const outbound = routeFor(connection.chains); const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || '')); - if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' }; + if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound }; const classifiedDomain = classifyDomain(metadata.host); const domain = classifiedDomain || UNKNOWN_DOMAIN; return { @@ -113,6 +145,7 @@ function parseConnection(value: unknown, devicesByIp: Map deviceId: currentDeviceId, ...domain, source, + outbound, }; } @@ -164,6 +197,8 @@ export function createDomainTrafficService({ if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2'); const epoch = crypto.randomUUID(); const totals = new Map(); + const routeTotals = new Map(); + const trackedTotals = new Map(); const normalSeriesLimit = maxSeries - 2; let normalSeries = 0; let previousConnections = new Map(); @@ -180,6 +215,8 @@ export function createDomainTrafficService({ source: { error: null }, overflowConnections: '0', attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, + tracked: [], + routes: [], series: [], }; @@ -192,6 +229,27 @@ export function createDomainTrafficService({ attributionEvents: Object.fromEntries( ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), ) as Record, + tracked: [...trackedTotals.values()] + .map((entry) => ({ + source: entry.source, + outbound: entry.outbound, + uploadBytes: entry.uploadBytes.toString(), + downloadBytes: entry.downloadBytes.toString(), + })) + .sort((left, right) => ( + left.source.localeCompare(right.source) || left.outbound.localeCompare(right.outbound) + )), + routes: [...routeTotals.values()] + .map((entry) => ({ + ...entry, + uploadBytes: entry.uploadBytes.toString(), + downloadBytes: entry.downloadBytes.toString(), + })) + .sort((left, right) => ( + String(left.deviceId).localeCompare(String(right.deviceId)) + || left.source.localeCompare(right.source) + || left.outbound.localeCompare(right.outbound) + )), series: [...totals.values()] .map((entry) => ({ ...entry, @@ -220,18 +278,42 @@ export function createDomainTrafficService({ if (!net.isIPv4(ip) || !id) continue; devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); } + const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp)); const activeConnections = new Map(); - for (const rawConnection of response.connections) { - const connection = parseConnection(rawConnection, devicesByIp); + for (const connection of connections) { const previous = previousConnections.get(connection.id); if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { attributionEvents[connection.outcome] += 1n; } + if (connection.outcome !== 'unsupported_source') { + const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload + ? connection.upload - previous.trackedUpload + : connection.upload; + const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload + ? connection.download - previous.trackedDownload + : connection.download; + const trackedKey = `${connection.source}\0${connection.outbound}`; + const tracked = trackedTotals.get(trackedKey) || { + source: connection.source, + outbound: connection.outbound, + uploadBytes: 0n, + downloadBytes: 0n, + }; + tracked.uploadBytes += uploadDelta; + tracked.downloadBytes += downloadDelta; + trackedTotals.set(trackedKey, tracked); + } if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') { activeConnections.set(connection.id, { outcome: connection.outcome, countedUpload: previous?.countedUpload ?? null, countedDownload: previous?.countedDownload ?? null, + trackedUpload: connection.outcome === 'unknown_device' + ? connection.upload + : previous?.trackedUpload ?? null, + trackedDownload: connection.outcome === 'unknown_device' + ? connection.download + : previous?.trackedDownload ?? null, }); continue; } @@ -257,6 +339,17 @@ export function createDomainTrafficService({ const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload ? connection.download - previous.countedDownload : connection.download; + const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`; + const routeTotal = routeTotals.get(routeKey) || { + deviceId: connection.deviceId, + source: connection.source, + outbound: connection.outbound, + uploadBytes: 0n, + downloadBytes: 0n, + }; + routeTotal.uploadBytes += uploadDelta; + routeTotal.downloadBytes += downloadDelta; + routeTotals.set(routeKey, routeTotal); const total = totals.get(key) || { deviceId: key === requestedKey ? connection.deviceId : '_other', domain, @@ -274,6 +367,8 @@ export function createDomainTrafficService({ requestedKey, countedUpload: connection.upload, countedDownload: connection.download, + trackedUpload: connection.upload, + trackedDownload: connection.download, }); } previousConnections = activeConnections; diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f0434fc..699e7ff 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.1', - gatewayClient: '0.26.0', - gatewayBackend: '0.26.1', + macClient: '0.25.2', + gatewayClient: '0.26.1', + gatewayBackend: '0.26.2', }); export interface ParsedVersion { diff --git a/src/web/features/instructions/instructionBlocks.ts b/src/web/features/instructions/instructionBlocks.ts index 7e4b0b5..0ebb4dc 100644 --- a/src/web/features/instructions/instructionBlocks.ts +++ b/src/web/features/instructions/instructionBlocks.ts @@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: { { id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) }, { id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson }, ], - note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.', + note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.', }] : []), ]; } diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index 01357b9..507c4c8 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -1018,11 +1018,87 @@ test('an old dataplane without domain traffic keeps inventory refresh and existi const service = createDeviceInventoryService({ store, observe: () => ({ observedAt: '2026-08-08T10:00:00.000Z', observations: [], error: null }), + observeTraffic: () => ({ + epoch: 'epoch-a', + generation: 'rules-a', + observedAt: '2026-08-08T10:00:00.000Z', + source: { error: null }, + direct: { uploadBytes: '0', downloadBytes: '0' }, + devices: [], + }), observeDomainTraffic: () => { throw new Error('Dataplane HTTP 404'); }, }); const snapshot = await service.refresh(); assert.equal(snapshot.devices.length, 0); assert.equal(service.metricsSnapshot().domainTraffic.source.error, 'Dataplane HTTP 404'); + assert.deepEqual(service.metricsSnapshot().directTraffic, { + epoch: 'epoch-a', + observedAt: '2026-08-08T10:00:00.000Z', + source: { error: null }, + uploadBytes: '0', + downloadBytes: '0', + series: [], + }); assert.equal(service.metricsSnapshot().traffic.totalBytes, '0'); }); + +test('direct IPv4 counters stay metrics-only and keep the last good transport snapshot', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-direct-metrics-')); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const store = createJsonStore({ filePath: path.join(directory, 'devices.json'), defaultValue: {} }); + const observedAt = '2026-08-12T10:00:00.000Z'; + const mac = '00:11:22:33:44:55'; + let fail = false; + let includeDirect = true; + const service = createDeviceInventoryService({ + store, + observe: () => ({ + observedAt, + error: null, + observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }], + }), + observeTraffic: () => { + if (fail) throw new Error('Dataplane direct counters unavailable'); + return { + epoch: 'epoch-a', + generation: 'rules-a', + observedAt, + source: { error: null }, + direct: includeDirect ? { uploadBytes: '56', downloadBytes: '78' } : undefined, + devices: [{ + mac, + uploadBytes: '100', + downloadBytes: '200', + ...(includeDirect ? { directUploadBytes: '12', directDownloadBytes: '34' } : {}), + }], + }; + }, + }); + + await service.refresh(); + assert.deepEqual(service.metricsSnapshot().directTraffic.series, [{ + deviceId: deviceId(mac), + uploadBytes: '12', + downloadBytes: '34', + }]); + assert.equal(service.metricsSnapshot().directTraffic.uploadBytes, '56'); + assert.equal(service.metricsSnapshot().directTraffic.downloadBytes, '78'); + assert.equal(Object.hasOwn(service.snapshot().devices[0], 'directUploadBytes'), false); + assert.doesNotMatch(JSON.stringify(store.read()), /directUploadBytes|directDownloadBytes/); + + fail = true; + await service.refresh(); + assert.deepEqual(service.metricsSnapshot().directTraffic.series, [{ + deviceId: deviceId(mac), + uploadBytes: '12', + downloadBytes: '34', + }]); + assert.equal(service.metricsSnapshot().directTraffic.source.error, 'Dataplane direct counters unavailable'); + + fail = false; + includeDirect = false; + await service.refresh(); + assert.deepEqual(service.metricsSnapshot().directTraffic.series, []); + assert.equal(service.metricsSnapshot().directTraffic.source.error, null); +}); diff --git a/test/server/device-traffic.test.js b/test/server/device-traffic.test.js index 4cdba4b..d659d18 100644 --- a/test/server/device-traffic.test.js +++ b/test/server/device-traffic.test.js @@ -11,6 +11,12 @@ import { const uploadChain = 'VPN_PROXY_TRAFFIC_UP'; const downloadChain = 'VPN_PROXY_TRAFFIC_DOWN'; +const routeAccounting = { + directChain: 'VPN_PROXY_DIRECT', + directMark: '0x40000000', + tproxyMark: '1', + gatewayClientCidrs: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'], +}; const dataplaneSource = fs.readFileSync( path.resolve(import.meta.dirname, '../../src/server/dataplane.ts'), 'utf8', @@ -23,7 +29,7 @@ const observation = (ip, mac = '00:11:22:33:44:55', deviceInterface = 'eth0') => 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, /ready = true;[\s\S]*deviceTrafficAccountingEnabled[\s\S]*setImmediate[\s\S]*traffic\.refresh\(\)/); assert.match(dataplaneSource, /traffic\.refresh\(\)\.catch/); }); @@ -56,6 +62,7 @@ test('traffic rules split local proxy traffic from public Gateway traffic in one bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'], uploadChain, downloadChain, + ...routeAccounting, slot: 'A', proxyPort: 8080, }); @@ -76,7 +83,26 @@ test('traffic rules split local proxy traffic from public Gateway traffic in one < lines.findIndex((line) => line.endsWith(':upload -j RETURN'))); assert.ok(lines.indexOf('-A VPN_PROXY_TRAFFIC_DOWN_A -s 10.0.0.0/8 -j RETURN') < lines.findIndex((line) => line.endsWith(':download -j RETURN'))); + assert.match(restore, /-A VPN_PROXY_DIRECT_A -m addrtype --dst-type LOCAL -j RETURN/); + assert.match(restore, /-A VPN_PROXY_DIRECT_A -m mark --mark 1\/1 -j RETURN/); + assert.match(restore, /-A VPN_PROXY_DIRECT_A -i br-\+ -j RETURN/); + assert.match(restore, /-A VPN_PROXY_DIRECT_A -d 10\.0\.0\.0\/8 -j RETURN/); + assert.match(restore, /-A VPN_PROXY_DIRECT_A -s 192\.168\.0\.0\/16 .*harbor-traffic:global:direct-upload -j CONNMARK --set-xmark 0x40000000\/0x40000000/); + assert.match(restore, /-A VPN_PROXY_TRAFFIC_DOWN_A -d 192\.168\.0\.0\/16 .*harbor-traffic:global:direct-download$/m); + assert.doesNotMatch(restore, /-A VPN_PROXY_TRAFFIC_DOWN_A -m connmark .*harbor-traffic:global:direct-download$/m); + assert.match(restore, /-A VPN_PROXY_DIRECT_A .*--mark 0x40000000\/0x40000000 .*harbor-traffic:[a-f0-9]{16}:direct-upload$/m); + assert.match(restore, /-A VPN_PROXY_TRAFFIC_DOWN_A .*--mark 0x40000000\/0x40000000 .*harbor-traffic:[a-f0-9]{16}:direct-download$/m); assert.doesNotMatch(restore, /TPROXY|DNAT|SNAT|REDIRECT/); + assert.throws(() => buildTrafficRestore({ + devices: [], + bypassCidrs: [], + uploadChain, + downloadChain, + ...routeAccounting, + gatewayClientCidrs: ['10.0.0.0/8', '10.1.0.0/16'], + slot: 'A', + proxyPort: 8080, + }), /traffic accounting/); }); test('counter parser preserves exact uint64 strings and sums TCP plus UDP proxy rules', () => { @@ -86,6 +112,11 @@ test('counter parser preserves exact uint64 strings and sums TCP plus UDP proxy 'VPN_PROXY_TRAFFIC_UP_A_P', ); assert.equal(counters.get('0123456789abcdef:proxy-upload'), '9007199254741002'); + const direct = parseTrafficCounters( + '[5:77] -A VPN_PROXY_TRAFFIC_DOWN_A -m connmark --mark 0x40000000/0x40000000 -m comment --comment "harbor-traffic:0123456789abcdef:direct-download"\n', + 'VPN_PROXY_TRAFFIC_DOWN_A', + ); + assert.equal(direct.get('0123456789abcdef:direct-download'), '77'); }); test('traffic service preserves active rules and snapshot when replacement fails', async () => { @@ -111,11 +142,18 @@ test('traffic service preserves active rules and snapshot when replacement fails return { status: 0, stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n` - + proxyBytes.map((value) => `[1:${value}] -A ${chain}_P -m comment --comment "harbor-traffic:${firstDevice.key}:${proxyDirection}" -j RETURN`).join('\n'), + + proxyBytes.map((value) => `[1:${value}] -A ${chain}_P -m comment --comment "harbor-traffic:${firstDevice.key}:${proxyDirection}" -j RETURN`).join('\n') + + (direction === 'download' + ? `\n[1:300] -A ${routeAccounting.directChain}_A -m comment --comment "harbor-traffic:${firstDevice.key}:direct-upload" -j CONNMARK\n` + + `[1:500] -A ${routeAccounting.directChain}_A -m comment --comment "harbor-traffic:global:direct-upload" -j CONNMARK\n` + + `[1:400] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:direct-download"\n` + + `[1:600] -A ${chain} -m comment --comment "harbor-traffic:global:direct-download"\n` + : ''), stderr: '', }; } - if (failReplacement && args.includes('-R') && args.includes(downloadChain)) { + if (failReplacement && command === 'iptables-restore' + && options.input.includes(`-R ${downloadChain}`)) { return { status: 1, stdout: '', stderr: 'cannot switch download rules' }; } return { status: 0, stdout: '', stderr: '' }; @@ -126,6 +164,7 @@ test('traffic service preserves active rules and snapshot when replacement fails downloadChain, bypassCidrs: ['10.0.0.0/8'], proxyPort: 8080, + ...routeAccounting, run, nextGeneration: () => generations.shift(), }); @@ -133,6 +172,7 @@ test('traffic service preserves active rules and snapshot when replacement fails const first = await service.refresh(); assert.equal(first.epoch, 'boot'); assert.equal(first.generation, 'rules-a'); + assert.deepEqual(first.direct, { uploadBytes: '500', downloadBytes: '600' }); assert.deepEqual(first.devices, [{ ip: '192.168.50.7', mac: '00:11:22:33:44:55', @@ -141,6 +181,8 @@ test('traffic service preserves active rules and snapshot when replacement fails downloadBytes: '3400', proxyUploadBytes: '110', proxyDownloadBytes: '220', + directUploadBytes: '300', + directDownloadBytes: '400', }]); const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length; @@ -162,6 +204,11 @@ test('traffic service preserves active rules and snapshot when replacement fails .filter(([, args]) => args.includes('-R') && args.includes(uploadChain)) .map(([, args]) => args.at(-1)); assert.deepEqual(uploadSwitches, [`${uploadChain}_B`, `${uploadChain}_A`]); + const failedMangleSwitch = calls.find(([command, , options]) => ( + command === 'iptables-restore' && options.input.includes(`-R ${downloadChain}`) + )); + assert.match(failedMangleSwitch[2].input, new RegExp(`-R ${downloadChain} 1 -j ${downloadChain}_B`)); + assert.match(failedMangleSwitch[2].input, new RegExp(`-R ${routeAccounting.directChain} 1 -j ${routeAccounting.directChain}_B`)); failReplacement = false; observed = { @@ -187,6 +234,32 @@ test('traffic service preserves active rules and snapshot when replacement fails assert.ok(calls.every(([, , options]) => options.timeout === 2_000)); }); +test('an initial accounting failure does not publish fresh zero counters', async () => { + const service = createDeviceTrafficService({ + observe: () => ({ + observedAt: '2026-08-07T12:00:00.000Z', + observations: [observation('192.168.50.7')], + error: null, + }), + uploadChain, + downloadChain, + bypassCidrs: [], + proxyPort: 8080, + ...routeAccounting, + run: (command) => ({ + status: command === 'iptables-restore' ? 1 : 0, + stdout: '', + stderr: command === 'iptables-restore' ? 'prepare failed' : '', + }), + }); + + const snapshot = await service.refresh(); + assert.equal(snapshot.observedAt, null); + assert.deepEqual(snapshot.direct, { uploadBytes: '0', downloadBytes: '0' }); + assert.deepEqual(snapshot.devices, []); + assert.match(snapshot.source.error, /prepare failed/); +}); + test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot', async () => { const observations = Array.from({ length: 512 }, (_, index) => observation( `10.${Math.floor(index / 254)}.${Math.floor((index % 254) / 254)}.${(index % 254) + 1}`, @@ -194,9 +267,11 @@ test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot' )); const calls = []; let releaseRestore; + let blockedRestore = false; const run = (command, args, options) => { calls.push([command, args, options]); - if (command === 'iptables-restore') { + if (command === 'iptables-restore' && !blockedRestore) { + blockedRestore = true; return new Promise((resolve) => { releaseRestore = () => resolve({ status: 0, stdout: '', stderr: '' }); }); @@ -210,6 +285,7 @@ test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot' downloadChain, bypassCidrs: [], proxyPort: 8080, + ...routeAccounting, run, nextGeneration: () => generations.shift(), }); @@ -225,8 +301,10 @@ test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot' assert.equal(snapshot.devices.length, 512); assert.deepEqual( calls.map(([command]) => command), - ['iptables-restore', 'iptables', 'iptables', 'iptables-save', 'iptables-save'], + ['iptables-restore', 'iptables', 'iptables-restore', 'iptables-save', 'iptables-save'], ); + assert.match(calls[2][2].input, new RegExp(`-A ${downloadChain} -j ${downloadChain}_A`)); + assert.match(calls[2][2].input, new RegExp(`-A ${routeAccounting.directChain} -j ${routeAccounting.directChain}_A`)); }); test('traffic service finalizes a detached slot once and keeps epoch totals monotonic', async () => { @@ -240,8 +318,14 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono error: null, }; const values = { - A: { upload: '100', download: '200', proxyUpload: '30', proxyDownload: '40' }, - B: { upload: '5', download: '7', proxyUpload: '2', proxyDownload: '3' }, + A: { + upload: '100', download: '200', proxyUpload: '30', proxyDownload: '40', + directUpload: '11', directDownload: '22', globalDirectUpload: '33', globalDirectDownload: '44', + }, + B: { + upload: '5', download: '7', proxyUpload: '2', proxyDownload: '3', + directUpload: '1', directDownload: '2', globalDirectUpload: '3', globalDirectDownload: '4', + }, }; const keys = { A: firstDevice.key, B: secondDevice.key }; let failNextCounterRead = false; @@ -260,6 +344,12 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono stdout: ['A', 'B'].flatMap((slot) => [ `[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`, `[1:${values[slot][proxyDirection]}] -A ${tableChain}_${slot}_P -m comment --comment "harbor-traffic:${keys[slot]}:${proxyKind}" -j RETURN`, + ...(direction === 'download' ? [ + `[1:${values[slot].directUpload}] -A ${routeAccounting.directChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:direct-upload"`, + `[1:${values[slot].directDownload}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:direct-download"`, + `[1:${values[slot].globalDirectUpload}] -A ${routeAccounting.directChain}_${slot} -m comment --comment "harbor-traffic:global:direct-upload"`, + `[1:${values[slot].globalDirectDownload}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:global:direct-download"`, + ] : []), ]).join('\n'), stderr: '', }; @@ -271,6 +361,7 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono downloadChain, bypassCidrs: [], proxyPort: 8080, + ...routeAccounting, run, nextGeneration: () => generations.shift(), }); @@ -278,17 +369,23 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono const first = await service.refresh(); assert.equal(first.epoch, 'epoch-1'); assert.equal(first.generation, 'rules-a'); - assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ - mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, + assert.deepEqual(first.direct, { uploadBytes: '33', downloadBytes: '44' }); + assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes }) => ({ + mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes, })), [{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200', proxyUploadBytes: '30', proxyDownloadBytes: '40', + directUploadBytes: '11', + directDownloadBytes: '22', }]); - values.A = { upload: '130', download: '240', proxyUpload: '35', proxyDownload: '48' }; + values.A = { + upload: '130', download: '240', proxyUpload: '35', proxyDownload: '48', + directUpload: '15', directDownload: '28', globalDirectUpload: '39', globalDirectDownload: '52', + }; observed = { observedAt: '2026-08-07T12:01:00.000Z', observations: [secondObservation], @@ -299,29 +396,35 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono assert.equal(pending.epoch, 'epoch-1'); assert.equal(pending.generation, 'rules-b'); assert.match(pending.source.error, /retired slot read failed/); - assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ - mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, + assert.deepEqual(pending.direct, { uploadBytes: '36', downloadBytes: '48' }); + assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes }) => ({ + mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes, })), [ - { mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200', proxyUploadBytes: '30', proxyDownloadBytes: '40' }, - { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3' }, + { mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200', proxyUploadBytes: '30', proxyDownloadBytes: '40', directUploadBytes: '11', directDownloadBytes: '22' }, + { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3', directUploadBytes: '1', directDownloadBytes: '2' }, ]); const finalized = await service.refresh(); assert.equal(finalized.generation, 'rules-b'); assert.equal(finalized.source.error, null); - assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ - mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, + assert.deepEqual(finalized.direct, { uploadBytes: '42', downloadBytes: '56' }); + assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes }) => ({ + mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes, })), [ - { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' }, - { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3' }, + { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48', directUploadBytes: '15', directDownloadBytes: '28' }, + { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3', directUploadBytes: '1', directDownloadBytes: '2' }, ]); - values.B = { upload: '15', download: '17', proxyUpload: '4', proxyDownload: '6' }; + values.B = { + upload: '15', download: '17', proxyUpload: '4', proxyDownload: '6', + directUpload: '4', directDownload: '6', globalDirectUpload: '8', globalDirectDownload: '10', + }; const polled = await service.refresh(); - assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ - mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, + assert.deepEqual(polled.direct, { uploadBytes: '47', downloadBytes: '62' }); + assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes }) => ({ + mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes, })), [ - { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48' }, - { mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17', proxyUploadBytes: '4', proxyDownloadBytes: '6' }, + { mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240', proxyUploadBytes: '35', proxyDownloadBytes: '48', directUploadBytes: '15', directDownloadBytes: '28' }, + { mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17', proxyUploadBytes: '4', proxyDownloadBytes: '6', directUploadBytes: '4', directDownloadBytes: '6' }, ]); }); diff --git a/test/server/domain-traffic.test.js b/test/server/domain-traffic.test.js index 1fa7354..7c5c810 100644 --- a/test/server/domain-traffic.test.js +++ b/test/server/domain-traffic.test.js @@ -9,11 +9,46 @@ import { deviceId } from '../../dist/server/services/deviceInventoryService.js'; const mac = '00:11:22:33:44:55'; const id = deviceId(mac); const device = { ip: '192.168.50.7', mac }; -const connection = (connectionId, type, host, upload, download, sourceIP = device.ip) => ({ +const connection = (connectionId, type, host, upload, download, sourceIP = device.ip, chains = ['vpn-out']) => ({ id: connectionId, metadata: { type, host, sourceIP }, upload, download, + chains, +}); + +test('sing-box route traffic keeps vpn, direct and unknown deltas separate', async () => { + let response = { connections: [ + connection('direct', 'tproxy/tproxy-in', 'one.example', 10, 100, device.ip, ['direct']), + connection('vpn', 'tproxy/tproxy-in', 'two.example', 20, 200), + connection('unknown', 'tproxy/tproxy-in', 'three.example', 30, 300, device.ip, []), + connection('unmapped', 'tproxy/tproxy-in', 'four.example', 40, 400, '192.168.50.99'), + ] }; + const service = createDomainTrafficService({ + observe: async () => response, + devices: () => [device], + }); + + await service.refresh(); + response = { connections: [ + connection('direct', 'tproxy/tproxy-in', 'one.example', 15, 110, device.ip, ['direct']), + connection('vpn', 'tproxy/tproxy-in', 'two.example', 25, 220, device.ip, ['direct']), + connection('unknown', 'tproxy/tproxy-in', 'three.example', 35, 330, device.ip, [null]), + connection('unmapped', 'tproxy/tproxy-in', 'four.example', 50, 500, '192.168.50.99'), + ] }; + await service.refresh(); + await service.refresh(); + + assert.deepEqual(service.snapshot().routes, [ + { deviceId: id, source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' }, + { deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' }, + { deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: '20', downloadBytes: '200' }, + ]); + assert.deepEqual(service.snapshot().tracked, [ + { source: 'gateway', outbound: 'direct', uploadBytes: '20', downloadBytes: '130' }, + { source: 'gateway', outbound: 'unknown', uploadBytes: '35', downloadBytes: '330' }, + { source: 'gateway', outbound: 'vpn', uploadBytes: '70', downloadBytes: '700' }, + ]); }); test('domain traffic accumulates connection deltas by device, service and source', async () => { @@ -142,6 +177,28 @@ test('domain traffic is bounded and keeps the last good snapshot on source failu assert.equal(service.snapshot().source.error, 'Clash API unavailable'); }); +test('an invalid connection rejects the whole snapshot without double-counting a retry', async () => { + let response = { connections: [ + connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100), + { id: 'invalid', upload: -1, download: 0, metadata: {} }, + ] }; + const service = createDomainTrafficService({ + observe: async () => response, + devices: () => [device], + }); + + await assert.rejects(service.refresh(), /невалидный domain traffic counter/); + assert.deepEqual(service.snapshot().tracked, []); + assert.deepEqual(service.snapshot().routes, []); + assert.deepEqual(service.snapshot().series, []); + + response = { connections: [connection('valid', 'tproxy/tproxy-in', 'one.example', 10, 100)] }; + await service.refresh(); + assert.equal(service.snapshot().tracked[0].downloadBytes, '100'); + assert.equal(service.snapshot().routes[0].downloadBytes, '100'); + assert.equal(service.snapshot().series[0].downloadBytes, '100'); +}); + test('domain classification normalizes known services and rejects IP or malformed labels', () => { assert.deepEqual(classifyDomain('WWW.YouTube.com.'), { domain: 'youtube.com', service: 'YouTube' }); assert.deepEqual(classifyDomain('api.example.org'), { domain: 'api.example.org', service: 'api.example.org' }); diff --git a/test/server/entrypoint-tproxy.test.js b/test/server/entrypoint-tproxy.test.js index 070c704..6272c20 100644 --- a/test/server/entrypoint-tproxy.test.js +++ b/test/server/entrypoint-tproxy.test.js @@ -14,18 +14,25 @@ test('gateway keeps direct forwarding active while TProxy interception is switch assert.match(entrypoint, /-I FORWARD 1 -j "\$GATEWAY_FORWARD_CHAIN"/); assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/); assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -i 'br-\+' -j RETURN/); + assert.match(entrypoint, /-I "\$TPROXY_CHAIN" "\$policy_rule" -j CONNMARK --set-xmark "0x0\/\$DIRECT_TRAFFIC_MARK"/); assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/); 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 PREROUTING 1 -j "\$DIRECT_TRAFFIC_CHAIN"/); assert.match(entrypoint, /-t mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/); assert.match(entrypoint, /-t raw -N "\$\{TRAFFIC_UPLOAD_CHAIN\}_\$\{slot\}_P"/); assert.match(entrypoint, /-t mangle -N "\$\{TRAFFIC_DOWNLOAD_CHAIN\}_\$\{slot\}_P"/); + assert.match(entrypoint, /-t mangle -N "\$\{DIRECT_TRAFFIC_CHAIN\}_\$\{slot\}"/); 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 validate_device_traffic_config; then\s+if ! setup_device_traffic/); assert.match(entrypoint, /if ! setup_device_traffic; then[\s\S]*VPN routing remains active/); - assert.match(entrypoint, /export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN BYPASS_CIDRS/); + assert.match(entrypoint, /cleanup_device_traffic\(\) \{[\s\S]*-D "\$TPROXY_CHAIN" -j CONNMARK/); + assert.match(entrypoint, /DIRECT_TRAFFIC_CHAIN conflicts/); + assert.match(entrypoint, /DIRECT_TRAFFIC_MARK must be one bit outside TPROXY_MARK/); + assert.match(entrypoint, /DEVICE_TRAFFIC_ACCOUNTING_ENABLED=false[\s\S]*setup_device_traffic[\s\S]*DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true/); + assert.match(entrypoint, /export TPROXY_PORT TPROXY_MARK DEVICE_POLICY_CHAIN TRAFFIC_UPLOAD_CHAIN TRAFFIC_DOWNLOAD_CHAIN DIRECT_TRAFFIC_CHAIN DIRECT_TRAFFIC_MARK GATEWAY_CLIENT_CIDRS BYPASS_CIDRS/); assert.match(entrypoint, /ipt_traffic\(\) \{\s+iptables -w 1/); }); diff --git a/test/server/gateway-routing.test.js b/test/server/gateway-routing.test.js index 09a67f3..6b8dd61 100644 --- a/test/server/gateway-routing.test.js +++ b/test/server/gateway-routing.test.js @@ -16,8 +16,8 @@ test('gateway switches only the TProxy PREROUTING jump', () => { ]); calls.length = 0; - const existing = (command, args) => { - calls.push([command, args]); + const existing = (command, args, commandOptions) => { + calls.push([command, args, commandOptions]); return { status: 0, stderr: '' }; }; setGatewayInterception(false, 'VPN_PROXY_TPROXY', existing); @@ -25,4 +25,22 @@ test('gateway switches only the TProxy PREROUTING jump', () => { ['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'], ['-w', '-t', 'mangle', '-D', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'], ]); + + calls.length = 0; + setGatewayInterception(true, 'VPN_PROXY_TPROXY', existing); + assert.deepEqual(calls.map(([command, args]) => [command, args]), [ + ['iptables', ['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY']], + ['iptables-restore', ['-w', '--noflush']], + ]); + assert.match(calls[1][2].input, /-D PREROUTING -j VPN_PROXY_TPROXY\n-I PREROUTING 1 -j VPN_PROXY_TPROXY/); + + assert.doesNotThrow(() => setGatewayInterception(true, 'VPN_PROXY_TPROXY', (_command, args) => ({ + status: args.includes('-C') ? 0 : 1, + stderr: args.includes('-C') ? '' : 'atomic reorder failed', + }))); + + assert.throws( + () => setGatewayInterception(true, 'VPN_PROXY_TPROXY\nCOMMIT', () => assert.fail('iptables must not run')), + /Некорректная TProxy chain/, + ); }); diff --git a/test/server/prometheus-metrics.test.js b/test/server/prometheus-metrics.test.js index 8d0ac42..3a28c64 100644 --- a/test/server/prometheus-metrics.test.js +++ b/test/server/prometheus-metrics.test.js @@ -28,6 +28,16 @@ const snapshot = { proxyDownloadBytes: '0', proxyTrafficObservedAt: null, }], + directTraffic: { + observedAt, + uploadBytes: '99', + downloadBytes: '999', + series: [{ + deviceId: 'dev_0011223344556677', + uploadBytes: '77', + downloadBytes: '888', + }], + }, domainTraffic: { observedAt, overflowConnections: '2', @@ -36,6 +46,19 @@ const snapshot = { unknown_device: '4', unsupported_source: '5', }, + tracked: [{ + source: 'gateway', + outbound: 'vpn', + uploadBytes: '77', + downloadBytes: '777', + }], + routes: [{ + deviceId: 'dev_0011223344556677', + source: 'gateway', + outbound: 'vpn', + uploadBytes: '55', + downloadBytes: '666', + }], series: [{ deviceId: 'dev_0011223344556677', domain: 'chatgpt.com', @@ -58,6 +81,15 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na assert.doesNotMatch(output, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/); assert.doesNotMatch(output, /00:11:22:33:44:55/); assert.match(output, /harbor_device_traffic_last_observed_timestamp_seconds\{device_id="dev_0011223344556677",source="gateway"\} 1786183200/); + assert.match(output, /# TYPE harbor_direct_ipv4_packet_bytes_total counter/); + assert.match(output, /harbor_direct_ipv4_packet_bytes_total\{direction="download"\} 999/); + assert.match(output, /# TYPE harbor_device_direct_ipv4_packet_bytes_total counter/); + assert.match(output, /harbor_device_direct_ipv4_packet_bytes_total\{device_id="dev_0011223344556677",direction="download"\} 888/); + assert.match(output, /harbor_direct_ipv4_packet_last_observed_timestamp_seconds 1786183200/); + assert.match(output, /# TYPE harbor_singbox_tracked_bytes_total counter/); + assert.match(output, /harbor_singbox_tracked_bytes_total\{source="gateway",outbound="vpn",direction="download"\} 777/); + assert.match(output, /# TYPE harbor_device_singbox_tracked_bytes_total counter/); + assert.match(output, /harbor_device_singbox_tracked_bytes_total\{device_id="dev_0011223344556677",source="gateway",outbound="vpn",direction="upload"\} 55/); assert.match(output, /harbor_device_domain_traffic_bytes_total\{device_id="dev_0011223344556677",domain="chatgpt\.com",service="OpenAI \/ ChatGPT",source="proxy",direction="download"\} 345/); assert.match(output, /harbor_domain_traffic_last_observed_timestamp_seconds 1786183200/); assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/); @@ -66,6 +98,7 @@ test('Prometheus exposition keeps exact counters, stable identity and escaped na assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unknown_device"\} 4/); assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/); assert.doesNotMatch(output, /harbor_device_domain_traffic_bytes_total\{[^\n]*name=/); + assert.doesNotMatch(output, /harbor_device_(?:direct_ipv4_packet|singbox_tracked)_bytes_total\{[^\n]*(?:name|ip|mac|server)=/); assert.equal(output.endsWith('\n'), true); }); @@ -99,6 +132,9 @@ test('invalid canonical counters fail the scrape instead of publishing corrupt v () => sendPrometheusMetrics({ writeHead() { throw new Error('headers sent'); } }, invalid), /Invalid Prometheus counter/, ); + const invalidRoute = structuredClone(snapshot); + invalidRoute.domainTraffic.routes[0].outbound = 'vpn-server-tag'; + assert.throws(() => renderPrometheusMetrics(invalidRoute), /Invalid sing-box outbound labels/); }); function routeResponse() { diff --git a/test/version-script.test.js b/test/version-script.test.js index 61b47b1..17fe450 100644 --- a/test/version-script.test.js +++ b/test/version-script.test.js @@ -31,6 +31,7 @@ test('version paths map to the components actually shipped by this repository', assert.deepEqual(affectedComponents(['src/web/App.tsx']), ['macClient', 'gatewayClient']); assert.deepEqual(affectedComponents(['vite.config.ts']), ['macClient', 'gatewayClient']); assert.deepEqual(affectedComponents(['tsconfig.web.json']), ['macClient', 'gatewayClient']); + assert.deepEqual(affectedComponents(['monitoring/grafana/harbor-gateway.json']), ['macClient', 'gatewayClient']); assert.deepEqual(affectedComponents(['src/server/index.ts']), ['macClient', 'gatewayBackend']); assert.deepEqual(affectedComponents(['tsconfig.server.json']), ['macClient', 'gatewayBackend']); assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']); diff --git a/test/web/prometheus-instructions.test.js b/test/web/prometheus-instructions.test.js index 550e385..564211d 100644 --- a/test/web/prometheus-instructions.test.js +++ b/test/web/prometheus-instructions.test.js @@ -43,11 +43,13 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device const domains = dashboard.panels.find(({ id }) => id === 9); const totalTraffic = dashboard.panels.find(({ id }) => id === 1); const trafficBySource = dashboard.panels.find(({ id }) => id === 4); + const singboxOutbounds = dashboard.panels.find(({ id }) => id === 15); + const directPackets = dashboard.panels.find(({ id }) => id === 16); const speedPanels = [globalSpeed, deviceSpeed, sourceSpeed]; assert.equal(dashboard.title, 'Harbor Gateway: трафик'); assert.equal(dashboard.refresh, '30s'); - assert.equal(dashboard.version, 7); + assert.equal(dashboard.version, 8); assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [ 'Обзор', 'Устройства', @@ -63,10 +65,16 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device assert.ok(titles.includes('Домены за выбранный период')); assert.ok(titles.includes('Весь Harbor за выбранный период')); assert.ok(titles.includes('Источники за выбранный период')); + assert.ok(titles.includes('sing-box: VPN / Direct за выбранный период')); + assert.ok(titles.includes('Gateway Direct: IPv4-пакеты за выбранный период')); assert.ok(expressions.some((expression) => expression.includes('harbor_traffic_bytes_total'))); assert.ok(expressions.some((expression) => expression.includes('harbor_device_traffic_bytes_total'))); assert.ok(expressions.some((expression) => expression.includes('harbor_device_domain_traffic_bytes_total{device_id=~"$device_id"}[$__range]'))); assert.ok(expressions.some((expression) => expression.includes('harbor_domain_traffic_last_observed_timestamp_seconds'))); + assert.ok(expressions.some((expression) => expression.includes('harbor_direct_ipv4_packet_last_observed_timestamp_seconds'))); + assert.match(singboxOutbounds.targets[0].expr, /sum by \(outbound, source\).*harbor_singbox_tracked_bytes_total\[\$__range\]/); + assert.match(directPackets.targets[0].expr, /sum by \(direction\).*harbor_direct_ipv4_packet_bytes_total\[\$__range\]/); + assert.match(directPackets.description, /нельзя складывать с sing-box tracker bytes/); assert.match(topDevices.targets[0].expr, /^topk\(10,[\s\S]*increase\(harbor_device_traffic_bytes_total\[\$__range\]\)[\s\S]*> 0[\s\S]*group_left \(name, ip\)/); assert.equal(topDevices.options.sortBy[0].desc, true); assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/); @@ -74,6 +82,7 @@ test('Grafana dashboard uses one all-or-one device scope and shows active device assert.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]); assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных'); assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода'); + assert.equal(freshness.targets.find(({ refId }) => refId === 'C').legendFormat, 'Gateway Direct'); assert.equal(deviceVariable.name, 'device_id'); assert.equal(deviceVariable.multi, false); assert.equal(deviceVariable.includeAll, true);