#!/bin/bash # ========================================================= # VFly - Multi-Protocol Manager V3.13 # ========================================================= # --- 颜色定义 --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # --- 流量监控配置 --- TRAFFIC_CONF="/etc/vps-traffic.conf" DEFAULT_QUOTA_GB=2048 # 2TB # --- 路径定义 --- TRAFFIC_WEB_DIR="/opt/vps-traffic-web" TRAFFIC_WEB_PORT=19999 XRAY_BIN="/usr/local/bin/xray" XRAY_CONF="/usr/local/etc/xray/config.json" HY2_CONF="/etc/hysteria/config.yaml" SNELL_CONF="/etc/snell/snell-server.conf" # --- 基础函数 --- # 通用端口选择函数,结果存入全局变量 SELECTED_PORT select_port() { local service_name="${1:-服务}" local default_random_min="${2:-10000}" local default_random_max="${3:-65535}" echo -e "\n${YELLOW}--- 端口选择 (${service_name}) ---${NC}" echo -e " ${CYAN}1.${NC} 使用 443 端口 ${YELLOW}(最佳隐蔽性,流量混入 HTTPS)${NC}" echo -e " ${CYAN}2.${NC} 随机端口 ${YELLOW}(${default_random_min}-${default_random_max},避开常用端口)${NC}" echo -e " ${CYAN}3.${NC} 手动输入端口" read -p "请选择 [1/2/3,默认 1]: " PORT_OPT [[ -z "$PORT_OPT" ]] && PORT_OPT=1 case $PORT_OPT in 1) SELECTED_PORT=443 echo -e "${GREEN}使用 443 端口${NC}" ;; 2) SELECTED_PORT=$(( RANDOM % (default_random_max - default_random_min + 1) + default_random_min )) echo -e "${GREEN}随机端口: ${SELECTED_PORT}${NC}" ;; 3) while true; do read -p "请输入端口 (1-65535): " SELECTED_PORT if [[ "$SELECTED_PORT" =~ ^[0-9]+$ ]] && (( SELECTED_PORT >= 1 && SELECTED_PORT <= 65535 )); then echo -e "${GREEN}使用端口: ${SELECTED_PORT}${NC}" break else echo -e "${RED}无效端口,请重新输入${NC}" fi done ;; *) SELECTED_PORT=443 echo -e "${YELLOW}无效选择,默认使用 443${NC}" ;; esac } check_root() { [[ $EUID -ne 0 ]] && { echo -e "${RED}请使用 sudo -i 切换到 root 用户后运行!${NC}"; exit 1; } } install_tools() { if ! command -v jq &>/dev/null || ! command -v qrencode &>/dev/null || ! command -v python3 &>/dev/null; then echo -e "${BLUE}正在安装必要工具...${NC}" if command -v apt &>/dev/null; then apt update -y && apt install -y wget curl unzip vim jq qrencode openssl socat python3 python3-pip elif command -v yum &>/dev/null; then yum update -y && yum install -y wget curl unzip vim jq qrencode openssl socat python3 python3-pip elif command -v dnf &>/dev/null; then dnf update -y && dnf install -y wget curl unzip vim jq qrencode openssl socat python3 python3-pip fi fi } get_ip() { curl -s4m8 https://ip.gs || curl -s4m8 https://api.ipify.org } check_status() { if systemctl is-active --quiet "$1"; then echo -e "${GREEN}运行中${NC}" else echo -e "${RED}未运行${NC}" fi } # --- 1. Reality 管理 (核心修复部分) --- install_reality() { echo -e "${BLUE}>>> 安装/重置 Xray Reality...${NC}" bash -c "$(curl -fsSL https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install if ! command -v xray &>/dev/null && [[ ! -f /usr/local/bin/xray ]]; then echo -e "${RED}Xray 安装失败,请检查网络或稍后重试。${NC}" return 1 fi mkdir -p /usr/local/etc/xray select_port "VLESS Reality" echo -e "${YELLOW}提示:443 端口让流量看起来像正常 HTTPS,隐蔽性最好;${NC}" echo -e "${YELLOW} 其他端口功能完全正常,但可能更容易被识别为代理流量。${NC}" while true; do read -p "请输入伪装域名 (SNI) [默认: griffithobservatory.org]: " SNI [[ -z "$SNI" ]] && SNI="griffithobservatory.org" if [[ "$SNI" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ ]]; then break else echo -e "${RED}无效域名格式,请重新输入(只允许字母、数字、点、连字符)${NC}" fi done echo -e "${YELLOW}正在生成密钥...${NC}" # 获取原始输出 KEYS_RAW=$($XRAY_BIN x25519) # 尝试匹配 Private Key (兼容 PrivateKey: 和 Private Key:) PK=$(echo "$KEYS_RAW" | grep -i "Private" | awk -F: '{print $NF}' | awk '{print $1}') # 尝试匹配 Public Key (兼容 Public Key: 和 Password:) PUB=$(echo "$KEYS_RAW" | grep -i "Public" | awk -F: '{print $NF}' | awk '{print $1}') # 如果没找到 Public,尝试找 Password (针对 Xray v26+) if [[ -z "$PUB" ]]; then PUB=$(echo "$KEYS_RAW" | grep -i "Password" | awk -F: '{print $NF}' | awk '{print $1}') fi # 如果还是失败,进入手动模式 if [[ -z "$PK" || -z "$PUB" ]]; then echo -e "${RED}自动抓取密钥失败 (可能是Xray版本输出格式变更)。${NC}" echo -e "当前输出内容:\n$KEYS_RAW" echo -e "${YELLOW}请根据上方内容手动复制粘贴:${NC}" read -p "请输入 PrivateKey: " PK read -p "请输入 Public Key (或 Password): " PUB fi # 最终检查 if [[ -z "$PK" || -z "$PUB" ]]; then echo -e "${RED}错误:未能获取有效密钥,停止安装。${NC}" return fi UUID=$($XRAY_BIN uuid) SID=$(openssl rand -hex 4) mkdir -p /var/log/xray touch /var/log/xray/access.log chown nobody:nogroup /var/log/xray/access.log 2>/dev/null || true cat > $XRAY_CONF < /usr/local/etc/xray/public.key chown root:"$XRAY_GROUP" /usr/local/etc/xray/public.key chmod 640 /usr/local/etc/xray/public.key if ! systemctl restart xray; then echo -e "${RED}Xray 服务启动失败,请查看日志: journalctl -u xray -n 20${NC}" return 1 fi echo -e "${GREEN}Reality 安装完成!${NC}" view_reality } view_reality() { if [[ ! -f $XRAY_CONF ]]; then echo -e "${RED}未找到配置文件${NC}"; return; fi IP=$(get_ip) PORT=$(jq -r '.inbounds[0].port' $XRAY_CONF) UUID=$(jq -r '.inbounds[0].settings.clients[0].id' $XRAY_CONF) SNI=$(jq -r '.inbounds[0].streamSettings.realitySettings.serverNames[0]' $XRAY_CONF) SID=$(jq -r '.inbounds[0].streamSettings.realitySettings.shortIds[0]' $XRAY_CONF) # 尝试读取保存的公钥 if [[ -f /usr/local/etc/xray/public.key ]]; then PUB=$(cat /usr/local/etc/xray/public.key) else PUB="未找到公钥文件,请重置" fi LINK="vless://${UUID}@${IP}:${PORT}?encryption=none&flow=xtls-rprx-vision&security=reality&sni=${SNI}&fp=chrome&pbk=${PUB}&sid=${SID}&type=tcp&headerType=none#Reality_Vision" echo -e "\n${YELLOW}=== Reality 配置信息 ===${NC}" echo -e "端口: $PORT" echo -e "SNI: $SNI" echo -e "UUID: $UUID" echo -e "Public Key: $PUB" echo -e "ShortID: $SID" echo -e "链接: $LINK" echo -e "\n${YELLOW}二维码:${NC}" qrencode -t ANSIUTF8 "$LINK" } manage_reality_menu() { echo -e "\n${BLUE}--- Reality 管理 ---${NC}" echo "1. 查看配置/二维码" echo "2. 重启服务" echo "3. 停止服务" echo "4. 查看日志" echo "5. 完整卸载 Reality" read -p "请选择: " OPT case $OPT in 1) view_reality ;; 2) systemctl restart xray && echo "已重启" ;; 3) systemctl stop xray && echo "已停止" ;; 4) journalctl -u xray -n 20 --no-pager ;; 5) uninstall_reality ;; *) echo "无效选择" ;; esac } # --- 2. Hysteria 2 管理 --- install_hy2() { echo -e "${BLUE}>>> 安装 Hysteria 2...${NC}" ARCH=$(uname -m) case $ARCH in x86_64) HY_ARCH="amd64" ;; aarch64) HY_ARCH="arm64" ;; *) echo "不支持架构"; return ;; esac LATEST=$(curl -s https://api.github.com/repos/apernet/hysteria/releases/latest | grep "tag_name" | cut -d '"' -f 4) if ! wget -O /usr/local/bin/hysteria_server "https://github.com/apernet/hysteria/releases/download/${LATEST}/hysteria-linux-${HY_ARCH}"; then echo -e "${RED}Hysteria 2 下载失败,请检查网络或稍后重试。${NC}" return 1 fi chmod +x /usr/local/bin/hysteria_server select_port "Hysteria 2 (UDP)" mkdir -p /etc/hysteria openssl req -x509 -nodes -newkey rsa:2048 -keyout /etc/hysteria/server.key -out /etc/hysteria/server.crt -days 3650 -subj "/CN=bing.com" 2>/dev/null PASS=$(openssl rand -hex 16) cat > $HY2_CONF < /etc/systemd/system/hysteria-server.service <>> 安装 Snell v5...${NC}" ARCH=$(uname -m) # 尝试动态获取最新版本号,失败时回退到已知稳定版本 local SNELL_VER SNELL_VER=$(curl -fsSm5 https://dl.nssurge.com/snell/snell-server-latest-version 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1) [[ -z "$SNELL_VER" ]] && SNELL_VER="v5.0.1" echo -e "${YELLOW}Snell 版本: ${SNELL_VER}${NC}" if [[ "$ARCH" == "x86_64" ]]; then URL="https://dl.nssurge.com/snell/snell-server-${SNELL_VER}-linux-amd64.zip" else URL="https://dl.nssurge.com/snell/snell-server-${SNELL_VER}-linux-aarch64.zip" fi if ! wget -O snell.zip "$URL"; then echo -e "${RED}Snell 下载失败,请检查网络或稍后重试。${NC}" return 1 fi unzip -o snell.zip -d /usr/local/bin rm snell.zip chmod +x /usr/local/bin/snell-server mkdir -p /etc/snell PSK=$(openssl rand -base64 20 | tr -dc 'a-zA-Z0-9') GROUP="nobody" grep -q "nogroup" /etc/group && GROUP="nogroup" cat > $SNELL_CONF < /lib/systemd/system/snell.service </dev/null; then systemctl restart vps-traffic-collector 2>/dev/null && \ echo -e "${GREEN}流量采集器协议映射已刷新。${NC}" fi } uninstall_reality() { local skip_confirm="${1:-0}" local remove_logs="${2:-ask}" if [[ "$skip_confirm" != "1" ]]; then confirm_uninstall "Reality (Xray)" || { echo -e "${YELLOW}已取消。${NC}"; return; } fi systemctl disable --now xray xray@ 2>/dev/null || true rm -f /etc/systemd/system/xray.service rm -f /etc/systemd/system/xray@.service rm -f /lib/systemd/system/xray.service rm -f /lib/systemd/system/xray@.service rm -f /etc/logrotate.d/xray rm -f "$XRAY_BIN" rm -rf /usr/local/etc/xray rm -rf /usr/local/share/xray rm -rf /etc/systemd/system/xray.service.d rm -rf /etc/systemd/system/xray@.service.d if [[ "$remove_logs" == "ask" ]]; then read -p "是否同时删除 Xray 日志目录 /var/log/xray?[y/N]: " yn [[ "$yn" =~ ^[Yy]$ ]] && remove_logs="yes" || remove_logs="no" fi [[ "$remove_logs" == "yes" ]] && rm -rf /var/log/xray systemctl daemon-reload systemctl reset-failed xray xray@ 2>/dev/null || true restart_traffic_collector_if_active echo -e "${GREEN}Reality (Xray) 已完整卸载。${NC}" } uninstall_hy2() { local skip_confirm="${1:-0}" if [[ "$skip_confirm" != "1" ]]; then confirm_uninstall "Hysteria2" || { echo -e "${YELLOW}已取消。${NC}"; return; } fi systemctl disable --now hysteria-server 2>/dev/null || true rm -f /etc/systemd/system/hysteria-server.service rm -f /lib/systemd/system/hysteria-server.service rm -f /usr/local/bin/hysteria_server rm -rf /etc/hysteria systemctl daemon-reload systemctl reset-failed hysteria-server 2>/dev/null || true restart_traffic_collector_if_active echo -e "${GREEN}Hysteria2 已完整卸载。${NC}" } uninstall_snell() { local skip_confirm="${1:-0}" if [[ "$skip_confirm" != "1" ]]; then confirm_uninstall "Snell v5" || { echo -e "${YELLOW}已取消。${NC}"; return; } fi systemctl disable --now snell 2>/dev/null || true rm -f /etc/systemd/system/snell.service rm -f /lib/systemd/system/snell.service rm -f /usr/local/bin/snell-server rm -rf /etc/snell systemctl daemon-reload systemctl reset-failed snell 2>/dev/null || true restart_traffic_collector_if_active echo -e "${GREEN}Snell v5 已完整卸载。${NC}" } uninstall_all_protocols() { confirm_uninstall "全部协议 (Reality / Hysteria2 / Snell)" || { echo -e "${YELLOW}已取消。${NC}"; return; } local remove_logs="no" read -p "是否同时删除 Xray 日志目录 /var/log/xray?[y/N]: " yn [[ "$yn" =~ ^[Yy]$ ]] && remove_logs="yes" uninstall_reality 1 "$remove_logs" uninstall_hy2 1 uninstall_snell 1 echo -e "${GREEN}全部协议已卸载完成。${NC}" } manage_uninstall_menu() { while true; do echo -e "\n${BLUE}--- 完整卸载协议 ---${NC}" echo "1. 卸载 Reality (Xray)" echo "2. 卸载 Hysteria2" echo "3. 卸载 Snell v5" echo "4. 卸载全部协议" echo "0. 返回" read -p "请选择: " OPT case $OPT in 1) uninstall_reality ;; 2) uninstall_hy2 ;; 3) uninstall_snell ;; 4) uninstall_all_protocols ;; 0) break ;; *) echo "无效选择" ;; esac done } # --- 5. 流量监控 --- _traffic_get_iface() { ip route show default 2>/dev/null | awk '/^default/{print $5}' | head -1 } _traffic_load_conf() { QUOTA_GB=$DEFAULT_QUOTA_GB RESET_DAY=1 ALERT_PCT=80 WEB_TOKEN="" WEB_PORT="" OFFSET_RX=0 OFFSET_TX=0 if [[ -f "$TRAFFIC_CONF" ]]; then local _val _val=$(grep -m1 '^QUOTA_GB=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && QUOTA_GB="$_val" _val=$(grep -m1 '^RESET_DAY=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && RESET_DAY="$_val" _val=$(grep -m1 '^ALERT_PCT=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && ALERT_PCT="$_val" _val=$(grep -m1 '^WEB_TOKEN=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ -n "$_val" ]] && WEB_TOKEN="$_val" _val=$(grep -m1 '^WEB_PORT=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && WEB_PORT="$_val" _val=$(grep -m1 '^OFFSET_RX=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && OFFSET_RX="$_val" _val=$(grep -m1 '^OFFSET_TX=' "$TRAFFIC_CONF" | cut -d'=' -f2-) [[ "$_val" =~ ^[0-9]+$ ]] && OFFSET_TX="$_val" fi } _traffic_save_conf() { cat > "$TRAFFIC_CONF" < /etc/logrotate.d/xray <<'EOF' /var/log/xray/access.log { daily rotate 7 compress delaycompress missingok notifempty copytruncate } EOF } _traffic_install_vnstat() { if ! command -v vnstat &>/dev/null; then echo -e "${BLUE}正在安装 vnstat...${NC}" if command -v apt &>/dev/null; then apt install -y vnstat elif command -v yum &>/dev/null; then yum install -y vnstat elif command -v dnf &>/dev/null; then dnf install -y vnstat fi systemctl enable vnstat --now local iface iface=$(_traffic_get_iface) if [[ -n "$iface" ]]; then vnstat -i "$iface" --add 2>/dev/null || true fi echo -e "${YELLOW}vnstat 刚安装,需要收集约 1 分钟数据后才有统计。${NC}" sleep 2 fi # 确保 vnstat 服务在跑 systemctl is-active --quiet vnstat || systemctl start vnstat } _traffic_install_conntrack() { if ! command -v conntrack &>/dev/null; then echo -e "${BLUE}正在安装 conntrack...${NC}" if command -v apt &>/dev/null; then apt install -y conntrack elif command -v yum &>/dev/null; then yum install -y conntrack-tools elif command -v dnf &>/dev/null; then dnf install -y conntrack-tools fi fi # 检查内核是否支持 conntrack if ! modinfo nf_conntrack &>/dev/null && ! lsmod | grep -q nf_conntrack; then echo -e "${RED}此系统内核不支持 nf_conntrack(可能是 OpenVZ/LXC),无法启用连接追踪。${NC}" return 1 fi # 启用 conntrack 计数(默认关闭) sysctl -w net.netfilter.nf_conntrack_acct=1 &>/dev/null || true sysctl -w net.netfilter.nf_conntrack_max=131072 &>/dev/null || true cat > /etc/sysctl.d/99-conntrack.conf </dev/null iptables -D OUTPUT -m conntrack --ctstate NEW -j ACCEPT 2>/dev/null iptables -I INPUT 1 -m conntrack --ctstate NEW -j ACCEPT iptables -I OUTPUT 1 -m conntrack --ctstate NEW -j ACCEPT # 持久化 iptables 规则(systemd service,兼容 systemd-networkd 和 ifupdown) mkdir -p /etc/iptables iptables-save > /etc/iptables/rules.v4 2>/dev/null || true cat > /etc/systemd/system/iptables-restore.service <<'SVC' [Unit] Description=Restore iptables rules (conntrack activation) Before=network-pre.target Wants=network-pre.target DefaultDependencies=no [Service] Type=oneshot ExecStart=/bin/sh -c 'iptables-restore < /etc/iptables/rules.v4' RemainAfterExit=yes [Install] WantedBy=multi-user.target SVC systemctl daemon-reload systemctl enable iptables-restore.service &>/dev/null echo -e "${GREEN}conntrack 就绪(已设置开机自动恢复)。${NC}" } _traffic_install_geoip() { local geoip_dir="/opt/vps-traffic-web/geoip" mkdir -p "$geoip_dir" echo -e "${BLUE}正在下载 GeoLite2-Country.mmdb...${NC}" local url="https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-Country.mmdb" if ! wget -qO "$geoip_dir/GeoLite2-Country.mmdb" "$url"; then echo -e "${RED}GeoIP 数据库下载失败,将跳过国家显示。${NC}" return 1 fi # 安装 maxminddb python 库 if ! python3 -c "import maxminddb" &>/dev/null; then # 优先用系统包管理(无需 --break-system-packages) if command -v apt-get &>/dev/null; then apt-get install -y python3-maxminddb &>/dev/null || true elif command -v yum &>/dev/null; then yum install -y python3-maxminddb &>/dev/null || true fi # 若系统包不存在,回退 pip(兼容 PEP 668 externally-managed 环境) if ! python3 -c "import maxminddb" &>/dev/null; then pip3 install maxminddb --quiet --break-system-packages 2>/dev/null || \ pip3 install maxminddb --quiet 2>/dev/null || \ pip install maxminddb --quiet 2>/dev/null || true fi if python3 -c "import maxminddb" &>/dev/null; then echo -e "${GREEN}maxminddb 安装成功。${NC}" else echo -e "${YELLOW}maxminddb 安装失败,国家显示将不可用。${NC}" fi fi echo -e "${GREEN}GeoIP 数据库就绪: $geoip_dir/GeoLite2-Country.mmdb${NC}" } _traffic_update_geoip() { echo -e "${YELLOW}正在更新 GeoIP 数据库...${NC}" _traffic_install_geoip # 重启采集器以加载新数据库 systemctl is-active --quiet vps-traffic-collector && systemctl restart vps-traffic-collector echo -e "${GREEN}GeoIP 更新完成。${NC}" } _traffic_flows_cleanup_cron() { local cron_script="/usr/local/bin/vps-flows-cleanup.sh" cat > "$cron_script" <<'SCRIPT' #!/bin/bash DB="/var/lib/vps-traffic/flows.db" [[ -f "$DB" ]] || exit 0 python3 - "$DB" <<'EOF' import sys, sqlite3, time db = sys.argv[1] cutoff = int(time.time()) - 180 * 86400 # 6 个月 with sqlite3.connect(db) as conn: conn.execute("DELETE FROM flows WHERE ts < ?", (cutoff,)) conn.execute("VACUUM") EOF logger -t vps-flows "cleanup: removed flows older than 6 months" SCRIPT chmod +x "$cron_script" local cron_line="0 3 * * * $cron_script" if ! crontab -l 2>/dev/null | grep -qF "$cron_script"; then ( crontab -l 2>/dev/null; echo "$cron_line" ) | crontab - fi } _traffic_list_protocol_ports() { if [[ -f "$XRAY_CONF" ]] && command -v jq &>/dev/null; then jq -r '.inbounds[]? | select(.protocol == "vless" or .protocol == "vmess") | .port' "$XRAY_CONF" 2>/dev/null fi if [[ -f "$HY2_CONF" ]]; then awk -F: '/^[[:space:]]*listen:/ {gsub(/[[:space:]]/, "", $NF); if ($NF ~ /^[0-9]+$/) print $NF; exit}' "$HY2_CONF" 2>/dev/null fi if [[ -f "$SNELL_CONF" ]]; then awk -F: '/^[[:space:]]*listen[[:space:]]*=/ {gsub(/[[:space:]]/, "", $NF); if ($NF ~ /^[0-9]+$/) print $NF; exit}' "$SNELL_CONF" 2>/dev/null fi } _traffic_remove_iptables_rules() { command -v iptables &>/dev/null || return while iptables -D INPUT -m conntrack --ctstate NEW -j ACCEPT 2>/dev/null; do :; done while iptables -D OUTPUT -m conntrack --ctstate NEW -j ACCEPT 2>/dev/null; do :; done local port proto for port in $(_traffic_list_protocol_ports | sort -nu); do for proto in tcp udp; do while iptables -D INPUT -p "$proto" --dport "$port" 2>/dev/null; do :; done while iptables -D OUTPUT -p "$proto" --sport "$port" 2>/dev/null; do :; done done done if [[ -f /etc/iptables/rules.v4 ]] && command -v iptables-save &>/dev/null; then iptables-save > /etc/iptables/rules.v4 2>/dev/null || true fi } _traffic_remove_conntrack_persistence() { rm -f /etc/sysctl.d/99-conntrack.conf if [[ -f /etc/systemd/system/iptables-restore.service ]] && \ grep -q "conntrack activation" /etc/systemd/system/iptables-restore.service 2>/dev/null; then systemctl disable --now iptables-restore.service 2>/dev/null || true rm -f /etc/systemd/system/iptables-restore.service fi } _traffic_clear_web_conf() { [[ -f "$TRAFFIC_CONF" ]] || return sed -i 's/^WEB_TOKEN=.*/WEB_TOKEN=/' "$TRAFFIC_CONF" 2>/dev/null || true sed -i 's/^WEB_PORT=.*/WEB_PORT=/' "$TRAFFIC_CONF" 2>/dev/null || true } _traffic_bytes_to_human() { local bytes=$1 if (( bytes >= 1099511627776 )); then awk "BEGIN {printf \"%.2f TB\", $bytes/1099511627776}" elif (( bytes >= 1073741824 )); then awk "BEGIN {printf \"%.2f GB\", $bytes/1073741824}" elif (( bytes >= 1048576 )); then awk "BEGIN {printf \"%.2f MB\", $bytes/1048576}" else echo "${bytes} B" fi } _traffic_progress_bar() { local pct=$1 local width=30 local filled=$(( pct * width / 100 )) [[ $filled -gt $width ]] && filled=$width local empty=$(( width - filled )) local bar="" for ((i=0; i= 90 )); then echo -e "${RED}[${bar}] ${pct}%${NC}" elif (( pct >= 70 )); then echo -e "${YELLOW}[${bar}] ${pct}%${NC}" else echo -e "${GREEN}[${bar}] ${pct}%${NC}" fi } traffic_show() { _traffic_install_vnstat _traffic_load_conf local iface iface=$(_traffic_get_iface) if [[ -z "$iface" ]]; then echo -e "${RED}无法检测网络接口${NC}" return fi # 获取本月流量(bytes) local rx_bytes tx_bytes total_bytes rx_bytes=$(vnstat -i "$iface" --json m 2>/dev/null | \ python3 -c "import sys,json; d=json.load(sys.stdin)['interfaces'][0]['traffic']['month']; print(d[-1]['rx'])" 2>/dev/null || echo 0) tx_bytes=$(vnstat -i "$iface" --json m 2>/dev/null | \ python3 -c "import sys,json; d=json.load(sys.stdin)['interfaces'][0]['traffic']['month']; print(d[-1]['tx'])" 2>/dev/null || echo 0) total_bytes=$(( rx_bytes + tx_bytes )) local quota_bytes=$(( QUOTA_GB * 1024 * 1024 * 1024 )) local used_pct=0 if (( quota_bytes > 0 )); then used_pct=$(( total_bytes * 100 / quota_bytes )) fi local remain_bytes=$(( quota_bytes - total_bytes )) [[ $remain_bytes -lt 0 ]] && remain_bytes=0 # 今日流量 local today_rx today_tx today_rx=$(vnstat -i "$iface" --json d 2>/dev/null | \ python3 -c "import sys,json; d=json.load(sys.stdin)['interfaces'][0]['traffic']['day']; print(d[-1]['rx'])" 2>/dev/null || echo 0) today_tx=$(vnstat -i "$iface" --json d 2>/dev/null | \ python3 -c "import sys,json; d=json.load(sys.stdin)['interfaces'][0]['traffic']['day']; print(d[-1]['tx'])" 2>/dev/null || echo 0) local today_total=$(( today_rx + today_tx )) local reset_date reset_date=$(date -d "$(date +%Y-%m)-${RESET_DAY}" +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d) echo -e "\n${BOLD}${BLUE}╔══════════════════════════════════════════╗${NC}" echo -e "${BOLD}${BLUE}║ VPS 流量监控 ║${NC}" echo -e "${BOLD}${BLUE}╚══════════════════════════════════════════╝${NC}" echo -e " 接口: ${CYAN}${iface}${NC} 重置日: 每月 ${RESET_DAY} 日" echo "" echo -e " ${BOLD}本月用量${NC}" echo -e " ↑ 上传: $(printf '%10s' "$(_traffic_bytes_to_human $tx_bytes)")" echo -e " ↓ 下载: $(printf '%10s' "$(_traffic_bytes_to_human $rx_bytes)")" echo -e " ∑ 合计: $(printf '%10s' "$(_traffic_bytes_to_human $total_bytes)")" echo "" echo -e " ${BOLD}月配额: ${QUOTA_GB} GB${NC} 剩余: $(_traffic_bytes_to_human $remain_bytes)" echo -n " " _traffic_progress_bar "$used_pct" echo "" echo -e " ${BOLD}今日用量${NC}" echo -e " ↑ ${_traffic_bytes_to_human $today_tx} ↓ ${_traffic_bytes_to_human $today_rx} 合计: $(_traffic_bytes_to_human $today_total)" echo "" if (( used_pct >= ALERT_PCT )); then echo -e " ${RED}⚠ 已用 ${used_pct}%,超过告警阈值 ${ALERT_PCT}%!${NC}" fi echo -e "${BLUE}──────────────────────────────────────────${NC}" echo -e " ${YELLOW}最近 5 天明细:${NC}" vnstat -i "$iface" -d 5 2>/dev/null | tail -8 } traffic_show_month() { _traffic_install_vnstat local iface iface=$(_traffic_get_iface) echo -e "\n${YELLOW}=== 历史月度流量 ===${NC}" vnstat -i "$iface" -m 2>/dev/null } traffic_set_quota() { _traffic_load_conf echo -e "\n${YELLOW}=== 配置流量配额 ===${NC}" echo -e "当前配额: ${QUOTA_GB} GB,告警阈值: ${ALERT_PCT}%,重置日: 每月 ${RESET_DAY} 日" echo "" read -p "月配额 (GB) [当前: ${QUOTA_GB}, 回车跳过]: " input if [[ -n "$input" ]]; then if [[ "$input" =~ ^[0-9]+$ ]] && (( input >= 1 && input <= 102400 )); then QUOTA_GB="$input" else echo -e "${YELLOW}无效值,保持当前配额 ${QUOTA_GB} GB${NC}" fi fi read -p "重置日 (1-28) [当前: ${RESET_DAY}, 回车跳过]: " input if [[ -n "$input" ]]; then if [[ "$input" =~ ^[0-9]+$ ]] && (( input >= 1 && input <= 28 )); then RESET_DAY="$input" else echo -e "${YELLOW}无效值,保持当前重置日 ${RESET_DAY}${NC}" fi fi read -p "告警阈值 % [当前: ${ALERT_PCT}, 回车跳过]: " input if [[ -n "$input" ]]; then if [[ "$input" =~ ^[0-9]+$ ]] && (( input >= 1 && input <= 100 )); then ALERT_PCT="$input" else echo -e "${YELLOW}无效值,保持当前阈值 ${ALERT_PCT}%${NC}" fi fi _traffic_save_conf echo -e "${GREEN}已保存。${NC}" } traffic_setup_cron() { _traffic_load_conf local iface iface=$(_traffic_get_iface) local cron_script="/usr/local/bin/vps-traffic-alert.sh" cat > "$cron_script" <

