Improve VPN client connection management
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-12 21:48:37 +03:00
parent 068a7f9890
commit 9e52ccc24d
22 changed files with 962 additions and 76 deletions
+2
View File
@@ -14,6 +14,8 @@ TPROXY_PORT=7895
TPROXY_MARK=1 TPROXY_MARK=1
TPROXY_TABLE=100 TPROXY_TABLE=100
TPROXY_CHAIN=VPN_PROXY_TPROXY TPROXY_CHAIN=VPN_PROXY_TPROXY
DIRECT_TRAFFIC_CHAIN=VPN_PROXY_DIRECT
DIRECT_TRAFFIC_MARK=0x40000000
GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD
GATEWAY_NAT_CHAIN=VPN_PROXY_NAT GATEWAY_NAT_CHAIN=VPN_PROXY_NAT
GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
+7 -2
View File
@@ -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_BIND_IP` | `0.0.0.0` | Адрес, на котором Gateway принимает прокси-подключения |
| `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy | | `PROXY_ALLOWED_CIDRS` | приватные IPv4-сети | Сети, которым разрешён доступ к Gateway Proxy |
| `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать | | `GATEWAY_CLIENT_CIDRS` | приватные IPv4-сети | Сети, трафик которых Gateway может маршрутизировать |
| `DIRECT_TRAFFIC_MARK` | `0x40000000` | Зарезервированный одиночный connmark-бит учёта Direct; измените при конфликте с host QoS/firewall, не пересекаясь с `TPROXY_MARK` |
| `LOG_LEVEL` | `info` | Уровень подробности журнала | | `LOG_LEVEL` | `info` | Уровень подробности журнала |
Остальные значения в `.env.example` относятся к сборке контейнера и внутренней маршрутизации. Меняйте их только при нестандартном развёртывании. Остальные значения в `.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. `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». Готовый dashboard: [`monitoring/grafana/harbor-gateway.json`](monitoring/grafana/harbor-gateway.json). При импорте Grafana попросит выбрать Prometheus data source. Та же конфигурация и dashboard доступны для копирования в Gateway drawer «Как использовать» → «Prometheus и Grafana».
+73 -3
View File
@@ -11,6 +11,8 @@ GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}"
GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}" GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}"
TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}" TRAFFIC_UPLOAD_CHAIN="${TRAFFIC_UPLOAD_CHAIN:-VPN_PROXY_TRAFFIC_UP}"
TRAFFIC_DOWNLOAD_CHAIN="${TRAFFIC_DOWNLOAD_CHAIN:-VPN_PROXY_TRAFFIC_DOWN}" 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}" GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
PROXY_PORT="${PROXY_PORT:-8080}" PROXY_PORT="${PROXY_PORT:-8080}"
PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}"
@@ -18,7 +20,9 @@ PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}"
PROXY_FIREWALL="${PROXY_FIREWALL:-true}" PROXY_FIREWALL="${PROXY_FIREWALL:-true}"
PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}"
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}" BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
export 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() { log() {
printf '[gateway-entrypoint] %s\n' "$*" printf '[gateway-entrypoint] %s\n' "$*"
@@ -28,6 +32,53 @@ if [[ "$APP_COMPONENT" == "control" ]]; then
exec node /app/dist/server/main.js exec node /app/dist/server/main.js
fi 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() { ipt() {
iptables -w "$@" iptables -w "$@"
} }
@@ -80,37 +131,53 @@ cleanup_gateway_forwarding() {
} }
cleanup_device_traffic() { 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 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 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 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 ipt_traffic -t mangle -F "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
for slot in A B; do 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}" 2>/dev/null || true
ipt_traffic -t raw -F "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" 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}_P" 2>/dev/null || true
ipt_traffic -t raw -X "${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 "${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}" 2>/dev/null || true
ipt_traffic -t mangle -F "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" 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}_P" 2>/dev/null || true
ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true ipt_traffic -t mangle -X "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}" 2>/dev/null || true
done done
ipt_traffic -t raw -X "$TRAFFIC_UPLOAD_CHAIN" 2>/dev/null || true 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 ipt_traffic -t mangle -X "$TRAFFIC_DOWNLOAD_CHAIN" 2>/dev/null || true
} }
setup_device_traffic() { setup_device_traffic() {
log "setup raw device traffic counters" log "setup device traffic counters"
cleanup_device_traffic cleanup_device_traffic
ipt_traffic -t raw -N "$TRAFFIC_UPLOAD_CHAIN" || return 1 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 ipt_traffic -t mangle -N "$TRAFFIC_DOWNLOAD_CHAIN" || return 1
for slot in A B; do 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}" || return 1
ipt_traffic -t raw -N "${TRAFFIC_UPLOAD_CHAIN}_${slot}_P" || 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}" || return 1
ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1 ipt_traffic -t mangle -N "${TRAFFIC_DOWNLOAD_CHAIN}_${slot}_P" || return 1
done done
ipt_traffic -t raw -I PREROUTING 1 -j "$TRAFFIC_UPLOAD_CHAIN" || return 1 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 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() { enable_ip_forwarding() {
@@ -172,7 +239,6 @@ setup_tproxy() {
for cidr in $BYPASS_CIDRS; do for cidr in $BYPASS_CIDRS; do
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
done done
if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then if ipt -t mangle -L "$DEVICE_POLICY_CHAIN" -n >/dev/null 2>&1; then
ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN" ipt -t mangle -A "$TPROXY_CHAIN" -j "$DEVICE_POLICY_CHAIN"
else else
@@ -183,9 +249,13 @@ setup_tproxy() {
setup_gateway_forwarding setup_gateway_forwarding
setup_tproxy setup_tproxy
if validate_device_traffic_config; then
if ! setup_device_traffic; then if ! setup_device_traffic; then
log "device traffic counters unavailable; VPN routing remains active" log "device traffic counters unavailable; VPN routing remains active"
cleanup_device_traffic cleanup_device_traffic
else
export DEVICE_TRAFFIC_ACCOUNTING_ENABLED=true
fi
fi fi
setup_proxy_firewall setup_proxy_firewall
+119 -1
View File
@@ -104,6 +104,14 @@
"legendFormat": "Домены", "legendFormat": "Домены",
"range": false, "range": false,
"refId": "B" "refId": "B"
},
{
"editorMode": "code",
"expr": "time() - harbor_direct_ipv4_packet_last_observed_timestamp_seconds",
"instant": true,
"legendFormat": "Gateway Direct",
"range": false,
"refId": "C"
} }
], ],
"title": "Свежесть данных в конце выбранного периода", "title": "Свежесть данных в конце выбранного периода",
@@ -897,6 +905,116 @@
], ],
"title": "Источники за выбранный период", "title": "Источники за выбранный период",
"type": "bargauge" "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", "refresh": "30s",
@@ -956,6 +1074,6 @@
"timezone": "browser", "timezone": "browser",
"title": "Harbor Gateway: трафик", "title": "Harbor Gateway: трафик",
"uid": "harbor-gateway-traffic", "uid": "harbor-gateway-traffic",
"version": 7, "version": 8,
"weekStart": "monday" "weekStart": "monday"
} }
+1 -1
View File
@@ -53,7 +53,7 @@ export function affectedComponents(files) {
for (const file of files) { for (const file of files) {
if (file === VERSION_FILE) continue; if (file === VERSION_FILE) continue;
if (/^(?:\.dockerignore$|package(?:-lock)?\.json$|tsconfig\.base\.json$|src\/shared\/)/.test(file)) add(...COMPONENTS); 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'); add('macClient', 'gatewayClient');
} else if (/^(src\/server\/|tsconfig\.server\.json$)/.test(file)) add('macClient', 'gatewayBackend'); } 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)) { else if (/^(install\.sh|Dockerfile\.client|docker-compose\.client(\.local)?\.yml|entrypoint\.client\.sh|scripts\/(install-macos-client|harbor-network-monitor)\.sh)$/.test(file)) {
+6
View File
@@ -22,6 +22,12 @@ export const settings = {
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY", devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP", trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN", trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
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 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") || "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), .trim().split(/\s+/).filter(Boolean),
+6
View File
@@ -25,6 +25,10 @@ const traffic = createDeviceTrafficService({
observe: () => readNeighborSnapshot(), observe: () => readNeighborSnapshot(),
uploadChain: settings.trafficUploadChain, uploadChain: settings.trafficUploadChain,
downloadChain: settings.trafficDownloadChain, downloadChain: settings.trafficDownloadChain,
directChain: settings.directTrafficChain,
directMark: settings.directTrafficMark,
tproxyMark: settings.tproxyMark,
gatewayClientCidrs: settings.gatewayClientCidrs,
bypassCidrs: settings.bypassCidrs, bypassCidrs: settings.bypassCidrs,
proxyPort: settings.proxyPort, proxyPort: settings.proxyPort,
}); });
@@ -147,6 +151,7 @@ server.listen(socketPath, async () => {
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`); console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
} finally { } finally {
ready = true; ready = true;
if (settings.deviceTrafficAccountingEnabled) {
setImmediate(() => { setImmediate(() => {
traffic.refresh() traffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
@@ -155,6 +160,7 @@ server.listen(socketPath, async () => {
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`)); traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
}, 15_000); }, 15_000);
trafficTimer.unref(); trafficTimer.unref();
}
setImmediate(() => { setImmediate(() => {
domainTraffic.refresh() domainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`)); .catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
+11 -1
View File
@@ -1,8 +1,10 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
const options = { encoding: 'utf8' as const }; 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) { 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 rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0; 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); if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
return; 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( const result = run(
'iptables', 'iptables',
+86
View File
@@ -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 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) : []; const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
if (domainSeries.length) { if (domainSeries.length) {
lines.push( lines.push(
@@ -143,6 +224,11 @@ export function renderPrometheusMetrics(value: unknown) {
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge', '# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
); );
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`); 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) { if (domainTraffic.overflowConnections != null) {
lines.push( lines.push(
+62 -1
View File
@@ -704,6 +704,14 @@ export function createDeviceInventoryService({
}, },
series: [], series: [],
}; };
let directTrafficSnapshot: Record<string, unknown> = {
epoch: null,
observedAt: null,
source: { error: null },
uploadBytes: '0',
downloadBytes: '0',
series: [],
};
function captureTrafficHistory(state: InventoryState) { function captureTrafficHistory(state: InventoryState) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac)); const knownMacs = new Set(state.devices.map(({ mac }) => mac));
@@ -877,7 +885,7 @@ export function createDeviceInventoryService({
} }
function metricsSnapshot() { function metricsSnapshot() {
return { ...snapshot(), domainTraffic: domainTrafficSnapshot }; return { ...snapshot(), domainTraffic: domainTrafficSnapshot, directTraffic: directTrafficSnapshot };
} }
function markPolicyEpoch(observed: unknown) { function markPolicyEpoch(observed: unknown) {
@@ -1064,6 +1072,59 @@ export function createDeviceInventoryService({
} else if (domainTrafficResult) { } else if (domainTrafficResult) {
domainTrafficSnapshot = record(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 nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored); const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device])); const byMac = new Map(state.devices.map((device) => [device.mac, device]));
+134 -14
View File
@@ -24,9 +24,9 @@ interface TrafficDevice {
key: string; key: string;
} }
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download'; type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download' | 'direct-upload' | 'direct-download';
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload'; type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload' | 'directUpload' | 'directDownload';
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes'; type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes' | 'directUploadBytes' | 'directDownloadBytes';
type CounterValues = Record<CounterField, bigint>; type CounterValues = Record<CounterField, bigint>;
interface RetiredCounters { interface RetiredCounters {
@@ -40,6 +40,7 @@ interface TrafficSnapshot {
generation: string; generation: string;
observedAt: string | null; observedAt: string | null;
source: { error: string | null }; source: { error: string | null };
direct: { uploadBytes: string; downloadBytes: string };
devices: Record<string, unknown>[]; devices: Record<string, unknown>[];
} }
@@ -53,6 +54,8 @@ const COUNTERS = [
['download', 'download', 'downloadBytes'], ['download', 'download', 'downloadBytes'],
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'], ['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'], ['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
['direct-upload', 'directUpload', 'directUploadBytes'],
['direct-download', 'directDownload', 'directDownloadBytes'],
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[]; ] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
const childChain = (chain: string, slot: string) => `${chain}_${slot}`; const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
@@ -117,7 +120,37 @@ function isIpv4Cidr(value: unknown) {
&& Number.isInteger(size) && size >= 0 && size <= 32; && 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<string, unknown> { function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
@@ -160,6 +193,10 @@ export function buildTrafficRestore({
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot, slot,
proxyPort, proxyPort,
}: { }: {
@@ -167,17 +204,28 @@ export function buildTrafficRestore({
bypassCidrs: readonly string[]; bypassCidrs: readonly string[];
uploadChain: string; uploadChain: string;
downloadChain: string; downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: readonly string[];
slot: string; slot: string;
proxyPort: number; 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) || !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|| proxyPort < 1 || proxyPort > 65_535 || 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'); throw new Error('Некорректная конфигурация traffic accounting');
} }
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const proxyUploadChild = proxyChildChain(uploadChain, slot); const proxyUploadChild = proxyChildChain(uploadChain, slot);
const proxyDownloadChild = proxyChildChain(downloadChain, slot); const proxyDownloadChild = proxyChildChain(downloadChain, slot);
const raw = [ const raw = [
@@ -192,8 +240,12 @@ export function buildTrafficRestore({
]; ];
const mangle = [ const mangle = [
'*mangle', '*mangle',
`-F ${directChild}`,
`-F ${downloadChild}`, `-F ${downloadChild}`,
`-F ${proxyDownloadChild}`, `-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 ${proxyDownloadChild}`,
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`, `-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}`, `-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) { for (const cidr of bypassCidrs) {
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`); raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${directChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${downloadChild} -s ${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) { 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`); 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`); 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<string, string> { export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linePattern = new RegExp( 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<string, string>(); const counters = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) { for (const line of String(text || '').split(/\r?\n/)) {
@@ -236,6 +295,10 @@ export function createDeviceTrafficService({
observe, observe,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
bypassCidrs, bypassCidrs,
proxyPort, proxyPort,
run = runCommand, run = runCommand,
@@ -244,6 +307,10 @@ export function createDeviceTrafficService({
observe: () => Promise<unknown> | unknown; observe: () => Promise<unknown> | unknown;
uploadChain: string; uploadChain: string;
downloadChain: string; downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: string[];
bypassCidrs: string[]; bypassCidrs: string[];
proxyPort: number; proxyPort: number;
run?: RunCommand; run?: RunCommand;
@@ -257,12 +324,14 @@ export function createDeviceTrafficService({
let pendingRetired: RetiredCounters | null = null; let pendingRetired: RetiredCounters | null = null;
let refreshPromise: Promise<TrafficSnapshot> | null = null; let refreshPromise: Promise<TrafficSnapshot> | null = null;
const finalized = new Map<string, CounterValues>(); const finalized = new Map<string, CounterValues>();
const finalizedDirect = { upload: 0n, download: 0n };
const devicesByKey = new Map<string, TrafficDevice>(); const devicesByKey = new Map<string, TrafficDevice>();
let current: TrafficSnapshot = { let current: TrafficSnapshot = {
epoch, epoch,
generation: epoch, generation: epoch,
observedAt: null, observedAt: null,
source: { error: null }, source: { error: null },
direct: { uploadBytes: '0', downloadBytes: '0' },
devices: [], devices: [],
}; };
@@ -278,6 +347,10 @@ export function createDeviceTrafficService({
bypassCidrs, bypassCidrs,
uploadChain, uploadChain,
downloadChain, downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot, slot,
proxyPort, proxyPort,
}); });
@@ -287,22 +360,37 @@ export function createDeviceTrafficService({
async function switchTo(slot: 'A' | 'B') { async function switchTo(slot: 'A' | 'B') {
const uploadChild = childChain(uploadChain, slot); const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot); const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const replace = activeSlot ? '-R' : '-A'; const replace = activeSlot ? '-R' : '-A';
const uploadArgs = activeSlot const uploadArgs = activeSlot
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild] ? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild]; : ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
const downloadArgs = activeSlot const mangleInput = [
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild] '*mangle',
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild]; 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); await execute('iptables', uploadArgs);
try { try {
await execute('iptables', downloadArgs); await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input: mangleInput });
} catch (error) { } catch (error) {
const rollbackArgs = activeSlot const uploadRollback = activeSlot
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)] ? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
: ['-w', '1', '-t', 'raw', '-F', uploadChain]; : ['-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; throw error;
} }
} }
@@ -318,6 +406,8 @@ export function createDeviceTrafficService({
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)), download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)), proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)), proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
directUpload: parseTrafficCounters(mangle, childChain(directChain, slot)),
directDownload: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
}; };
const counters = new Map<string, string>(); const counters = new Map<string, string>();
for (const { key } of devices) { for (const { key } of devices) {
@@ -325,6 +415,8 @@ export function createDeviceTrafficService({
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0'); 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; return counters;
} }
@@ -345,6 +437,8 @@ export function createDeviceTrafficService({
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind); for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
finalized.set(key, next); finalized.set(key, next);
} }
finalizedDirect.upload += counter(counters, 'global', 'direct-upload');
finalizedDirect.download += counter(counters, 'global', 'direct-download');
pendingRetired = null; pendingRetired = null;
return true; return true;
} }
@@ -368,18 +462,41 @@ export function createDeviceTrafficService({
} }
return [...totalsByMac.values()] return [...totalsByMac.values()]
.map((total) => { .map((total) => {
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total; const {
key: _key,
upload,
download,
proxyUpload,
proxyDownload,
directUpload,
directDownload,
...device
} = total;
return { return {
...device, ...device,
uploadBytes: upload.toString(), uploadBytes: upload.toString(),
downloadBytes: download.toString(), downloadBytes: download.toString(),
proxyUploadBytes: proxyUpload.toString(), proxyUploadBytes: proxyUpload.toString(),
proxyDownloadBytes: proxyDownload.toString(), proxyDownloadBytes: proxyDownload.toString(),
directUploadBytes: directUpload.toString(),
directDownloadBytes: directDownload.toString(),
}; };
}) })
.sort((left, right) => left.mac.localeCompare(right.mac)); .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() { async function performRefresh() {
let observed: Record<string, unknown>; let observed: Record<string, unknown>;
try { try {
@@ -426,12 +543,14 @@ export function createDeviceTrafficService({
} }
} }
if (activeSlot) {
try { try {
activeCounters = await readCounters(activeDevices, activeSlot); activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true; countersRead = true;
} catch (error) { } catch (error) {
sourceError = sourceError || (error instanceof Error ? error.message : String(error)); sourceError = sourceError || (error instanceof Error ? error.message : String(error));
} }
}
current = { current = {
epoch, epoch,
generation: current.generation, generation: current.generation,
@@ -439,6 +558,7 @@ export function createDeviceTrafficService({
? observed.observedAt ? observed.observedAt
: current.observedAt, : current.observedAt,
source: { error: sourceError }, source: { error: sourceError },
direct: countersRead ? directTotals() : current.direct,
devices: countersRead ? processTotals() : current.devices, devices: countersRead ? processTotals() : current.devices,
}; };
return structuredClone(current); return structuredClone(current);
+99 -4
View File
@@ -9,6 +9,7 @@ const DEFAULT_MAX_SERIES = 4096;
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' }; const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const; const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number]; type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
type TrafficRoute = 'vpn' | 'direct' | 'unknown';
const SERVICE_DOMAINS = [ const SERVICE_DOMAINS = [
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']], ['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']], ['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
@@ -21,13 +22,19 @@ interface ParsedBaseConnection {
} }
type ParsedConnection = type ParsedConnection =
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' }) | (ParsedBaseConnection & { outcome: 'unsupported_source' })
| (ParsedBaseConnection & {
outcome: 'unknown_device';
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
})
| (ParsedBaseConnection & { | (ParsedBaseConnection & {
outcome: 'classified' | 'unresolved_host'; outcome: 'classified' | 'unresolved_host';
deviceId: string; deviceId: string;
domain: string; domain: string;
service: string; service: string;
source: 'gateway' | 'proxy'; source: 'gateway' | 'proxy';
outbound: TrafficRoute;
}); });
interface PreviousConnection { interface PreviousConnection {
@@ -36,6 +43,8 @@ interface PreviousConnection {
requestedKey?: string; requestedKey?: string;
countedUpload: bigint | null; countedUpload: bigint | null;
countedDownload: bigint | null; countedDownload: bigint | null;
trackedUpload: bigint | null;
trackedDownload: bigint | null;
} }
interface DomainSeriesTotal { interface DomainSeriesTotal {
@@ -47,12 +56,28 @@ interface DomainSeriesTotal {
downloadBytes: bigint; downloadBytes: bigint;
} }
interface RouteSeriesTotal {
deviceId?: string;
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
uploadBytes: bigint;
downloadBytes: bigint;
}
interface DomainTrafficSnapshot { interface DomainTrafficSnapshot {
epoch: string; epoch: string;
observedAt: string | null; observedAt: string | null;
source: { error: string | null }; source: { error: string | null };
overflowConnections: string; overflowConnections: string;
attributionEvents: Record<AttributionOutcome, string>; attributionEvents: Record<AttributionOutcome, string>;
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
}>;
routes: Array<Omit<RouteSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
}>;
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & { series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string; uploadBytes: string;
downloadBytes: string; downloadBytes: string;
@@ -86,6 +111,12 @@ function sourceFor(type: string): 'gateway' | 'proxy' | null {
return 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<string, string | null>): ParsedConnection { function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
const connection = record(value); const connection = record(value);
const id = String(connection.id || ''); const id = String(connection.id || '');
@@ -103,8 +134,9 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
}; };
const source = sourceFor(String(metadata.type || '')); const source = sourceFor(String(metadata.type || ''));
if (!source) return { ...parsed, outcome: 'unsupported_source' }; if (!source) return { ...parsed, outcome: 'unsupported_source' };
const outbound = routeFor(connection.chains);
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || '')); 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 classifiedDomain = classifyDomain(metadata.host);
const domain = classifiedDomain || UNKNOWN_DOMAIN; const domain = classifiedDomain || UNKNOWN_DOMAIN;
return { return {
@@ -113,6 +145,7 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
deviceId: currentDeviceId, deviceId: currentDeviceId,
...domain, ...domain,
source, source,
outbound,
}; };
} }
@@ -164,6 +197,8 @@ export function createDomainTrafficService({
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2'); if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
const epoch = crypto.randomUUID(); const epoch = crypto.randomUUID();
const totals = new Map<string, DomainSeriesTotal>(); const totals = new Map<string, DomainSeriesTotal>();
const routeTotals = new Map<string, RouteSeriesTotal>();
const trackedTotals = new Map<string, RouteSeriesTotal>();
const normalSeriesLimit = maxSeries - 2; const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0; let normalSeries = 0;
let previousConnections = new Map<string, PreviousConnection>(); let previousConnections = new Map<string, PreviousConnection>();
@@ -180,6 +215,8 @@ export function createDomainTrafficService({
source: { error: null }, source: { error: null },
overflowConnections: '0', overflowConnections: '0',
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' }, attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
tracked: [],
routes: [],
series: [], series: [],
}; };
@@ -192,6 +229,27 @@ export function createDomainTrafficService({
attributionEvents: Object.fromEntries( attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]), ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
) as Record<AttributionOutcome, string>, ) as Record<AttributionOutcome, string>,
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()] series: [...totals.values()]
.map((entry) => ({ .map((entry) => ({
...entry, ...entry,
@@ -220,18 +278,42 @@ export function createDomainTrafficService({
if (!net.isIPv4(ip) || !id) continue; if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id); devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
} }
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
const activeConnections = new Map<string, PreviousConnection>(); const activeConnections = new Map<string, PreviousConnection>();
for (const rawConnection of response.connections) { for (const connection of connections) {
const connection = parseConnection(rawConnection, devicesByIp);
const previous = previousConnections.get(connection.id); const previous = previousConnections.get(connection.id);
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) { if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n; 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') { if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
activeConnections.set(connection.id, { activeConnections.set(connection.id, {
outcome: connection.outcome, outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null, countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? 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; continue;
} }
@@ -257,6 +339,17 @@ export function createDomainTrafficService({
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload ? connection.download - previous.countedDownload
: connection.download; : 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) || { const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other', deviceId: key === requestedKey ? connection.deviceId : '_other',
domain, domain,
@@ -274,6 +367,8 @@ export function createDomainTrafficService({
requestedKey, requestedKey,
countedUpload: connection.upload, countedUpload: connection.upload,
countedDownload: connection.download, countedDownload: connection.download,
trackedUpload: connection.upload,
trackedDownload: connection.download,
}); });
} }
previousConnections = activeConnections; previousConnections = activeConnections;
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.1', macClient: '0.25.2',
gatewayClient: '0.26.0', gatewayClient: '0.26.1',
gatewayBackend: '0.26.1', gatewayBackend: '0.26.2',
}); });
export interface ParsedVersion { export interface ParsedVersion {
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) }, { id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson }, { 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.',
}] : []), }] : []),
]; ];
} }
+76
View File
@@ -1018,11 +1018,87 @@ test('an old dataplane without domain traffic keeps inventory refresh and existi
const service = createDeviceInventoryService({ const service = createDeviceInventoryService({
store, store,
observe: () => ({ observedAt: '2026-08-08T10:00:00.000Z', observations: [], error: null }), 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'); }, observeDomainTraffic: () => { throw new Error('Dataplane HTTP 404'); },
}); });
const snapshot = await service.refresh(); const snapshot = await service.refresh();
assert.equal(snapshot.devices.length, 0); assert.equal(snapshot.devices.length, 0);
assert.equal(service.metricsSnapshot().domainTraffic.source.error, 'Dataplane HTTP 404'); 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'); 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);
});
+126 -23
View File
@@ -11,6 +11,12 @@ import {
const uploadChain = 'VPN_PROXY_TRAFFIC_UP'; const uploadChain = 'VPN_PROXY_TRAFFIC_UP';
const downloadChain = 'VPN_PROXY_TRAFFIC_DOWN'; 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( const dataplaneSource = fs.readFileSync(
path.resolve(import.meta.dirname, '../../src/server/dataplane.ts'), path.resolve(import.meta.dirname, '../../src/server/dataplane.ts'),
'utf8', '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', () => { 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, /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/); 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'], bypassCidrs: ['10.0.0.0/8', '192.168.0.0/16'],
uploadChain, uploadChain,
downloadChain, downloadChain,
...routeAccounting,
slot: 'A', slot: 'A',
proxyPort: 8080, 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'))); < 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') 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'))); < 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.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', () => { 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', 'VPN_PROXY_TRAFFIC_UP_A_P',
); );
assert.equal(counters.get('0123456789abcdef:proxy-upload'), '9007199254741002'); 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 () => { 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 { return {
status: 0, status: 0,
stdout: `[1:${bytes}] -A ${chain} -m comment --comment "harbor-traffic:${firstDevice.key}:${direction}" -j RETURN\n` 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: '', 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: 1, stdout: '', stderr: 'cannot switch download rules' };
} }
return { status: 0, stdout: '', stderr: '' }; return { status: 0, stdout: '', stderr: '' };
@@ -126,6 +164,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
downloadChain, downloadChain,
bypassCidrs: ['10.0.0.0/8'], bypassCidrs: ['10.0.0.0/8'],
proxyPort: 8080, proxyPort: 8080,
...routeAccounting,
run, run,
nextGeneration: () => generations.shift(), nextGeneration: () => generations.shift(),
}); });
@@ -133,6 +172,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
const first = await service.refresh(); const first = await service.refresh();
assert.equal(first.epoch, 'boot'); assert.equal(first.epoch, 'boot');
assert.equal(first.generation, 'rules-a'); assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.direct, { uploadBytes: '500', downloadBytes: '600' });
assert.deepEqual(first.devices, [{ assert.deepEqual(first.devices, [{
ip: '192.168.50.7', ip: '192.168.50.7',
mac: '00:11:22:33:44:55', mac: '00:11:22:33:44:55',
@@ -141,6 +181,8 @@ test('traffic service preserves active rules and snapshot when replacement fails
downloadBytes: '3400', downloadBytes: '3400',
proxyUploadBytes: '110', proxyUploadBytes: '110',
proxyDownloadBytes: '220', proxyDownloadBytes: '220',
directUploadBytes: '300',
directDownloadBytes: '400',
}]); }]);
const switchCallsBefore = calls.filter(([, args]) => args.includes('-R') || args.includes('-A')).length; 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)) .filter(([, args]) => args.includes('-R') && args.includes(uploadChain))
.map(([, args]) => args.at(-1)); .map(([, args]) => args.at(-1));
assert.deepEqual(uploadSwitches, [`${uploadChain}_B`, `${uploadChain}_A`]); 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; failReplacement = false;
observed = { 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)); 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 () => { test('a 512-device refresh keeps a fixed subprocess count and a cached snapshot', async () => {
const observations = Array.from({ length: 512 }, (_, index) => observation( const observations = Array.from({ length: 512 }, (_, index) => observation(
`10.${Math.floor(index / 254)}.${Math.floor((index % 254) / 254)}.${(index % 254) + 1}`, `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 = []; const calls = [];
let releaseRestore; let releaseRestore;
let blockedRestore = false;
const run = (command, args, options) => { const run = (command, args, options) => {
calls.push([command, args, options]); calls.push([command, args, options]);
if (command === 'iptables-restore') { if (command === 'iptables-restore' && !blockedRestore) {
blockedRestore = true;
return new Promise((resolve) => { return new Promise((resolve) => {
releaseRestore = () => resolve({ status: 0, stdout: '', stderr: '' }); 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, downloadChain,
bypassCidrs: [], bypassCidrs: [],
proxyPort: 8080, proxyPort: 8080,
...routeAccounting,
run, run,
nextGeneration: () => generations.shift(), 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.equal(snapshot.devices.length, 512);
assert.deepEqual( assert.deepEqual(
calls.map(([command]) => command), 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 () => { 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, error: null,
}; };
const values = { const values = {
A: { upload: '100', download: '200', proxyUpload: '30', proxyDownload: '40' }, A: {
B: { upload: '5', download: '7', proxyUpload: '2', proxyDownload: '3' }, 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 }; const keys = { A: firstDevice.key, B: secondDevice.key };
let failNextCounterRead = false; 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) => [ 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][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`, `[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'), ]).join('\n'),
stderr: '', stderr: '',
}; };
@@ -271,6 +361,7 @@ test('traffic service finalizes a detached slot once and keeps epoch totals mono
downloadChain, downloadChain,
bypassCidrs: [], bypassCidrs: [],
proxyPort: 8080, proxyPort: 8080,
...routeAccounting,
run, run,
nextGeneration: () => generations.shift(), 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(); const first = await service.refresh();
assert.equal(first.epoch, 'epoch-1'); assert.equal(first.epoch, 'epoch-1');
assert.equal(first.generation, 'rules-a'); assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ assert.deepEqual(first.direct, { uploadBytes: '33', downloadBytes: '44' });
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes }) => ({
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, directUploadBytes, directDownloadBytes,
})), [{ })), [{
mac: firstObservation.mac, mac: firstObservation.mac,
uploadBytes: '100', uploadBytes: '100',
downloadBytes: '200', downloadBytes: '200',
proxyUploadBytes: '30', proxyUploadBytes: '30',
proxyDownloadBytes: '40', 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 = { observed = {
observedAt: '2026-08-07T12:01:00.000Z', observedAt: '2026-08-07T12:01:00.000Z',
observations: [secondObservation], 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.epoch, 'epoch-1');
assert.equal(pending.generation, 'rules-b'); assert.equal(pending.generation, 'rules-b');
assert.match(pending.source.error, /retired slot read failed/); assert.match(pending.source.error, /retired slot read failed/);
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ assert.deepEqual(pending.direct, { uploadBytes: '36', downloadBytes: '48' });
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, 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: 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' }, { mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7', proxyUploadBytes: '2', proxyDownloadBytes: '3', directUploadBytes: '1', directDownloadBytes: '2' },
]); ]);
const finalized = await service.refresh(); const finalized = await service.refresh();
assert.equal(finalized.generation, 'rules-b'); assert.equal(finalized.generation, 'rules-b');
assert.equal(finalized.source.error, null); assert.equal(finalized.source.error, null);
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ assert.deepEqual(finalized.direct, { uploadBytes: '42', downloadBytes: '56' });
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, 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: 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' }, { 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(); const polled = await service.refresh();
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes }) => ({ assert.deepEqual(polled.direct, { uploadBytes: '47', downloadBytes: '62' });
mac, uploadBytes, downloadBytes, proxyUploadBytes, proxyDownloadBytes, 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: 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' }, { mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17', proxyUploadBytes: '4', proxyDownloadBytes: '6', directUploadBytes: '4', directDownloadBytes: '6' },
]); ]);
}); });
+58 -1
View File
@@ -9,11 +9,46 @@ import { deviceId } from '../../dist/server/services/deviceInventoryService.js';
const mac = '00:11:22:33:44:55'; const mac = '00:11:22:33:44:55';
const id = deviceId(mac); const id = deviceId(mac);
const device = { ip: '192.168.50.7', 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, id: connectionId,
metadata: { type, host, sourceIP }, metadata: { type, host, sourceIP },
upload, upload,
download, 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 () => { 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'); 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', () => { 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('WWW.YouTube.com.'), { domain: 'youtube.com', service: 'YouTube' });
assert.deepEqual(classifyDomain('api.example.org'), { domain: 'api.example.org', service: 'api.example.org' }); assert.deepEqual(classifyDomain('api.example.org'), { domain: 'api.example.org', service: 'api.example.org' });
+9 -2
View File
@@ -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 FORWARD 1 -j "\$GATEWAY_FORWARD_CHAIN"/);
assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/); assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/);
assert.match(entrypoint, /-A "\$TPROXY_CHAIN" -i 'br-\+' -j RETURN/); 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, /-A PREROUTING -j "\$TPROXY_CHAIN"/);
assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/); assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/);
assert.match(entrypoint, /-t raw -I PREROUTING 1 -j "\$TRAFFIC_UPLOAD_CHAIN"/); assert.match(entrypoint, /-t 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 mangle -I POSTROUTING 1 -j "\$TRAFFIC_DOWNLOAD_CHAIN"/);
assert.match(entrypoint, /-t raw -N "\$\{TRAFFIC_UPLOAD_CHAIN\}_\$\{slot\}_P"/); 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 "\$\{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 "\$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 tcp -j TPROXY/);
assert.match(entrypoint, /-A "\$\{DEVICE_POLICY_CHAIN\}_A" -p udp -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, /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/); assert.match(entrypoint, /ipt_traffic\(\) \{\s+iptables -w 1/);
}); });
+20 -2
View File
@@ -16,8 +16,8 @@ test('gateway switches only the TProxy PREROUTING jump', () => {
]); ]);
calls.length = 0; calls.length = 0;
const existing = (command, args) => { const existing = (command, args, commandOptions) => {
calls.push([command, args]); calls.push([command, args, commandOptions]);
return { status: 0, stderr: '' }; return { status: 0, stderr: '' };
}; };
setGatewayInterception(false, 'VPN_PROXY_TPROXY', existing); 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', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'],
['-w', '-t', 'mangle', '-D', '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/,
);
}); });
+36
View File
@@ -28,6 +28,16 @@ const snapshot = {
proxyDownloadBytes: '0', proxyDownloadBytes: '0',
proxyTrafficObservedAt: null, proxyTrafficObservedAt: null,
}], }],
directTraffic: {
observedAt,
uploadBytes: '99',
downloadBytes: '999',
series: [{
deviceId: 'dev_0011223344556677',
uploadBytes: '77',
downloadBytes: '888',
}],
},
domainTraffic: { domainTraffic: {
observedAt, observedAt,
overflowConnections: '2', overflowConnections: '2',
@@ -36,6 +46,19 @@ const snapshot = {
unknown_device: '4', unknown_device: '4',
unsupported_source: '5', 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: [{ series: [{
deviceId: 'dev_0011223344556677', deviceId: 'dev_0011223344556677',
domain: 'chatgpt.com', 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, /harbor_device_traffic_bytes_total\{[^\n]*source="proxy"/);
assert.doesNotMatch(output, /00:11:22:33:44:55/); 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, /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_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_last_observed_timestamp_seconds 1786183200/);
assert.match(output, /harbor_domain_traffic_overflow_connections_total 2/); 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="unknown_device"\} 4/);
assert.match(output, /harbor_domain_traffic_attribution_events_total\{outcome="unsupported_source"\} 5/); 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_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); 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), () => sendPrometheusMetrics({ writeHead() { throw new Error('headers sent'); } }, invalid),
/Invalid Prometheus counter/, /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() { function routeResponse() {
+1
View File
@@ -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(['src/web/App.tsx']), ['macClient', 'gatewayClient']);
assert.deepEqual(affectedComponents(['vite.config.ts']), ['macClient', 'gatewayClient']); assert.deepEqual(affectedComponents(['vite.config.ts']), ['macClient', 'gatewayClient']);
assert.deepEqual(affectedComponents(['tsconfig.web.json']), ['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(['src/server/index.ts']), ['macClient', 'gatewayBackend']);
assert.deepEqual(affectedComponents(['tsconfig.server.json']), ['macClient', 'gatewayBackend']); assert.deepEqual(affectedComponents(['tsconfig.server.json']), ['macClient', 'gatewayBackend']);
assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']); assert.deepEqual(affectedComponents(['docker-compose.client.local.yml']), ['macClient']);
+10 -1
View File
@@ -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 domains = dashboard.panels.find(({ id }) => id === 9);
const totalTraffic = dashboard.panels.find(({ id }) => id === 1); const totalTraffic = dashboard.panels.find(({ id }) => id === 1);
const trafficBySource = dashboard.panels.find(({ id }) => id === 4); 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]; const speedPanels = [globalSpeed, deviceSpeed, sourceSpeed];
assert.equal(dashboard.title, 'Harbor Gateway: трафик'); assert.equal(dashboard.title, 'Harbor Gateway: трафик');
assert.equal(dashboard.refresh, '30s'); assert.equal(dashboard.refresh, '30s');
assert.equal(dashboard.version, 7); assert.equal(dashboard.version, 8);
assert.deepEqual(titles.filter((title) => ['Обзор', 'Устройства', 'Сервисы и домены', 'Техническая детализация'].includes(title)), [ 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('Домены за выбранный период'));
assert.ok(titles.includes('Весь Harbor за выбранный период')); assert.ok(titles.includes('Весь Harbor за выбранный период'));
assert.ok(titles.includes('Источники за выбранный период')); 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_traffic_bytes_total')));
assert.ok(expressions.some((expression) => expression.includes('harbor_device_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_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_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.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.equal(topDevices.options.sortBy[0].desc, true);
assert.match(topDevices.fieldConfig.overrides[1].properties[0].value[0].url, /var-device_id=\$\{__data\.fields/); 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.deepEqual(freshness.fieldConfig.defaults.thresholds.steps.map(({ value }) => value), [null, 60, 120]);
assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных'); assert.equal(freshness.fieldConfig.defaults.noValue, 'Нет данных');
assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода'); assert.equal(freshness.title, 'Свежесть данных в конце выбранного периода');
assert.equal(freshness.targets.find(({ refId }) => refId === 'C').legendFormat, 'Gateway Direct');
assert.equal(deviceVariable.name, 'device_id'); assert.equal(deviceVariable.name, 'device_id');
assert.equal(deviceVariable.multi, false); assert.equal(deviceVariable.multi, false);
assert.equal(deviceVariable.includeAll, true); assert.equal(deviceVariable.includeAll, true);