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", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.38", "vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6", "vue-i18n": "^11.4.6",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
}, },
@@ -625,6 +627,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1734,6 +1742,18 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "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": { "node_modules/vue-i18n": {
"version": "11.4.6", "version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz", "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" "type-check": "vue-tsc --build"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.5.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.38", "vue": "^3.5.38",
"vue-chartjs": "^5.3.4",
"vue-i18n": "^11.4.6", "vue-i18n": "^11.4.6",
"vue-router": "^5.1.0" "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.', 'Are you sure you want to delete user {username}? This action cannot be undone.',
confirmDeleteButton: 'Confirm Delete', 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: { vpn: {
title: 'VPN Management', title: 'VPN Management',
refresh: 'Refresh', refresh: 'Refresh',
@@ -142,6 +154,7 @@ export default {
user: 'User', user: 'User',
ipv4: 'IPv4', ipv4: 'IPv4',
ipv6: 'IPv6', ipv6: 'IPv6',
realIp: 'Real IP',
connectTime: 'Connected At', connectTime: 'Connected At',
noOnlineClients: 'No online clients', noOnlineClients: 'No online clients',
staticIpReservation: 'Static IP Reservation', staticIpReservation: 'Static IP Reservation',
+13
View File
@@ -94,6 +94,18 @@ export default {
confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。', confirmDeleteMessage: '确定要删除用户 {username} 吗?此操作不可撤销。',
confirmDeleteButton: '确认删除', confirmDeleteButton: '确认删除',
}, },
traffic: {
myTraffic: '我的流量统计',
userTrafficToday: '用户今日流量',
todayTraffic: '今日流量',
upload: '上行',
download: '下行',
total: '合计',
history: '流量历史',
date: '日期',
noTrafficData: '暂无流量数据',
trafficHistory7d: '近 7 天流量',
},
vpn: { vpn: {
title: 'VPN 管理', title: 'VPN 管理',
refresh: '刷新', refresh: '刷新',
@@ -141,6 +153,7 @@ export default {
user: '用户', user: '用户',
ipv4: 'IPv4', ipv4: 'IPv4',
ipv6: 'IPv6', ipv6: 'IPv6',
realIp: '真实 IP',
connectTime: '连接时间', connectTime: '连接时间',
noOnlineClients: '暂无在线客户端', noOnlineClients: '暂无在线客户端',
staticIpReservation: '静态 IP 预留', staticIpReservation: '静态 IP 预留',
+89 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -24,11 +25,37 @@ interface ClientInfo {
username: string username: string
ip: string ip: string
ip6?: string ip6?: string
real_ip: string
connected_at: string connected_at: string
rx_bytes: number
tx_bytes: number
} }
const vpnClients = ref<ClientInfo[]>([]) const vpnClients = ref<ClientInfo[]>([])
const kickError = ref('') 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 { function formatUptime(seconds: number): string {
if (seconds <= 0) return '0m' if (seconds <= 0) return '0m'
const d = Math.floor(seconds / 86400) const d = Math.floor(seconds / 86400)
@@ -82,6 +109,28 @@ async function fetchVpnStatus() {
} catch {} } 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) { async function handleKick(userId: number, username: string) {
kickError.value = '' kickError.value = ''
if (userId === authStore.user?.id) { if (userId === authStore.user?.id) {
@@ -107,9 +156,12 @@ onMounted(async () => {
fetchUserCount() fetchUserCount()
fetchStats() fetchStats()
fetchVpnStatus() fetchVpnStatus()
fetchTrafficToday()
fetchSiteTraffic7d()
statsTimer = setInterval(() => { statsTimer = setInterval(() => {
fetchStats() fetchStats()
fetchVpnStatus() fetchVpnStatus()
fetchTrafficToday()
}, 30000) }, 30000)
}) })
@@ -164,21 +216,27 @@ function handleStatClick(route: string) {
<thead> <thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> <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.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.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.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-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> <th class="px-6 py-3 text-left font-medium text-gray-500 dark:text-gray-400">{{ t('common.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="!vpnClients.length"> <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>
<tr v-for="(c, i) in vpnClients" :key="i" class="border-b border-gray-100 dark:border-gray-700/50"> <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-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.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-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-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"> <td class="px-6 py-3">
<button <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" 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> </table>
<p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p> <p v-if="kickError" class="text-sm text-red-500 px-6 pb-4">{{ kickError }}</p>
</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.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> </div>
</template> </template>
+55 -1
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import TrafficChart from '@/components/TrafficChart.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const router = useRouter() const router = useRouter()
@@ -11,11 +12,41 @@ const { t } = useI18n()
interface VpnConnection { interface VpnConnection {
ip: string ip: string
ip6?: string ip6?: string
real_ip: string
connected_at: string connected_at: string
} }
const vpnConnections = ref<VpnConnection[]>([]) const vpnConnections = ref<VpnConnection[]>([])
const maxConns = ref(30) 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() { async function fetchVpnConnections() {
try { try {
const res = await fetch('/api/me/vpn/connections', { const res = await fetch('/api/me/vpn/connections', {
@@ -31,6 +62,7 @@ async function fetchVpnConnections() {
onMounted(async () => { onMounted(async () => {
await authStore.fetchUser() await authStore.fetchUser()
fetchVpnConnections() fetchVpnConnections()
fetchMyTraffic()
}) })
const showPasswordModal = ref(false) const showPasswordModal = ref(false)
@@ -94,6 +126,26 @@ async function handleChangePassword() {
</div> </div>
</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="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"> <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> <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"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50"> <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.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.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-left font-medium text-gray-500 dark:text-gray-400">{{ t('vpn.connectTime') }}</th>
@@ -111,9 +164,10 @@ async function handleChangePassword() {
</thead> </thead>
<tbody> <tbody>
<tr v-if="!vpnConnections.length"> <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>
<tr v-for="(c, i) in vpnConnections" :key="i" class="border-b border-gray-100 dark:border-gray-700/50"> <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.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-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-gray-500 dark:text-gray-400">{{ c.connected_at }}</td>
+1
View File
@@ -23,6 +23,7 @@ interface ClientInfo {
username: string username: string
ip: string ip: string
ip6?: string ip6?: string
real_ip: string
connected_at: string connected_at: string
} }
interface Status { interface Status {
+3
View File
@@ -16,6 +16,8 @@ type WebConfig struct {
SockGroup string `yaml:"sock_group"` SockGroup string `yaml:"sock_group"`
SockDirMode string `yaml:"sock_dir_mode"` SockDirMode string `yaml:"sock_dir_mode"`
JWTSecret string `yaml:"jwt_secret"` JWTSecret string `yaml:"jwt_secret"`
RealIPHeaders []string `yaml:"real_ip_headers"`
TrustedProxies []string `yaml:"trusted_proxies"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
@@ -36,6 +38,7 @@ func defaultConfig() *Config {
Sock: "web.sock", Sock: "web.sock",
SockMode: "0666", SockMode: "0666",
SockDirMode: "0755", SockDirMode: "0755",
RealIPHeaders: []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"},
}, },
Database: DatabaseConfig{ Database: DatabaseConfig{
Type: "sqlite", Type: "sqlite",
+1 -1
View File
@@ -40,7 +40,7 @@ func Init(cfg *config.DatabaseConfig) error {
return fmt.Errorf("数据库连接失败: %w", err) 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) return fmt.Errorf("数据库迁移失败: %w", err)
} }
+1 -1
View File
@@ -80,7 +80,7 @@ func Login(c *gin.Context) {
session := model.Session{ session := model.Session{
SessionID: sessionID, SessionID: sessionID,
UserID: user.ID, UserID: user.ID,
IP: c.ClientIP(), IP: middleware.GetRealIP(c),
UserAgent: c.GetHeader("User-Agent"), UserAgent: c.GetHeader("User-Agent"),
ExpiresAt: time.Now().Add(24 * time.Hour), 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{}{} updates := map[string]interface{}{}
if req.Status != nil { if req.Status != nil {
if user.ID == currentUserID.(uint) && *req.Status != 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "不能禁用自己的账号"})
return
}
updates["status"] = *req.Status updates["status"] = *req.Status
} }
+4 -3
View File
@@ -211,6 +211,7 @@ func UpdateVpnSettings(c *gin.Context) {
type myVpnConnection struct { type myVpnConnection struct {
IP string `json:"ip"` IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"` IP6 string `json:"ip6,omitempty"`
RealIP string `json:"real_ip"`
ConnectedAt string `json:"connected_at"` ConnectedAt string `json:"connected_at"`
} }
@@ -232,6 +233,7 @@ func GetMyVpnConnections(c *gin.Context) {
connections = append(connections, myVpnConnection{ connections = append(connections, myVpnConnection{
IP: ci.IP, IP: ci.IP,
IP6: ci.IP6, IP6: ci.IP6,
RealIP: ci.RealIP,
ConnectedAt: ci.ConnectedAt, ConnectedAt: ci.ConnectedAt,
}) })
} }
@@ -474,12 +476,11 @@ func KickUserClient(c *gin.Context) {
} }
n := 0 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() { if vpn.VPN != nil && vpn.VPN.Running() {
n = vpn.VPN.KickUser(uint(id)) 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}) 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 { func (TrafficStat) TableName() string {
return "traffic_stats" 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.GET("/me/sessions", handler.ListMySessions)
auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession) auth.DELETE("/me/sessions/:sessionId", handler.RevokeMySession)
auth.GET("/me/vpn/connections", handler.GetMyVpnConnections) 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") admin := r.Group("/api/admin")
@@ -48,6 +50,10 @@ func Setup(r *gin.Engine) {
admin.POST("/vpn/reservations", handler.CreateVpnReservation) admin.POST("/vpn/reservations", handler.CreateVpnReservation)
admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation) admin.DELETE("/vpn/reservations/:id", handler.DeleteVpnReservation)
admin.DELETE("/vpn/clients/:id", handler.KickUserClient) 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") distDir := http.Dir("./dist")
+4 -3
View File
@@ -31,6 +31,7 @@ var upgrader = websocket.Upgrader{
func HandleWS(c *gin.Context) { func HandleWS(c *gin.Context) {
tokenStr := c.Query("token") tokenStr := c.Query("token")
realIP := middleware.GetRealIP(c)
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
@@ -51,11 +52,11 @@ func HandleWS(c *gin.Context) {
conn.Close() conn.Close()
return return
} }
runTunnel(conn, &u) runTunnel(conn, &u, realIP)
return return
} }
user, err := authenticate(conn, db.DB, c.ClientIP()) user, err := authenticate(conn, db.DB, realIP)
if err != nil { if err != nil {
log.Printf("认证读取失败: %v", err) log.Printf("认证读取失败: %v", err)
conn.Close() conn.Close()
@@ -65,5 +66,5 @@ func HandleWS(c *gin.Context) {
return return
} }
runTunnel(conn, user) runTunnel(conn, user, realIP)
} }
+62 -2
View File
@@ -27,6 +27,7 @@ type VpnService struct {
switchx *PacketSwitch switchx *PacketSwitch
tun *TUNInterface tun *TUNInterface
tunDone chan struct{} tunDone chan struct{}
flushDone chan struct{}
running bool running bool
startedAt time.Time startedAt time.Time
clients map[*tunnelConn]struct{} clients map[*tunnelConn]struct{}
@@ -50,16 +51,59 @@ func (s *VpnService) StartedAt() time.Time {
return s.startedAt return s.startedAt
} }
const trafficFlushPeriod = 60 * time.Second
func (s *VpnService) TotalLiveTraffic() (rx, tx int64) { func (s *VpnService) TotalLiveTraffic() (rx, tx int64) {
s.mu.RLock() s.mu.RLock()
for c := range s.clients { for c := range s.clients {
rx += c.rxBytes.Load() rx += c.rxBytes.Load() - c.flushedRx.Load()
tx += c.txBytes.Load() tx += c.txBytes.Load() - c.flushedTx.Load()
} }
s.mu.RUnlock() s.mu.RUnlock()
return 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 { func (s *VpnService) Settings() model.VpnSetting {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
@@ -150,11 +194,13 @@ func (s *VpnService) ApplySettings(settings model.VpnSetting, reservations4, res
s.switchx = NewPacketSwitch(settings.AllowClientToClient) s.switchx = NewPacketSwitch(settings.AllowClientToClient)
s.tun = tun s.tun = tun
s.tunDone = make(chan struct{}) s.tunDone = make(chan struct{})
s.flushDone = make(chan struct{})
s.running = true s.running = true
s.startedAt = time.Now() s.startedAt = time.Now()
s.mu.Unlock() s.mu.Unlock()
go s.serveTUN() go s.serveTUN()
go s.trafficFlusher()
subnet4 := ipNet.String() subnet4 := ipNet.String()
var subnet6Str string var subnet6Str string
@@ -205,10 +251,21 @@ func (s *VpnService) Stop() error {
s.running = false s.running = false
tun := s.tun tun := s.tun
done := s.tunDone done := s.tunDone
flushDone := s.flushDone
s.flushDone = nil
clients := s.clients clients := s.clients
s.clients = make(map[*tunnelConn]struct{}) s.clients = make(map[*tunnelConn]struct{})
s.mu.Unlock() 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 { for c := range clients {
c.close() c.close()
} }
@@ -378,7 +435,10 @@ type ClientInfo struct {
Username string `json:"username"` Username string `json:"username"`
IP string `json:"ip"` IP string `json:"ip"`
IP6 string `json:"ip6,omitempty"` IP6 string `json:"ip6,omitempty"`
RealIP string `json:"real_ip"`
ConnectedAt string `json:"connected_at"` ConnectedAt string `json:"connected_at"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
} }
func (s *VpnService) KickUser(userID uint) int { func (s *VpnService) KickUser(userID uint) int {
+31 -3
View File
@@ -35,16 +35,27 @@ type tunnelConn struct {
svc *VpnService svc *VpnService
assignedIP net.IP assignedIP net.IP
assignedIP6 net.IP assignedIP6 net.IP
realIP string
connectedAt time.Time connectedAt time.Time
writeMu sync.Mutex writeMu sync.Mutex
ready atomic.Bool ready atomic.Bool
rxBytes atomic.Int64 rxBytes atomic.Int64
txBytes atomic.Int64 txBytes atomic.Int64
flushedRx atomic.Int64
flushedTx atomic.Int64
} }
func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP } func (c *tunnelConn) AssignedIP() net.IP { return c.assignedIP }
func (c *tunnelConn) AssignedIP6() net.IP { return c.assignedIP6 } 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 { func (c *tunnelConn) WritePacket(data []byte) error {
if !c.ready.Load() || len(data) == 0 { if !c.ready.Load() || len(data) == 0 {
return nil return nil
@@ -81,7 +92,10 @@ func (c *tunnelConn) info() ClientInfo {
UserID: c.user.ID, UserID: c.user.ID,
Username: c.user.Username, Username: c.user.Username,
IP: c.assignedIP.String(), IP: c.assignedIP.String(),
RealIP: c.realIP,
ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"), ConnectedAt: c.connectedAt.Format("2006-01-02 15:04:05"),
RxBytes: c.rxBytes.Load(),
TxBytes: c.txBytes.Load(),
} }
if c.assignedIP6 != nil { if c.assignedIP6 != nil {
ci.IP6 = c.assignedIP6.String() ci.IP6 = c.assignedIP6.String()
@@ -89,7 +103,7 @@ func (c *tunnelConn) info() ClientInfo {
return ci return ci
} }
func runTunnel(conn *websocket.Conn, user *model.User) { func runTunnel(conn *websocket.Conn, user *model.User, realIP string) {
defer conn.Close() defer conn.Close()
if VPN == nil || !VPN.Running() { if VPN == nil || !VPN.Running() {
@@ -131,12 +145,14 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
svc: VPN, svc: VPN,
assignedIP: ip4, assignedIP: ip4,
assignedIP6: ip6, assignedIP6: ip6,
realIP: realIP,
connectedAt: time.Now(), connectedAt: time.Now(),
} }
VPN.registerClient(tc) VPN.registerClient(tc)
defer func() { defer func() {
recordTraffic(tc.rxBytes.Load(), tc.txBytes.Load()) rx, tx := tc.flushDelta()
recordTraffic(tc.user.ID, rx, tx)
VPN.unregisterClient(tc) 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 { if rx == 0 && tx == 0 {
return return
} }
today := time.Now().Format("2006-01-02") 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} stat := model.TrafficStat{Date: today, RxBytes: rx, TxBytes: tx}
if err := db.DB.Clauses(clause.OnConflict{ if err := db.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "date"}}, Columns: []clause.Column{{Name: "date"}},
+7
View File
@@ -26,6 +26,7 @@ func main() {
} }
middleware.SetJWTSecret(cfg.Web.JWTSecret) middleware.SetJWTSecret(cfg.Web.JWTSecret)
middleware.SetRealIPHeaders(cfg.Web.RealIPHeaders)
if err := db.Init(&cfg.Database); err != nil { if err := db.Init(&cfg.Database); err != nil {
log.Fatalf("数据库初始化失败: %v", err) log.Fatalf("数据库初始化失败: %v", err)
@@ -38,6 +39,12 @@ func main() {
r := gin.Default() r := gin.Default()
if len(cfg.Web.TrustedProxies) > 0 {
_ = r.SetTrustedProxies(cfg.Web.TrustedProxies)
} else {
_ = r.SetTrustedProxies(nil)
}
router.Setup(r) router.Setup(r)
if cfg.Web.Port == 0 && cfg.Web.Sock == "" { if cfg.Web.Port == 0 && cfg.Web.Sock == "" {