Compare commits

..
4 Commits
Author SHA1 Message Date
kevin 8398067acb feat: 连接列表显示客户端真实 IP,支持反代和 CDN 场景
新增可配置的 real_ip_headers(默认 CF-Connecting-IP > X-Real-IP >
X-Forwarded-For 降级取值),trusted_proxies 用于 gin 代理信任链。
WebSocket 连接建立时捕获真实 IP 并在 /profile 和 /admin 连接列表展示,
登录会话 IP 同步改用真实 IP。
2026-07-10 16:58:14 +08:00
kevin a98cdb0cac fix: 禁止管理员通过 UpdateUser 禁用自己的账号 2026-07-10 14:22:16 +08:00
kevin 9fda231d47 fix: 修复踢下线竞态窗口,先禁用再踢线防重连
KickUserClient 原先先踢线再禁用账号,客户端在两步之间自动重连时
status 仍为 1 可成功认证。调换顺序为先禁用+作废会话再踢线,确保
重连时认证已被拦截。
2026-07-10 14:21:59 +08:00
kevin 50e1661c1b feat: 实现每用户流量统计与定时落库
流量统计从全站按日聚合升级为 per-user 维度,并引入 60 秒定时
落库机制,服务崩溃最多丢失 60 秒数据。

后端:
- model/vpn.go: 新增 UserTrafficStat 模型(user_id + date 联合唯一索引)
- db/db.go: AutoMigrate 注册新模型
- vpn/tunnel.go: tunnelConn 增加 flushedRx/flushedTx 快照字段 +
  flushDelta() 增量计算;recordTraffic 改为 per-user 双写
  (user_traffic_stats + traffic_stats);连接断开 defer 改增量落库
- vpn/service.go: 新增 flushDone 字段 + trafficFlusher(60s 定时
  落库)+ flushAllTraffic;Stop() 先停 flusher 再最终 flush 全部
  在线连接增量;TotalLiveTraffic 改返回未落库增量避免与 DB 重复;
  新增 UserLiveTraffic(userID);ClientInfo 增加 RxBytes/TxBytes
- handler/traffic.go: 新建 5 个 handler(admin 3 个 + user 2 个)
- router.go: 注册 5 条新路由

新增 API:
- GET /api/admin/traffic/today  所有用户今日流量排行
- GET /api/admin/traffic/history?days=N  全站近 N 天流量历史
- GET /api/admin/traffic/users/:id?days=N  指定用户近 N 天流量
- GET /api/me/traffic/today  自己的今日流量
- GET /api/me/traffic?days=N  自己的近 N 天流量

前端:
- 安装 chart.js + vue-chartjs
- 新建 TrafficChart.vue 可复用柱状图组件(上行/下行双柱)
- AdminView.vue: 在线客户端表格增加 RX/TX 列 + 全站 7 天流量
  图表 + 用户今日流量排行表
- ProfileView.vue: 新增流量统计卡片(今日上行/下行/合计)+ 7 天
  流量柱状图
