极简v2ray流量统计面板
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.最后打开网页就是这样了:
登陆前:
登录后: