Improve VPN client connection management
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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».
|
||||||
|
|
||||||
|
|||||||
+74
-4
@@ -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 ! setup_device_traffic; then
|
if validate_device_traffic_config; 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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)}`));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.',
|
||||||
}] : []),
|
}] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user