- zh.ts/en.ts: 新增 traffic 相关国际化
2026-07-10 14:18:06 +08:00
21 changed files with 715 additions and 21 deletions
+30
View File
@@ -8,8 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
@@ -625,6 +627,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1734,6 +1742,18 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -3405,6 +3425,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-i18n": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
+2
View File
@@ -11,8 +11,10 @@
"type-check": "vue-tsc --build"
},
"dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4",
"vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const { t } = useI18n()
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const props = defineProps<{
records: TrafficRecord[]
}>()
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
const chartData = computed(() => ({
labels: props.records.map(r => r.date.slice(5)),
datasets: [
{
label: t('traffic.upload'),
data: props.records.map(r => r.rx_bytes),
backgroundColor: 'rgba(14, 165, 233, 0.6)',
borderColor: 'rgba(14, 165, 233, 1)',
borderWidth: 1,
},
{
label: t('traffic.download'),
data: props.records.map(r => r.tx_bytes),
backgroundColor: 'rgba(34, 197, 94, 0.6)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 1,
},
],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
return `${label}: ${formatBytes(context.parsed.y)}`
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value: any) => formatBytes(Number(value)),
},
},
},
}
</script>
<template>
<div class="h-64">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
+13
View File
@@ -95,6 +95,18 @@ export default {
'Are you sure you want to delete user {username}? This action cannot be undone.',
confirmDeleteButton: 'Confirm Delete',
},
traffic: {
myTraffic: 'My Traffic',
userTrafficToday: "User Traffic Today",
todayTraffic: "Today's Traffic",
upload: 'Upload',
download: 'Download',
total: 'Total',
history: 'Traffic History',
date: 'Date',
noTrafficData: 'No traffic data',
trafficHistory7d: 'Traffic - Last 7 Days',
},
vpn: {
title: 'VPN Management',
refresh: 'Refresh',
@@ -142,6 +154,7 @@ export default {
user: 'User',
ipv4: 'IPv4',
ipv6: 'IPv6',
realIp: 'Real IP',
connectTime: 'Connected At',
noOnlineClients: 'No online clients',
staticIpReservation: 'Static IP Reservation',
+13
View File
@@ -94,6 +94,18 @@ export default {
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
confirmDeleteButton: '确认删除',
},
traffic: {
myTraffic: '我的流量统计',
userTrafficToday: '用户今日流量',
todayTraffic: '今日流量',
upload: '上行',
download: '下行',
total: '合计',
history: '流量历史',
date: '日期',
noTrafficData: '暂无流量数据',
trafficHistory7d: '近 7 天流量',
},
vpn: {
title: 'VPN 管理',
refresh: '刷新',
@@ -141,6 +153,7 @@ export default {
user: '用户',
ipv4: 'IPv4',
ipv6: 'IPv6',
realIp: '真实 IP',
connectTime: '连接时间',
noOnlineClients: '暂无在线客户端',
staticIpReservation: '静态 IP 预留',
+89 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const router = useRouter()
const authStore = useAuthStore()
@@ -24,11 +25,37 @@ interface ClientInfo {
username: string
ip: string
ip6?: string
real_ip: string
connected_at: string
rx_bytes: number
tx_bytes: number
}
const vpnClients = ref<ClientInfo[]>([])
const kickError = ref('')
interface UserTraffic {
user_id: number
username: string
rx_bytes: number
tx_bytes: number
total_bytes: number
}
const userTrafficToday = ref<UserTraffic[]>([])
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const siteTraffic7d = ref<TrafficRecord[]>([])
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
function formatUptime(seconds: number): string {
if (seconds <= 0) return '0m'
const d = Math.floor(seconds / 86400)
@@ -82,6 +109,28 @@ async function fetchVpnStatus() {
} catch {}
}
async function fetchTrafficToday() {
try {
const res = await fetch('/api/admin/traffic/today', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
userTrafficToday.value = (data.users || []).sort((a: UserTraffic, b: UserTraffic) => b.total_bytes - a.total_bytes)
} catch {}
}
async function fetchSiteTraffic7d() {
try {
const res = await fetch('/api/admin/traffic/history?days=7', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
siteTraffic7d.value = data.records || []
} catch {}
}
async function handleKick(userId: number, username: string) {
kickError.value = ''
if (userId === authStore.user?.id) {
@@ -107,9 +156,12 @@ onMounted(async () => {
fetchUserCount()
fetchStats()
fetchVpnStatus()
fetchTrafficToday()
fetchSiteTraffic7d()
statsTimer = setInterval(() => {
fetchStats()
fetchVpnStatus()
fetchTrafficToday()
}, 30000)
})
@@ -164,21 +216,27 @@ function handleStatClick(route: string) {
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.user') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.realIp') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!vpnClients.length">
<td colspan="5" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
<td colspan="8" class="px-6 py-6 text-center text-gray-400">{{ t('vpn.noOnlineClients') }}</td>
</tr>
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ c.username }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.real_ip || '-' }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.rx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(c.tx_bytes) }}</td>
<td class="px-6 py-3">
<button
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 transition-colors"
@@ -192,5 +250,35 @@ function handleStatClick(route: string) {
</table>
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.trafficHistory7d') }}</h3>
<TrafficChart :records="siteTraffic7d" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white p-6 pb-4">{{ t('traffic.userTrafficToday') }}</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.username') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.upload') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.download') }}</th>
<th class="px-6 py-3 text-right font-medium text-gray-500 dark:text-gray-400">{{ t('traffic.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="!userTrafficToday.length">
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('traffic.noTrafficData') }}</td>
</tr>
<tr v-for="(u, i) in userTrafficToday" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-900 dark:text-white font-medium">{{ u.username }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.rx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-700 dark:text-gray-300 tabular-nums">{{ formatBytes(u.tx_bytes) }}</td>
<td class="px-6 py-3 text-right text-gray-900 dark:text-white font-medium tabular-nums">{{ formatBytes(u.total_bytes) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
+55 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -11,11 +12,41 @@ const { t } = useI18n()
interface VpnConnection {
ip: string
ip6?: string
real_ip: string
connected_at: string
}
const vpnConnections = ref<VpnConnection[]>([])
const maxConns = ref(30)
interface TrafficRecord {
date: string
rx_bytes: number
tx_bytes: number
}
const myTraffic7d = ref<TrafficRecord[]>([])
const todayRx = ref(0)
const todayTx = ref(0)
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
async function fetchMyTraffic() {
try {
const res = await fetch('/api/me/traffic?days=7', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
if (!res.ok) return
const data = await res.json()
myTraffic7d.value = data.records || []
todayRx.value = data.today_rx_bytes || 0
todayTx.value = data.today_tx_bytes || 0
} catch {}
}
async function fetchVpnConnections() {
try {
const res = await fetch('/api/me/vpn/connections', {
@@ -31,6 +62,7 @@ async function fetchVpnConnections() {
onMounted(async () => {
await authStore.fetchUser()
fetchVpnConnections()
fetchMyTraffic()
})
const showPasswordModal = ref(false)
@@ -94,6 +126,26 @@ async function handleChangePassword() {
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t('traffic.myTraffic') }}</h3>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.upload') }}</p>
<p class="text-lg font-bold text-sky-600 dark:text-sky-400 tabular-nums">{{ formatBytes(todayRx) }}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.download') }}</p>
<p class="text-lg font-bold text-green-600 dark:text-green-400 tabular-nums">{{ formatBytes(todayTx) }}</p>
</div>
<div class="text-center">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">{{ t('traffic.total') }}</p>
<p class="text-lg font-bold text-gray-900 dark:text-white tabular-nums">{{ formatBytes(todayRx + todayTx) }}</p>
</div>
</div>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{{ t('traffic.trafficHistory7d') }}</h4>
<TrafficChart :records="myTraffic7d" />
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden mb-6">
<div class="flex items-center justify-between p-6 pb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('profile.myVpnConnections') }}</h3>
@@ -104,6 +156,7 @@ async function handleChangePassword() {
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.realIp') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv4') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.ipv6') }}</th>
<th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
@@ -111,9 +164,10 @@ async function handleChangePassword() {
</thead>
<tbody>
<tr v-if="!vpnConnections.length">
<td colspan="3" class="px-6 py-6 text-center text-gray-400">{{ t('profile.noConnections') }}</td>
<td colspan="4" class="px-6 py-6 text-center text-gray-400">{{ t('profile.noConnections') }}</td>
</tr>
<tr v-for="(c, i) in vpnConnections" :key="i" class="border-b border-gray-100 dark:border-gray-700/50">
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.real_ip || '-' }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip }}</td>
<td class="px-6 py-3 text-gray-700 dark:text-gray-300">{{ c.ip6 || '-' }}</td>
<td class="px-6 py-3 text-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
+1
View File
@@ -23,6 +23,7 @@ interface ClientInfo {
username: string
ip: string
ip6?: string
real_ip: string
connected_at: string
}
interface Status {
+3
View File
@@ -16,6 +16,8 @@ type WebConfig struct {
SockGroup string `yaml:"sock_group"`
SockDirMode string `yaml:"sock_dir_mode"`
JWTSecret string `yaml:"jwt_secret"`
RealIPHeaders []string `yaml:"real_ip_headers"`
TrustedProxies []string `yaml:"trusted_proxies"`
}
type DatabaseConfig struct {
@@ -36,6 +38,7 @@ func defaultConfig() *Config {
Sock: "web.sock",
SockMode: "0666",
SockDirMode: "0755",
RealIPHeaders: []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"},
},
Database: DatabaseConfig{
Type: "sqlite",
+1 -1
View File
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
return fmt.Errorf("数据库连接失败: %w", err)
}
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}); err != nil {
if err := DB.AutoMigrate(&model.User{}, &model.Session{}, &model.VpnSetting{}, &model.VpnReservation{}, &model.TrafficStat{}, &model.UserTrafficStat{}); err != nil {
return fmt.Errorf("数据库迁移失败: %w", err)
}
+1 -1
View File
@@ -80,7 +80,7 @@ func Login(c *gin.Context) {
session := model.Session{
SessionID: sessionID,
UserID: user.ID,
IP: c.ClientIP(),
IP: middleware.GetRealIP(c),
UserAgent: c.GetHeader("User-Agent"),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
+255
View File
@@ -0,0 +1,255 @@
package handler
import (
"net/http"
"strconv"
"time"
"lmvpn/internal/db"
"lmvpn/internal/model"
"lmvpn/internal/vpn"
"github.com/gin-gonic/gin"
)
type userTrafficItem struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
TotalBytes int64 `json:"total_bytes"`
}
type trafficRecord struct {
Date string `json:"date"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func parseDays(c *gin.Context) int {
days := 7
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
days = n
}
}
return days
}
func GetAdminTrafficToday(c *gin.Context) {
today := time.Now().Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("date = ?", today).Find(&stats)
userIDs := make([]uint, 0, len(stats))
for _, s := range stats {
userIDs = append(userIDs, s.UserID)
}
nameMap := make(map[uint]string)
if len(userIDs) > 0 {
var users []model.User
db.DB.Where("id IN ?", userIDs).Find(&users)
for _, u := range users {
nameMap[u.ID] = u.Username
}
}
items := make([]userTrafficItem, 0, len(stats))
var totalRx, totalTx int64
seen := make(map[uint]bool)
for _, s := range stats {
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(s.UserID)
}
rx := s.RxBytes + liveRx
tx := s.TxBytes + liveTx
items = append(items, userTrafficItem{
UserID: s.UserID,
Username: nameMap[s.UserID],
RxBytes: rx,
TxBytes: tx,
TotalBytes: rx + tx,
})
totalRx += rx
totalTx += tx
seen[s.UserID] = true
}
if vpn.VPN != nil && vpn.VPN.Running() {
for _, ci := range vpn.VPN.ClientList() {
if seen[ci.UserID] {
continue
}
liveRx, liveTx := vpn.VPN.UserLiveTraffic(ci.UserID)
if liveRx == 0 && liveTx == 0 {
continue
}
var u model.User
if err := db.DB.First(&u, ci.UserID).Error; err != nil {
continue
}
items = append(items, userTrafficItem{
UserID: ci.UserID,
Username: u.Username,
RxBytes: liveRx,
TxBytes: liveTx,
TotalBytes: liveRx + liveTx,
})
totalRx += liveRx
totalTx += liveTx
seen[ci.UserID] = true
}
}
c.JSON(http.StatusOK, gin.H{
"total_rx_bytes": totalRx,
"total_tx_bytes": totalTx,
"users": items,
})
}
func GetAdminTrafficHistory(c *gin.Context) {
days := parseDays(c)
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.TrafficStat
db.DB.Where("date >= ?", startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
today := time.Now().Format("2006-01-02")
var todayStat model.TrafficStat
db.DB.Where("date = ?", today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.TotalLiveTraffic()
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"records": out,
})
}
func GetAdminUserTraffic(c *gin.Context) { idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
var user model.User
if err := db.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
days := parseDays(c)
records := queryUserTraffic(uint(id), days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", id, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uint(id))
}
c.JSON(http.StatusOK, gin.H{
"user_id": user.ID,
"username": user.Username,
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func GetMyTrafficToday(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
today := time.Now().Format("2006-01-02")
var stat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&stat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"rx_bytes": stat.RxBytes + liveRx,
"tx_bytes": stat.TxBytes + liveTx,
})
}
func GetMyTrafficHistory(c *gin.Context) {
userID, _ := c.Get("user_id")
uid := userID.(uint)
days := parseDays(c)
records := queryUserTraffic(uid, days)
today := time.Now().Format("2006-01-02")
var todayStat model.UserTrafficStat
db.DB.Where("user_id = ? AND date = ?", uid, today).First(&todayStat)
liveRx, liveTx := int64(0), int64(0)
if vpn.VPN != nil && vpn.VPN.Running() {
liveRx, liveTx = vpn.VPN.UserLiveTraffic(uid)
}
c.JSON(http.StatusOK, gin.H{
"today_rx_bytes": todayStat.RxBytes + liveRx,
"today_tx_bytes": todayStat.TxBytes + liveTx,
"today_live_rx": liveRx,
"today_live_tx": liveTx,
"records": records,
})
}
func queryUserTraffic(userID uint, days int) []trafficRecord {
startDate := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
var stats []model.UserTrafficStat
db.DB.Where("user_id = ? AND date >= ?", userID, startDate).Order("date asc").Find(&stats)
dateMap := make(map[string]trafficRecord, len(stats))
for _, s := range stats {
dateMap[s.Date] = trafficRecord{
Date: s.Date,
RxBytes: s.RxBytes,
TxBytes: s.TxBytes,
}
}
out := make([]trafficRecord, 0, days)
for i := days - 1; i >= 0; i-- {
d := time.Now().AddDate(0, 0, -i).Format("2006-01-02")
if r, ok := dateMap[d]; ok {
out = append(out, r)
} else {
out = append(out, trafficRecord{Date: d})
}
}
return out
}
+4
View File
@@ -144,6 +144,10 @@ func UpdateUser(c *gin.Context) {
updates := map[string]interface{}{}
if req.Status != nil {
if user.ID == currentUserID.(uint) && *req.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
return
}
updates["status"] = *req.Status
}
+4 -3
View File
@@ -211,6 +211,7 @@ func UpdateVpnSettings(c *gin.Context) {
type myVpnConnection struct {
IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"`
RealIP string `json:"real_ip"`
ConnectedAt string `json:"connected_at"`
}
@@ -232,6 +233,7 @@ func GetMyVpnConnections(c *gin.Context) {
connections = append(connections, myVpnConnection{
IP: ci.IP,
IP6: ci.IP6,
RealIP: ci.RealIP,
ConnectedAt: ci.ConnectedAt,
})
}
@@ -474,12 +476,11 @@ func KickUserClient(c *gin.Context) {
}
n := 0
db.DB.Model(&user).Update("status", 0)
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
if vpn.VPN != nil && vpn.VPN.Running() {
n = vpn.VPN.KickUser(uint(id))
}
db.DB.Model(&user).Update("status", 0)
db.DB.Model(&model.Session{}).Where("user_id = ?", id).Update("invalid", true)
c.JSON(http.StatusOK, gin.H{"message": "已断开用户连接并禁用账号", "kicked": n})
}
+28
View File
@@ -0,0 +1,28 @@
package middleware
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
var realIPHeaders []string
func SetRealIPHeaders(headers []string) {
realIPHeaders = headers
}
func GetRealIP(c *gin.Context) string {
for _, header := range realIPHeaders {
val := c.GetHeader(header)
if val == "" {
continue
}
ip := strings.TrimSpace(strings.Split(val, ",")[0])
if ip != "" && net.ParseIP(ip) != nil {
return ip
}
}
return c.ClientIP()
}
+13
View File
@@ -45,3 +45,16 @@ type TrafficStat struct {
func (TrafficStat) TableName() string {
return "traffic_stats"
}
type UserTrafficStat struct {
ID uint `gorm:"primaryKey;autoIncrement"`
UserID uint `gorm:"uniqueIndex:idx_user_date;not null"`
Date string `gorm:"uniqueIndex:idx_user_date;size:10;not null"`
RxBytes int64 `gorm:"default:0"`
TxBytes int64 `gorm:"default:0"`
UpdatedAt time.Time
}
func (UserTrafficStat) TableName() string {
return "user_traffic_stats"
}
+6
View File
@@ -27,6 +27,8 @@ func Setup(r *gin.Engine) {
auth.GET("/me/sessions", handler.ListMySessions)
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections)
auth.GET("/me/traffic/today", handler.GetMyTrafficToday)
auth.GET("/me/traffic", handler.GetMyTrafficHistory)
}
admin := r.Group("/api/admin")
@@ -48,6 +50,10 @@ func Setup(r *gin.Engine) {
admin.POST("/vpn/reservations", handler.CreateVpnReservation)
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
admin.DELETE("/vpn/clients/:id", handler.KickUserClient)
admin.GET("/traffic/today", handler.GetAdminTrafficToday)
admin.GET("/traffic/history", handler.GetAdminTrafficHistory)
admin.GET("/traffic/users/:id", handler.GetAdminUserTraffic)
}
distDir := http.Dir("./dist")
+4 -3
View File
@@ -31,6 +31,7 @@ var upgrader = websocket.Upgrader{
func HandleWS(c *gin.Context) {
tokenStr := c.Query("token")
realIP := middleware.GetRealIP(c)
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
@@ -51,11 +52,11 @@ func HandleWS(c *gin.Context) {
conn.Close()
return
}
runTunnel(conn, &u)
runTunnel(conn, &u, realIP)
return
}
user, err := authenticate(conn, db.DB, c.ClientIP())
user, err := authenticate(conn, db.DB, realIP)
if err != nil {
log.Printf("认证读取失败: %v", err)
conn.Close()
@@ -65,5 +66,5 @@ func HandleWS(c *gin.Context) {
return
}
runTunnel(conn, user)
runTunnel(conn, user, realIP)
}
+62 -2
View File
@@ -27,6 +27,7 @@ type VpnService struct {
switchx *PacketSwitch
tun *TUNInterface
tunDone chan struct{}
flushDone chan struct{}
running bool
startedAt time.Time
clients map[*tunnelConn]struct{}
@@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time {
return s.startedAt
}
const trafficFlushPeriod = 60 * time.Second
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
s.mu.RLock()
for c := range s.clients {
rx += c.rxBytes.Load()
tx += c.txBytes.Load()
rx += c.rxBytes.Load() - c.flushedRx.Load()
tx += c.txBytes.Load() - c.flushedTx.Load()
}
s.mu.RUnlock()
return
}
func (s *VpnService) UserLiveTraffic(userID uint) (rx, tx int64) {
s.mu.RLock()
for c := range s.clients {
if c.user.ID == userID {
rx += c.rxBytes.Load() - c.flushedRx.Load()
tx += c.txBytes.Load() - c.flushedTx.Load()
}
}
s.mu.RUnlock()
return
}
func (s *VpnService) trafficFlusher() {
ticker := time.NewTicker(trafficFlushPeriod)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flushAllTraffic()
case <-s.flushDone:
return
}
}
}
func (s *VpnService) flushAllTraffic() {
s.mu.RLock()
conns := make([]*tunnelConn, 0, len(s.clients))
for c := range s.clients {
conns = append(conns, c)
}
s.mu.RUnlock()
for _, c := range conns {
rx, tx := c.flushDelta()
if rx > 0 || tx > 0 {
recordTraffic(c.user.ID, rx, tx)
}
}
}
func (s *VpnService) Settings() model.VpnSetting {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -150,11 +194,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.switchx = NewPacketSwitch(settings.AllowClientToClient)
s.tun = tun
s.tunDone = make(chan struct{})
s.flushDone = make(chan struct{})
s.running = true
s.startedAt = time.Now()
s.mu.Unlock()
go s.serveTUN()
go s.trafficFlusher()
subnet4 := ipNet.String()
var subnet6Str string
@@ -205,10 +251,21 @@ func (s *VpnService) Stop() error {
s.running = false
tun := s.tun
done := s.tunDone
flushDone := s.flushDone
s.flushDone = nil
clients := s.clients
s.clients = make(map[*tunnelConn]struct{})
s.mu.Unlock()
if flushDone != nil {
close(flushDone)
}
for c := range clients {
rx, tx := c.flushDelta()
if rx > 0 || tx > 0 {
recordTraffic(c.user.ID, rx, tx)
}
}
for c := range clients {
c.close()
}
@@ -378,7 +435,10 @@ type ClientInfo struct {
Username string `json:"username"`
IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"`
RealIP string `json:"real_ip"`
ConnectedAt string `json:"connected_at"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
}
func (s *VpnService) KickUser(userID uint) int {
+31 -3
View File
@@ -35,16 +35,27 @@ type tunnelConn struct {
svc *VpnService
assignedIP net.IP
assignedIP6 net.IP
realIP string
connectedAt time.Time
writeMu sync.Mutex
ready atomic.Bool
rxBytes atomic.Int64
txBytes atomic.Int64
flushedRx atomic.Int64
flushedTx atomic.Int64
}
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 }
func (c *tunnelConn) flushDelta() (rx, tx int64) {
curRx := c.rxBytes.Load()
curTx := c.txBytes.Load()
rx = curRx - c.flushedRx.Swap(curRx)
tx = curTx - c.flushedTx.Swap(curTx)
return
}
func (c *tunnelConn) WritePacket(data []byte) error {
if !c.ready.Load() || len(data) == 0 {
return nil
@@ -81,7 +92,10 @@ func (c *tunnelConn) info() ClientInfo {
UserID: c.user.ID,
Username: c.user.Username,
IP: c.assignedIP.String(),
RealIP: c.realIP,
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
RxBytes: c.rxBytes.Load(),
TxBytes: c.txBytes.Load(),
}
if c.assignedIP6 != nil {
ci.IP6 = c.assignedIP6.String()
@@ -89,7 +103,7 @@ func (c *tunnelConn) info() ClientInfo {
return ci
}
func runTunnel(conn *websocket.Conn, user *model.User) {
func runTunnel(conn *websocket.Conn, user *model.User, realIP string) {
defer conn.Close()
if VPN == nil || !VPN.Running() {
@@ -131,12 +145,14 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
svc: VPN,
assignedIP: ip4,
assignedIP6: ip6,
realIP: realIP,
connectedAt: time.Now(),
}
VPN.registerClient(tc)
defer func() {
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load())
rx, tx := tc.flushDelta()
recordTraffic(tc.user.ID, rx, tx)
VPN.unregisterClient(tc)
}()
@@ -236,11 +252,23 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
}
}
func recordTraffic(rx, tx int64) {
func recordTraffic(userID uint, rx, tx int64) {
if rx == 0 && tx == 0 {
return
}
today := time.Now().Format("2006-01-02")
userStat := model.UserTrafficStat{UserID: userID, Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"rx_bytes": gorm.Expr("rx_bytes + ?", rx),
"tx_bytes": gorm.Expr("tx_bytes + ?", tx),
}),
}).Create(&userStat).Error; err != nil {
log.Printf("记录用户流量失败: %v", err)
}
stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}},
+7
View File
@@ -26,6 +26,7 @@ func main() {
}
middleware.SetJWTSecret(cfg.Web.JWTSecret)
middleware.SetRealIPHeaders(cfg.Web.RealIPHeaders)
if err := db.Init(&cfg.Database); err != nil {
log.Fatalf("数据库初始化失败: %v", err)
@@ -38,6 +39,12 @@ func main() {
r := gin.Default()
if len(cfg.Web.TrustedProxies) > 0 {
_ = r.SetTrustedProxies(cfg.Web.TrustedProxies)
} else {
_ = r.SetTrustedProxies(nil)
}
router.Setup(r)
if cfg.Web.Port == 0 && cfg.Web.Sock == "" {