Files
vpsetup/traffic-guard.sh
eli b4a3640536 docs(guard): 记录 2026-11-07 用 GCP Monitoring 做的实测校准
拿 compute.googleapis.com/instance/network/sent_bytes_count 跟本站 tx_bytes 增量法在同一
时间窗口对账:4,096,497,518 vs 4,137,694,034 bytes,差不到 1%。结论是计量手段本身够准,
偏差主要来自 set-used 的初始估计,不是方法问题。

顺手把"怎么查真实用量"写进头注释(gcloud 没有 time-series 子命令,得打 Monitoring v3
REST API 并自己把 delta 点求和),下次校准不用重新摸索。同时标注该指标比计费口径宽,
是上界。

gcoregon 的 set-used 已按实测从 12 GiB 校正到 9 GiB。
2026-11-07 21:04:16 +08:00

339 lines
17 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#
# traffic-guard.sh — 月度出站流量预算看门狗(为 GCP Standard Tier 每结算账号每月 200 GiB 免费出站设计)
#
# 目的:在本机本月出站流量逼近免费额度前,【只停掉代理类服务】(xray / caddy / hysteria-server),
# 避免超额计费;进入新的结算月后,只把"本脚本自己停掉的服务"重新拉起来。
#
# 绝不关机:本脚本只调用 `systemctl is-active|stop|start <GUARD_SERVICES 里的服务>`,
# 永远不会 poweroff / shutdown / halt / reboot,也不会停掉 VM(否则会连 SSH 一起丢,机器失联)。
#
# 用法:
# traffic-guard.sh [check] # 默认;由 systemd timer 周期调用(幂等)
# traffic-guard.sh status # 打印 月份 / 已用 / 剩余 / 阈值 / 是否已停(只读,不改状态)
# traffic-guard.sh set-used <GiB> # 把本月已用量校准为某个值(例如照 GCP 账单控制台的数字)
# traffic-guard.sh resume # 手动恢复:拉起本脚本停掉的服务并清除标记(不改本月累计)
# 加 --dry-run(或 GUARD_DRY_RUN=1):不调用 systemctl、不写状态/标记,只打印将执行的动作
#
# 环境变量(默认值):
# GUARD_LIMIT_GIB=200 免费额度(用于 status 的"剩余")
# GUARD_STOP_AT_GIB=185 累计 >= 该值就停服务(留 15 GiB 余量给统计误差、停服前的在途流量)
# GUARD_SERVICES="xray caddy hysteria-server"
# GUARD_TZ=America/Los_Angeles 结算月的时区(见下"账期")
# GUARD_STATE_DIR=/var/lib/traffic-guard 状态目录(状态文件 state,标记文件 STOPPED)
# GUARD_SYSFS_NET=/sys/class/net (测试用)网卡统计目录
# GUARD_EXCLUDE_IFACES="" (可选)不计入的网卡名正则,如 '^(docker0|veth.*|br-.*)$'
# GUARD_MONTH="" (测试用)强制当前月份 YYYY-MM
# (GUARD_INTERVAL 只由安装器 setup_traffic_guard.sh 用于生成 timer)
#
# 计量:累加所有【UP 且非 loopback】网卡 /sys/class/net/*/statistics/tx_bytes 的增量。
# * tx_bytes 统计的是网卡发出的全部字节(含 L2/L3 头、重传、内网/VPC 流量、回环以外的隧道/容器桥
# 流量),而 GCP 只对"到互联网的出站"按载荷计费 —— 所以 tx_bytes 会【略微多算】,
# 偏向保守(宁可早停,不会漏算),这是有意接受的安全方向。
# * 免费额度是【每结算账号】共享的:同账号下别的 VM / 项目的出站不在本机计量里。
# 本脚本只能看到本机,若同账号还有其他出站流量,请调低 GUARD_STOP_AT_GIB 或用 set-used 校准。
# * 增量法:每次与上次各网卡读数相减;读数变小(重启/网卡重建,计数器清零)则把当前读数当增量。
# 上次检查到重启之间的那一小段流量会漏记(最多一个 GUARD_INTERVAL 的量,由 15 GiB 余量吸收)。
# * 首次运行没有历史:本月从 0 开始计(不把"开机以来"的历史流量算进来,避免误停)。
# 如已知本月已用量,请用 `set-used` 校准。
# * 实测校准(2026-11-07 gcoregon):取 GCP Monitoring 的
# compute.googleapis.com/instance/network/sent_bytes_count,与本站 tx_bytes 增量法在
# 同一时间窗口(boot 后到查询时刻)的读数分别是 4,096,497,518 / 4,137,694,034 bytes,
# 相差不到 1% —— 说明【计量手段本身够准】,误差主要来自 set-used 的初始估计。
# 查真实用量的办法(gcloud 没有 time-series 子命令,只能打 Monitoring v3 REST API,
# delta 指标需要把返回的点逐个求和):
# tok=$(gcloud auth print-access-token)
# curl -s -H "Authorization: Bearer $tok" --get \
# https://monitoring.googleapis.com/v3/projects/<PROJECT>/timeSeries \
# --data-urlencode 'filter=metric.type="compute.googleapis.com/instance/network/sent_bytes_count"' \
# --data-urlencode 'interval.startTime=2026-11-01T07:00:00Z' \
# --data-urlencode "interval.endTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# 注意 Monitoring 指标比计费口径【宽】(含到 Google API / 同区 VM 的流量),是上界。
#
# 账期:GCP 结算月是自然月,用量按太平洋时间(America/Los_Angeles)切月。默认按 PT 切月 ——
# 万一 GCP 实际按 UTC 切,PT 的月初比 UTC 晚 7~8 小时,我们只会"多算几小时"(安全方向);
# 反过来若默认 UTC 而 GCP 按 PT,则会在 GCP 仍属旧月的几个小时里提前清零(不安全)。
# 需要别的时区用 GUARD_TZ。
#
# 到阈值:停掉 GUARD_SERVICES 里【当前处于 active】的服务,写标记文件 $GUARD_STATE_DIR/STOPPED,
# 日志只记一次;之后每次检查若服务仍是停的 → 什么都不做、不刷日志(幂等)。若期间重启机器导致
# 服务被开机拉起,下一次检查会再次停掉并记一条日志。
# 新账期:清除标记,只 start 本脚本自己停掉的服务(记录在 state 的 stopped_services);
# 从没被本脚本停过的服务(例如你手动停的、或根本没装的)一概不碰。我们只 stop 不 disable,
# 所以"恢复"就是 start,不会改动任何 unit 的 enable 状态。
set -euo pipefail
# 手动执行时(非 systemd 环境)也从安装器写的配置文件里取值,否则 status 会显示内置默认、
# 与实际 timer 生效的配置不一致。逐行精确匹配 KEY="value",不 source(避免被文件内容执行)。
guard_env_file="${GUARD_ENV_FILE:-/etc/traffic-guard.env}"
if [ -r "$guard_env_file" ]; then
_gv() { sed -n "s/^$1=\"\\(.*\\)\"\$/\\1/p" "$guard_env_file" 2>/dev/null | head -1; }
[ -n "${GUARD_LIMIT_GIB:-}" ] || GUARD_LIMIT_GIB="$(_gv GUARD_LIMIT_GIB)"
[ -n "${GUARD_STOP_AT_GIB:-}" ] || GUARD_STOP_AT_GIB="$(_gv GUARD_STOP_AT_GIB)"
[ -n "${GUARD_SERVICES:-}" ] || GUARD_SERVICES="$(_gv GUARD_SERVICES)"
[ -n "${GUARD_TZ:-}" ] || GUARD_TZ="$(_gv GUARD_TZ)"
[ -n "${GUARD_INTERVAL:-}" ] || GUARD_INTERVAL="$(_gv GUARD_INTERVAL)"
unset -f _gv
fi
GUARD_LIMIT_GIB="${GUARD_LIMIT_GIB:-200}"
GUARD_STOP_AT_GIB="${GUARD_STOP_AT_GIB:-185}"
GUARD_SERVICES="${GUARD_SERVICES:-xray caddy hysteria-server}"
GUARD_TZ="${GUARD_TZ:-America/Los_Angeles}"
GUARD_STATE_DIR="${GUARD_STATE_DIR:-/var/lib/traffic-guard}"
GUARD_SYSFS_NET="${GUARD_SYSFS_NET:-/sys/class/net}"
GUARD_EXCLUDE_IFACES="${GUARD_EXCLUDE_IFACES:-}"
GUARD_MONTH="${GUARD_MONTH:-}"
GUARD_DRY_RUN="${GUARD_DRY_RUN:-}"
STATE_FILE="$GUARD_STATE_DIR/state"
MARKER_FILE="$GUARD_STATE_DIR/STOPPED"
LOCK_FILE="$GUARD_STATE_DIR/lock"
log() { printf 'traffic-guard: %s\n' "$*"; }
die() { printf 'traffic-guard: ERROR: %s\n' "$*" >&2; exit 1; }
# ---------- 参数 ----------
CMD="check"; ARG=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) GUARD_DRY_RUN=1;;
check|status|resume) CMD="$1";;
set-used) CMD="set-used"; shift; ARG="${1:-}";;
-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d'; exit 0;;
*) die "未知参数:$1(check|status|set-used <GiB>|resume|--dry-run)";;
esac
shift
done
# ---------- 校验 ----------
num_re='^[0-9]+(\.[0-9]+)?$'
[[ "$GUARD_LIMIT_GIB" =~ $num_re ]] || die "GUARD_LIMIT_GIB 必须是数字:$GUARD_LIMIT_GIB"
[[ "$GUARD_STOP_AT_GIB" =~ $num_re ]] || die "GUARD_STOP_AT_GIB 必须是数字:$GUARD_STOP_AT_GIB"
gib_to_bytes() { awk -v g="$1" 'BEGIN{printf "%.0f", g*1073741824}'; }
LIMIT_BYTES="$(gib_to_bytes "$GUARD_LIMIT_GIB")"
STOP_BYTES="$(gib_to_bytes "$GUARD_STOP_AT_GIB")"
[ "$STOP_BYTES" -le "$LIMIT_BYTES" ] || die "GUARD_STOP_AT_GIB($GUARD_STOP_AT_GIB) 不能大于 GUARD_LIMIT_GIB($GUARD_LIMIT_GIB)"
# 服务名只许常规 unit 名;明确拒绝 .target 与任何关机/重启类名字 —— 本脚本绝不能把机器停掉
for _svc in $GUARD_SERVICES; do
[[ "$_svc" =~ ^[A-Za-z0-9@_.:-]+$ ]] || die "GUARD_SERVICES 含非法服务名:$_svc"
case "$_svc" in
*.target|poweroff*|halt*|reboot*|shutdown*|kexec*|suspend*|hibernate*)
die "GUARD_SERVICES 不允许包含 $_svc(本脚本绝不关机/重启)";;
esac
done
unset _svc
# ---------- systemctl 封装(dry-run 不执行) ----------
svc_active() { systemctl is-active --quiet "$1" 2>/dev/null; }
svc_stop() { if [ -n "$GUARD_DRY_RUN" ]; then log "[dry-run] 将停止 $1"; return 0; fi; systemctl stop "$1"; }
svc_start() { if [ -n "$GUARD_DRY_RUN" ]; then log "[dry-run] 将启动 $1"; return 0; fi; systemctl start "$1"; }
# ---------- 状态文件(key=value,不 source,逐键 awk 读取) ----------
state_get() {
[ -f "$STATE_FILE" ] || return 0
awk -F= -v k="$1" '$1==k{sub(/^[^=]*=/,""); print; exit}' "$STATE_FILE"
}
current_month() {
if [ -n "$GUARD_MONTH" ]; then printf '%s' "$GUARD_MONTH"; else TZ="$GUARD_TZ" date +%Y-%m; fi
}
# 当前各网卡读数:CUR_TX[name]=tx_bytes,CUR_UP[name]=1/0(UP 且非 loopback 才记 1)
declare -A CUR_TX=() CUR_UP=()
snapshot_ifaces() {
CUR_TX=(); CUR_UP=()
local d name flags up tx
for d in "$GUARD_SYSFS_NET"/*; do
[ -d "$d" ] || continue
name="${d##*/}"
[ "$name" = lo ] && continue
tx="$(cat "$d/statistics/tx_bytes" 2>/dev/null || true)"
[[ "$tx" =~ ^[0-9]+$ ]] || continue
up=0
if [ -r "$d/flags" ]; then
flags="$(cat "$d/flags")"
# IFF_UP=0x1,IFF_LOOPBACK=0x8
if [[ "$flags" =~ ^0x[0-9a-fA-F]+$ ]] && (( (flags & 1) == 1 && (flags & 8) == 0 )); then up=1; fi
elif [ "$(cat "$d/operstate" 2>/dev/null || true)" = up ]; then
up=1
fi
if [ -n "$GUARD_EXCLUDE_IFACES" ] && [[ "$name" =~ $GUARD_EXCLUDE_IFACES ]]; then up=0; fi
CUR_TX[$name]="$tx"; CUR_UP[$name]="$up"
done
}
# 计算 "状态里的累计 + 自上次以来的增量",结果放 NEW_USED。
# 所有非 lo 网卡都登记 last(含 DOWN 的),这样网卡 UP 起来时不会把历史读数一次性算成增量。
NEW_USED=0
compute_usage() {
local base="$1" name last cur delta
NEW_USED="$base"
for name in "${!CUR_TX[@]}"; do
cur="${CUR_TX[$name]}"
last="$(state_get "if:$name")"
if [ -z "$last" ]; then delta="$cur" # 本月中途新出现的网卡:计数从它诞生起算
elif [ "$cur" -ge "$last" ]; then delta=$((cur - last))
else delta="$cur"; fi # 读数变小 = 计数器清零(重启/网卡重建)
[ "${CUR_UP[$name]}" = 1 ] && NEW_USED=$((NEW_USED + delta))
done
return 0
}
# 写状态(原子替换)。参数:month used stopped stopped_services stopped_at
write_state() {
[ -z "$GUARD_DRY_RUN" ] || return 0
local tmp name
mkdir -p "$GUARD_STATE_DIR"
tmp="$(mktemp "$GUARD_STATE_DIR/state.XXXXXX")"
{
echo "month=$1"
echo "used_bytes=$2"
echo "stopped=$3"
echo "stopped_services=$4"
echo "stopped_at=$5"
for name in "${!CUR_TX[@]}"; do echo "if:$name=${CUR_TX[$name]}"; done
} > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$STATE_FILE"
}
fmt_gib() { awk -v b="$1" 'BEGIN{printf "%.2f", b/1073741824}'; }
# 恢复:只 start 本脚本记录在案的服务,清除标记。参数:stopped_services
restore_services() {
local svc
for svc in $1; do
if svc_start "$svc"; then log "已重新启动 $svc(此前由 traffic-guard 停止)"
else log "WARN: 启动 $svc 失败,请手动检查"; fi
done
[ -n "$GUARD_DRY_RUN" ] || rm -f "$MARKER_FILE"
}
# ---------- check ----------
do_check() {
local month used stopped stopped_svcs stopped_at now svc newly="" fresh=0
month="$(current_month)"
snapshot_ifaces
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
stopped="$(state_get stopped)"; stopped_svcs="$(state_get stopped_services)"
stopped_at="$(state_get stopped_at)"; used="$(state_get used_bytes)"
if [ ! -f "$STATE_FILE" ]; then
log "首次运行:本月($month)从 0 开始计量(如已知本月已用量,请用 set-used <GiB> 校准)"
used=0; stopped=0; stopped_svcs=""; stopped_at=""; fresh=1
elif [ "$(state_get month)" != "$month" ]; then
log "进入新账期 $month(上月:$(state_get month),累计 $(fmt_gib "${used:-0}") GiB)→ 计数清零"
if [ "$stopped" = 1 ]; then restore_services "$stopped_svcs"; fi
used=0; stopped=0; stopped_svcs=""; stopped_at=""; fresh=1
fi
if [ "$fresh" = 1 ]; then
# 新基线:不计增量,只记下当前各网卡读数
NEW_USED=0
write_state "$month" 0 0 "" ""
else
compute_usage "${used:-0}"
fi
used="$NEW_USED"
if [ "$used" -ge "$STOP_BYTES" ]; then
# 只停当前 active 的服务;已经是停的/没装的不碰、不记
for svc in $GUARD_SERVICES; do
if svc_active "$svc"; then
if svc_stop "$svc"; then
newly+="$svc "
case " $stopped_svcs " in *" $svc "*) ;; *) stopped_svcs="${stopped_svcs:+$stopped_svcs }$svc";; esac
else
log "WARN: 停止 $svc 失败,下次检查重试"
fi
fi
done
if [ "$stopped" != 1 ]; then
if [ -n "$newly" ] || [ -n "$GUARD_DRY_RUN" ]; then
stopped=1; stopped_at="$now"
log "!!! 本月累计出站 $(fmt_gib "$used") GiB ≥ 阈值 $GUARD_STOP_AT_GIB GiB(额度 $GUARD_LIMIT_GIB GiB)→ 已停止服务:${newly% };下个账期自动恢复"
if [ -z "$GUARD_DRY_RUN" ]; then
mkdir -p "$GUARD_STATE_DIR"
printf 'stopped_at=%s\nmonth=%s\nused_bytes=%s\nservices=%s\n' "$now" "$month" "$used" "$stopped_svcs" > "$MARKER_FILE"
fi
else
# 超阈值但没有任何 active 服务可停(例如全是手动停的):记一次状态,不记 stopped_services
stopped=1; stopped_at="$now"
log "!!! 本月累计出站 $(fmt_gib "$used") GiB ≥ 阈值 $GUARD_STOP_AT_GIB GiB,但 GUARD_SERVICES 中没有处于 active 的服务可停"
[ -n "$GUARD_DRY_RUN" ] || printf 'stopped_at=%s\nmonth=%s\nused_bytes=%s\nservices=\n' "$now" "$month" "$used" > "$MARKER_FILE"
fi
elif [ -n "$newly" ]; then
log "已超阈值但服务又被拉起(可能重启过),再次停止:$newly"
fi
fi
write_state "$month" "$used" "${stopped:-0}" "$stopped_svcs" "$stopped_at"
}
# ---------- status ----------
do_status() {
local month smonth used stopped stopped_svcs stopped_at note=""
month="$(current_month)"; smonth="$(state_get month)"
snapshot_ifaces
if [ ! -f "$STATE_FILE" ]; then
log "尚无状态文件($STATE_FILE);首次 check 会建立本月基线"
used=0; stopped=0; stopped_svcs=""; stopped_at=""
elif [ "$smonth" != "$month" ]; then
note="(状态文件还停留在 $smonth,下次 check 会清零并恢复服务)"
used=0; stopped="$(state_get stopped)"; stopped_svcs="$(state_get stopped_services)"; stopped_at="$(state_get stopped_at)"
else
compute_usage "$(state_get used_bytes)"; used="$NEW_USED"
stopped="$(state_get stopped)"; stopped_svcs="$(state_get stopped_services)"; stopped_at="$(state_get stopped_at)"
fi
local remaining=$((LIMIT_BYTES - used))
[ "$remaining" -ge 0 ] || remaining=0
echo "month: $month (账期时区 $GUARD_TZ) $note"
echo "used: $used bytes = $(fmt_gib "$used") GiB"
echo "remaining: $(fmt_gib "$remaining") GiB (额度 $GUARD_LIMIT_GIB GiB)"
echo "threshold: $GUARD_STOP_AT_GIB GiB (到达后停止:$GUARD_SERVICES)"
if [ "${stopped:-0}" = 1 ]; then
echo "stopped: yes (自 ${stopped_at:-?};traffic-guard 停掉的服务:${stopped_svcs:-无})"
else
echo "stopped: no"
fi
}
# ---------- set-used ----------
do_set_used() {
[[ "$ARG" =~ $num_re ]] || die "用法:set-used <GiB>(数字)"
local month bytes
month="$(current_month)"; bytes="$(gib_to_bytes "$ARG")"
snapshot_ifaces
# 沿用已有 stopped 信息;读数基线刷新为当前(之后只累加新增量)
local stopped stopped_svcs stopped_at
stopped="$(state_get stopped)"; stopped_svcs="$(state_get stopped_services)"; stopped_at="$(state_get stopped_at)"
[ "$(state_get month)" = "$month" ] || { stopped=0; stopped_svcs=""; stopped_at=""; }
write_state "$month" "$bytes" "${stopped:-0}" "$stopped_svcs" "$stopped_at"
log "本月($month)已用量已设为 $ARG GiB($bytes bytes)"
}
# ---------- resume ----------
do_resume() {
[ "$(state_get stopped)" = 1 ] || { log "当前没有被 traffic-guard 停止的服务"; return 0; }
local month used
month="$(state_get month)"; used="$(state_get used_bytes)"
snapshot_ifaces
restore_services "$(state_get stopped_services)"
write_state "$month" "${used:-0}" 0 "" ""
}
# ---------- 主流程 ----------
if [ "$CMD" != status ] && [ -z "$GUARD_DRY_RUN" ]; then
mkdir -p "$GUARD_STATE_DIR"
exec 9> "$LOCK_FILE"
flock -n 9 || { log "另一个实例正在运行,本次跳过"; exit 0; }
fi
case "$CMD" in
check) do_check;;
status) do_status;;
set-used) do_set_used;;
resume) do_resume;;
esac