v2ray自带流量统计功能,执行如下语句就能获得实时累计流量(需打开流量统计配置,详见下面步骤1):


/usr/bin/v2ray/v2ray api stats --server=127.0.0.1:10085

    Value       Name
1   22.62KB     inbound>>>api>>>traffic>>>downlink
2   13.01KB     inbound>>>api>>>traffic>>>uplink
3   26.40MB     inbound>>>main>>>traffic>>>downlink
4   11.36MB     inbound>>>main>>>traffic>>>uplink
5   10.92MB     user>>>home>>>traffic>>>downlink
6   4.51MB      user>>>home>>>traffic>>>uplink
7   15.47MB     user>>>mobile>>>traffic>>>downlink
8   6.76MB      user>>>mobile>>>traffic>>>uplink

Total: 75.45MB

但是重启服务以后,流量就清零了,为了持久化,我做了一个极简的统计v2ray流量的web面板,支持按用户统计,每月初清零,并通过口令控制访问权限。

纯静态页面!只需要安装python3和任一一个web服务端即可,我这里用的是nginx。

步骤如下:

1.修改v2ray配置文件,增加流量统计(注意:配置文件中的email是必须的,用于区分不同用户的流量):


{
  "stats": {},
  "api": {
    "tag": "api",
    "services": [
      "StatsService"
    ]
  },
  "policy": {
    "levels": {
      "0": {
        "statsUserUplink": true,
        "statsUserDownlink": true
      }
    },
    "system": {
      "statsInboundUplink": true,
      "statsInboundDownlink": true
    }
  },
  "inbounds": [
    {
      "port": 23456,
      "listen":"127.0.0.1",
      "protocol": "vless",
      "settings": {
        "clients": [
          {
            "email": "user1",
            "id": "****************uuid1****************"
          },
          {
            "email": "user2",
            "id": "****************uuid2****************"
          },
          {
            "email": "user3",
            "id": "****************uuid3****************"
          }
        ],
        "decryption": "none"
      },
      "streamSettings": {
        "network": "ws",
        "wsSettings": {
        "path": "/mypath"
        }
      },
      "tag": "main"
    },
    {
      "tag": "api",
      "protocol": "dokodemo-door",
      "listen": "127.0.0.1",
      "port": 10085,
      "settings": {
        "address": "127.0.0.1"
      }
    }
  ],
  "outbounds": [
    {
      "protocol": "socks",
      "settings": {
        "servers": [
          {
            "address": "127.0.0.1",
            "port": 40000
          }
        ]
      },
      "tag": "warp"
    },
    {
      "protocol": "freedom",
      "tag": "direct"
    }
  ],
  "routing": {
    "domainStrategy": "AsIs",
    "rules": [
      {
        "type": "field",
        "inboundTag":["main"],
        "outboundTag": "direct",
        "domain": [
          "geosite:netflix",
          "geosite:youtube",
          "openai.com",
          "*.openai.com",
          "chatgpt.com",
          "*.chatgpt.com"
        ]
      },
      {
        "type": "field",
        "inboundTag":["main"],
        "outboundTag": "direct",
        "ip": [
            "127.0.0.1", 
            "::1",
            "8.8.8.8",
            "8.8.4.4",
            "1.1.1.1"
        ]
      },
      {
        "type":"field",
        "inboundTag":["main"],
        "outboundTag":"warp",
        "network":"tcp,udp"
      },
      {
        "type": "field",
        "inboundTag": ["api"],
        "outboundTag": "api"
      }
    ]
  }
}

2.在nginx页面根目录下创建index.html,我这里是ubuntu,路径是:/usr/share/nginx/html/ ,内容如下:


<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
    font-family: 'Arial', sans-serif;
    background: linear-gradient(135deg, #6fb1fc, #4364f7);
    margin: 0;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 20px;
}
.login-container, .stats-container {
    background: #fff;
    padding: 40px 30px;
    border-radius: 12px;
    box-shadow: 0 8px 20px rgba(0,0,0,0.2);
    width: 100%;
    max-width: 600px;
    text-align: center;
}
h2 {
    margin-bottom: 25px;
    color: #4364f7;
    font-size: 24px;
}
.input-group {
    position: relative;
    margin-bottom: 20px;
    width: 100%;
}
.input-group i {
    position: absolute;
    left: 12px;
    top: 50%;
    transform: translateY(-50%);
    color: #888;
    pointer-events: none;
}
input[type="password"] {
    width: 100%;
    padding: 12px 12px 12px 36px;
    border-radius: 6px;
    border: 1px solid #ccc;
    font-size: 16px;
    transition: border 0.3s, box-shadow 0.3s;
}
input[type="password"]:focus {
    outline: none;
    border-color: #4364f7;
    box-shadow: 0 0 5px rgba(67,100,247,0.5);
}
button {
    width: 100%;
    padding: 12px;
    background-color: #4364f7;
    border: none;
    border-radius: 6px;
    color: #fff;
    font-size: 16px;
    cursor: pointer;
    transition: background 0.3s, transform 0.2s;
}
button:hover {
    background-color: #6fb1fc;
    transform: translateY(-2px);
}
.footer {
    margin-top: 15px;
    font-size: 13px;
    color: #888;
}
.stats-content {
    text-align: left;
    max-height: 70vh;
    overflow: auto;
}
.stats-content table {
    width: 100%;
    border-collapse: collapse;
    margin-top: 20px;
}
.stats-content th, .stats-content td {
    border: 1px solid #ddd;
    padding: 8px;
}
.stats-content th {
    background: #4364f7;
    color: white;
}
.stats-content tr:nth-child(even) { background: #f9f9f9; }
.stats-content tr:hover { background: #e9f5ff; }
</style>
</head>
<body>
<div class="login-container" id="loginBox">
    <h2><i class="fas fa-shield-alt"></i> 登录</h2>
    <div class="input-group">
        <i class="fas fa-key"></i>
        <input type="password" id="key" placeholder="请输入口令">
    </div>
    <button onclick="decrypt()">验证</button>
    <div class="footer">请输入口令</div>
</div>

<div class="stats-container" id="statsBox" style="display:none;">
    <div class="stats-content" id="statsContent">
        <!-- 解密后的内容会显示在这里 -->
    </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script>
let lastAttempt = 0;          // 上次解密尝试时间(毫秒)
let attemptTimes = [];        // 最近尝试时间戳数组

const MAX_ATTEMPTS = 5;       // 1 分钟内最大尝试次数
const TIME_WINDOW = 60000;    // 1 分钟窗口(毫秒)
const MIN_INTERVAL = 2000;    // 最小间隔 2 秒

document.getElementById("key").addEventListener("keyup", function(event) {
    if (event.key === "Enter") decrypt();
});

async function decrypt() {
    const now = Date.now();

    // 限制连续点击间隔
    if (now - lastAttempt < MIN_INTERVAL) {
        alert("操作太频繁,请稍候再试");
        return;
    }
    lastAttempt = now;

    // 清理 1 分钟前的记录
    attemptTimes = attemptTimes.filter(t => now - t < TIME_WINDOW);

    // 检查 1 分钟内尝试次数
    if (attemptTimes.length >= MAX_ATTEMPTS) {
        alert("尝试次数过多,请稍候再试");
        return;
    }
    attemptTimes.push(now);

    const keyInput = document.getElementById("key").value;
    if (!keyInput) return alert("请输入口令");

    try {
        const resp = await fetch("encrypt.txt");
        const b64 = await resp.text();
        const raw = CryptoJS.enc.Base64.parse(b64);
        const rawHex = raw.toString(CryptoJS.enc.Hex);

        const iv = CryptoJS.enc.Hex.parse(rawHex.slice(0, 32));
        const ct = CryptoJS.enc.Hex.parse(rawHex.slice(32));

        const keyStr = keyInput.padEnd(16, "\0").slice(0,16);
        const key = CryptoJS.enc.Utf8.parse(keyStr);

        const decrypted = CryptoJS.AES.decrypt({ciphertext: ct}, key, {
            iv: iv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        const decoded = decrypted.toString(CryptoJS.enc.Utf8);
        if (!decoded) throw "验证失败";

        // 隐藏登录框,显示统计容器
        document.getElementById("loginBox").style.display = "none";
        const statsBox = document.getElementById("statsBox");
        statsBox.style.display = "block";

        // 防 XSS:只插入文本内容,不执行 HTML
        const statsContent = document.getElementById("statsContent");
        statsContent.textContent = decoded;

        // 使用 DOMParser + 创建元素方式,确保安全
        const parser = new DOMParser();
        const doc = parser.parseFromString(decoded, "text/html");
        const bodyElems = doc.body ? Array.from(doc.body.children) : [];
        statsContent.innerHTML = ''; // 清空
        bodyElems.forEach(el => statsContent.appendChild(el.cloneNode(true)));

    } catch(e) {
        alert("验证失败,口令错误");
        console.error(e);
    }
}
</script>
</body>
</html>

3.创建python文件v2ray-stats.py,放在/root下,文件中的key就是访问口令(默认123456),可以改成自己的:


#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad
from Cryptodome.Random import get_random_bytes
import base64
import subprocess
import datetime
import os

API_CMD = ["/usr/bin/v2ray/v2ray", "api", "stats", "--server=127.0.0.1:10085"]
DATA_FILE = "/var/lib/v2ray-stats/data.db"
HTML_FILE = "/root/plaintext.html"
OUTPUT_FILE = "/usr/share/nginx/html/encrypt.txt"
#登录密钥
key = b"123456"

os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
now = datetime.datetime.now()

# 每月1日0点清零 data.db
if now.day == 1 and now.hour == 0:
    open(DATA_FILE, "w").close()
    subprocess.run(["systemctl", "restart", "v2ray"], check=True)

# 读取旧数据
OLD = {}  # name -> (total_bytes, last_api_bytes)
if os.path.exists(DATA_FILE):
    with open(DATA_FILE, "r") as f:
        for line in f:
            parts = line.strip().split()
            if len(parts) == 3:
                name, total_str, last_str = parts
                try:
                    OLD[name] = (int(total_str), int(last_str))
                except:
                    OLD[name] = (0, 0)

# 调用 V2Ray API 获取本次累计流量
result = subprocess.run(API_CMD, capture_output=True, text=True)
lines = result.stdout.strip().splitlines()
stats_lines = lines[1:]  # 跳过标题

for line in stats_lines:
    line = line.strip()
    if line.startswith("Total:"):
        continue
    parts = line.split()
    if len(parts) < 2:
        continue
    value = parts[1]
    name = " ".join(parts[2:]) if len(parts) > 2 else ""
    name = name.replace(">", "-")

    # 转换为字节
    try:
        num = float(value[:-2])
        unit = value[-2:]
    except:
        continue

    if unit == "B":
        current_bytes = int(num)
    elif unit == "KB":
        current_bytes = int(num * 1024)
    elif unit == "MB":
        current_bytes = int(num * 1024 * 1024)
    elif unit == "GB":
        current_bytes = int(num * 1024 * 1024 * 1024)
    else:
        current_bytes = 0

    total_prev, last_api_prev = OLD.get(name, (0, 0))
    delta = current_bytes - last_api_prev
    if delta < 0:  # V2Ray 重启,API 累计从0开始
        delta = current_bytes
    total_new = total_prev + delta
    OLD[name] = (total_new, current_bytes)

# 保存最新累计到 data.db
with open(DATA_FILE, "w") as f:
    for name, (total_bytes, last_api_bytes) in OLD.items():
        f.write(f"{name} {total_bytes} {last_api_bytes}\n")

# 自动单位显示函数
def format_bytes(b):
    if b < 102.4:  # 小于0.1 KB
        return f"{b:.2f} B"
    elif b < 1024*102.4:  # 小于0.1 MB
        return f"{b/1024:.2f} KB"
    elif b < 1024*1024*102.4:  # 小于0.1 GB
        return f"{b/1024/1024:.2f} MB"
    else:
        return f"{b/1024/1024/1024:.2f} GB"

# 生成 HTML
html = """
<html>
<head>
<meta charset="utf-8">
<title>本月流量统计</title>
<style>
body { font-family: Arial, sans-serif; background:#f4f6f9; color:#333; margin:40px; }
h2 { color:#007BFF; }
table { border-collapse: collapse; width: 60%; margin-top:20px; background:#fff; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background:#007BFF; color:white; }
tr:nth-child(even) { background:#f9f9f9; }
tr:hover { background:#e9f5ff; }
.footer { margin-top:20px; font-size: 0.9em; color:#666; }
</style>
<meta http-equiv="refresh" content="60">
</head>
<body>
<h2>📊 本月流量统计</h2>
<table>
<tr><th>名称</th><th>累计流量</th></tr>
"""

total_all = 0
for name in sorted(OLD.keys()):
    total_bytes, _ = OLD[name]
    html += f"<tr><td>{name}</td><td>{format_bytes(total_bytes)}</td></tr>\n"
    total_all += total_bytes

html += f"<tr><td>总计</td><td>{format_bytes(total_all)}</td></tr>\n"
html += f"""
</table>
<div class="footer">更新时间(UTC): {now.strftime('%Y-%m-%d %H:%M:%S')} (页面每60秒自动刷新)</div>
</body>
</html>
"""

with open(HTML_FILE, "w", encoding="utf-8") as f:
    f.write(html)

# 输出密文
ENC_FILE = OUTPUT_FILE

# ----------------------
# AES-CBC 加密
# ----------------------
# key 长度 16 字节,不足补零
key_bytes = key.ljust(16, b'\0')
iv = get_random_bytes(16)
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
ct = cipher.encrypt(pad(html.encode("utf-8"), AES.block_size))

# IV + 密文 Base64 编码并以二进制写入文件(跨平台一致)
with open(ENC_FILE, "wb") as f:
    f.write(base64.b64encode(iv + ct))

print("生成并加密完成 -> encrypt.txt")

4.安装及配置自动运行,以ubuntu为例


touch /var/lib/v2ray-stats/data.db
sudo apt-get update
sudo apt-get install python3 python3-pycryptodome

另外执行crontab -e,最后一行添加自动运行:


* * * * * python3 /root/v2ray-stats.py

5.最后打开网页就是这样了:

登陆前:

登录页面

登录后:

统计页面