VPS 流量监控

请输入访问密码

密码错误,请重试
总览
加载中...
时间范围: 协议:
加载中...
时间范围: 协议:
加载中...
加载中...
""" class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, fmt, *args): pass def _check_auth(self): if not TOKEN: return True auth = self.headers.get("Authorization", "") if not auth.startswith("Bearer "): return False return hmac.compare_digest(auth[7:].encode(), TOKEN.encode()) def do_POST(self): if self.path == "/auth": length = int(self.headers.get("Content-Length", 0)) try: body = json.loads(self.rfile.read(length)) pwd = body.get("password", "") except Exception: pwd = "" if TOKEN and hmac.compare_digest(pwd.encode(), TOKEN.encode()): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({"token": TOKEN}).encode()) else: self.send_response(401) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"error":"invalid password"}') else: self.send_response(404) self.end_headers() def do_GET(self): parsed = urllib.parse.urlparse(self.path) qs = urllib.parse.parse_qs(parsed.query) path = parsed.path if path == "/" or path == "/index.html": self._index(); return if not self._check_auth(): self.send_response(401) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(b'{"error":"unauthorized"}') return if path == "/api": self._api_vnstat() elif path == "/api/flows/summary": self._api_json(flows_summary(qs.get("range",["7d"])[0], qs.get("direction",[None])[0], qs.get("protocol",[None])[0])) elif path == "/api/flows/top": self._api_json(flows_top(qs.get("field",["host"])[0], qs.get("range",["7d"])[0], qs.get("direction",[None])[0], int(qs.get("limit",["30"])[0]), qs.get("protocol",[None])[0])) elif path == "/api/flows/timeline": self._api_json(flows_timeline(qs.get("range",["7d"])[0], qs.get("bucket",["day"])[0], qs.get("direction",[None])[0], qs.get("protocol",[None])[0])) elif path == "/api/flows/recent": self._api_json(flows_recent(int(qs.get("limit",["100"])[0]))) elif path == "/api/flows/by_protocol": self._api_json(flows_by_protocol(qs.get("range",["7d"])[0])) else: self._index() def _index(self): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(HTML.encode()) def _api_json(self, data): self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data).encode()) def _api_vnstat(self): rx, tx = get_month_data() rx += OFFSET_RX tx += OFFSET_TX total = rx + tx quota_bytes = QUOTA_GB * 1024**3 used_pct = int(total * 100 / quota_bytes) if quota_bytes else 0 remain = max(quota_bytes - total, 0) self._api_json({ "iface": IFACE, "rx": rx, "tx": tx, "quota_gb": QUOTA_GB, "alert_pct": ALERT_PCT, "used_pct": used_pct, "remain": remain, "offset_rx": OFFSET_RX, "offset_tx": OFFSET_TX, "days": get_day_data(30), "months": get_all_months(), }) if __name__ == "__main__": server = http.server.HTTPServer(("0.0.0.0", PORT), Handler) print(f"VPS Traffic Web running on :{PORT}") server.serve_forever() PYEOF } _web_write_service() { cat > /etc/systemd/system/vps-traffic-web.service <= 1 && input_port <= 65535 )); then TRAFFIC_WEB_PORT="$input_port" else echo -e "${YELLOW}无效端口,使用默认 ${TRAFFIC_WEB_PORT}${NC}" fi fi # 安装依赖 echo -e "${BLUE}>>> 安装 conntrack...${NC}" _traffic_install_conntrack || echo -e "${YELLOW}conntrack 不可用,字节统计将受限。${NC}" echo -e "${BLUE}>>> 下载 GeoIP 数据库...${NC}" _traffic_install_geoip || true echo -e "${BLUE}>>> 配置 logrotate...${NC}" _traffic_setup_logrotate # 若 Xray 已安装,确保 loglevel=info if [[ -f "$XRAY_CONF" ]] && command -v jq &>/dev/null; then local cur_level cur_level=$(jq -r '.log.loglevel // "warning"' "$XRAY_CONF" 2>/dev/null) if [[ "$cur_level" != "info" ]]; then jq '.log.loglevel = "info"' "$XRAY_CONF" > /tmp/xray_conf_tmp.json && \ mv /tmp/xray_conf_tmp.json "$XRAY_CONF" systemctl is-active --quiet xray && systemctl reload xray 2>/dev/null || \ systemctl is-active --quiet xray && systemctl restart xray 2>/dev/null || true echo -e "${GREEN}Xray loglevel 已更新为 info。${NC}" fi fi # 写 web server 和 collector _web_write_server "$iface" "$QUOTA_GB" "$RESET_DAY" "$ALERT_PCT" "$token" _web_write_service _traffic_install_collector "$iface" _traffic_flows_cleanup_cron # 保存 token 到配置 grep -q "^WEB_TOKEN=" "$TRAFFIC_CONF" 2>/dev/null && \ sed -i "s/^WEB_TOKEN=.*/WEB_TOKEN=${token}/" "$TRAFFIC_CONF" || \ echo "WEB_TOKEN=${token}" >> "$TRAFFIC_CONF" grep -q "^WEB_PORT=" "$TRAFFIC_CONF" 2>/dev/null && \ sed -i "s/^WEB_PORT=.*/WEB_PORT=${TRAFFIC_WEB_PORT}/" "$TRAFFIC_CONF" || \ echo "WEB_PORT=${TRAFFIC_WEB_PORT}" >> "$TRAFFIC_CONF" systemctl enable vps-traffic-web --now local ip ip=$(get_ip) echo -e "\n${GREEN}✓ Web 面板 + 流量采集器已启动!${NC}" echo -e "${BOLD}访问地址:${NC} ${CYAN}http://${ip}:${TRAFFIC_WEB_PORT}/${NC}" echo -e "${BOLD}登录密码:${NC} ${CYAN}${token}${NC}" echo "" echo -e "${YELLOW}注意:采集器需要几分钟才能积累连接数据,实时流水标签页才会显示内容。${NC}" echo -e "${YELLOW}提示:建议用 nginx 反代并套 TLS 以避免明文传输密码。${NC}" } traffic_web_show_url() { _traffic_load_conf local ip token port ip=$(get_ip) token="${WEB_TOKEN:-}" port="${WEB_PORT:-${TRAFFIC_WEB_PORT}}" if [[ -z "$token" ]]; then echo -e "${RED}Web 面板未安装,请先选择「安装 Web 面板」${NC}" return fi echo -e "\n${YELLOW}=== Web 面板访问信息 ===${NC}" echo -e "地址: ${CYAN}http://${ip}:${port}/${NC}" echo -e "密码: ${CYAN}${token}${NC}" echo -e "面板状态: $(check_status vps-traffic-web)" echo -e "采集器状态: $(check_status vps-traffic-collector)" } traffic_web_remove() { echo -e "\n${YELLOW}将完整卸载 Web 面板:停止面板与采集器,删除服务、程序目录、清理 cron 和采集辅助规则。${NC}" read -p "确认继续?[y/N]: " yn [[ "$yn" =~ ^[Yy]$ ]] || { echo -e "${YELLOW}已取消。${NC}"; return; } systemctl disable vps-traffic-web --now 2>/dev/null systemctl disable vps-traffic-collector --now 2>/dev/null _traffic_remove_iptables_rules _traffic_remove_conntrack_persistence rm -f /etc/systemd/system/vps-traffic-web.service rm -f /etc/systemd/system/vps-traffic-collector.service rm -rf "$TRAFFIC_WEB_DIR" _traffic_clear_web_conf systemctl daemon-reload read -p "是否同时删除历史流量数据库?[y/N]: " yn if [[ "$yn" =~ ^[Yy]$ ]]; then rm -rf /var/lib/vps-traffic echo -e "${GREEN}历史数据已删除。${NC}" fi # 移除清理 cron local cron_script="/usr/local/bin/vps-flows-cleanup.sh" crontab -l 2>/dev/null | grep -v "$cron_script" | crontab - 2>/dev/null rm -f "$cron_script" echo -e "${GREEN}Web 面板及采集器已卸载。${NC}" } traffic_web_set_offset() { _traffic_load_conf local iface iface=$(_traffic_get_iface) echo -e "\n${YELLOW}=== 设置本月已消耗流量偏移 ===${NC}" echo -e "当前偏移: 下行 ${CYAN}$(awk "BEGIN{printf \"%.2f GB\", ${OFFSET_RX:-0}/1073741824}")${NC} 上行 ${CYAN}$(awk "BEGIN{printf \"%.2f GB\", ${OFFSET_TX:-0}/1073741824}")${NC}" echo -e "${YELLOW}提示: 填入面板统计周期开始前已消耗的流量(如账单显示已用 280 GB,则填 280)${NC}" echo -e " 留空 = 保持原值,填 0 = 清零" read -p "本月已消耗下行 (GB,留空不变): " inp_rx read -p "本月已消耗上行 (GB,留空不变): " inp_tx if [[ -n "$inp_rx" ]]; then if [[ "$inp_rx" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then OFFSET_RX=$(awk "BEGIN{printf \"%d\", ${inp_rx}*1073741824}") else echo -e "${RED}无效数字,下行偏移未修改${NC}" fi fi if [[ -n "$inp_tx" ]]; then if [[ "$inp_tx" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then OFFSET_TX=$(awk "BEGIN{printf \"%d\", ${inp_tx}*1073741824}") else echo -e "${RED}无效数字,上行偏移未修改${NC}" fi fi _traffic_save_conf # 重写 server.py 并重启(保持原有其他参数不变) local token="${WEB_TOKEN:-}" port="${WEB_PORT:-${TRAFFIC_WEB_PORT}}" TRAFFIC_WEB_PORT="${port}" _web_write_server "$iface" "$QUOTA_GB" "$RESET_DAY" "$ALERT_PCT" "$token" systemctl restart vps-traffic-web 2>/dev/null echo -e "${GREEN}✓ 偏移已保存,面板已刷新。${NC}" echo -e " 新偏移: 下行 ${CYAN}$(awk "BEGIN{printf \"%.2f GB\", ${OFFSET_RX}/1073741824}")${NC} 上行 ${CYAN}$(awk "BEGIN{printf \"%.2f GB\", ${OFFSET_TX}/1073741824}")${NC}" } manage_traffic_web_menu() { while true; do echo -e "\n${BLUE}--- Web 流量面板 ---${NC}" echo -e "1. 安装/重装 Web 面板 (含采集器 + GeoIP)" echo -e "2. 查看访问地址" echo -e "3. 重启服务" echo -e "4. 查看面板日志" echo -e "5. 更新 GeoIP 数据库" echo -e "6. 查看采集器状态/日志" echo -e "7. 设置流量偏移(手动补录本月已消耗)" echo -e "8. 完整卸载 Web 面板" echo -e "0. 返回" read -p "请选择: " OPT case $OPT in 1) traffic_web_install ;; 2) traffic_web_show_url ;; 3) systemctl restart vps-traffic-web vps-traffic-collector && echo "已重启" ;; 4) journalctl -u vps-traffic-web -n 30 --no-pager ;; 5) _traffic_update_geoip ;; 6) echo -e "采集器状态: $(check_status vps-traffic-collector)"; journalctl -u vps-traffic-collector -n 30 --no-pager ;; 7) traffic_web_set_offset ;; 8) traffic_web_remove ;; 0) break ;; *) echo "无效选择" ;; esac done } # --- 主菜单 --- main_menu() { while true; do echo -e "\n${BLUE}=====================================${NC}" echo -e " 全能协议管理脚本 V3.13" echo -e "${BLUE}=====================================${NC}" echo -e "1. 安装/重置 Reality (TCP 443) [$(check_status xray)]" echo -e "2. 安装/重置 Hysteria2 (UDP 443)[$(check_status hysteria-server)]" echo -e "3. 安装/重置 Snell v5 (11807) [$(check_status snell)]" echo -e "-------------------------------------" echo -e "4. 管理 Reality (查看配置/二维码)" echo -e "5. 管理 Hysteria2" echo -e "6. 管理 Snell" echo -e "-------------------------------------" echo -e "7. 开启 BBR 加速" echo -e "8. 流量监控 (命令行)" echo -e "9. Web 流量面板" echo -e "-------------------------------------" echo -e "10. IPv6 管理 [$(ipv6_status 2>/dev/null | grep -o '已.*')]" echo -e "11. 完整卸载协议" echo -e "0. 退出脚本" echo -e "${BLUE}=====================================${NC}" read -p "请输入选项: " CHOICE case $CHOICE in 1) install_reality ;; 2) install_hy2 ;; 3) install_snell ;; 4) manage_reality_menu ;; 5) manage_hy2_menu ;; 6) manage_snell_menu ;; 7) enable_bbr ;; 8) manage_traffic_menu ;; 9) manage_traffic_web_menu ;; 10) echo -e "\n${BLUE}--- IPv6 管理 ---${NC}" ipv6_status echo "1. 禁用 IPv6" echo "2. 启用 IPv6" echo "0. 返回" read -p "选项: " V6CHOICE case $V6CHOICE in 1) disable_ipv6 ;; 2) enable_ipv6 ;; esac ;; 11) manage_uninstall_menu ;; 0) exit 0 ;; *) echo "无效选项"; sleep 1 ;; esac done } # --- 入口 --- check_root install_tools main_menu