feat: 连接列表显示客户端真实 IP,支持反代和 CDN 场景

新增可配置的 real_ip_headers(默认 CF-Connecting-IP > X-Real-IP >
X-Forwarded-For 降级取值),trusted_proxies 用于 gin 代理信任链。
WebSocket 连接建立时捕获真实 IP 并在 /profile 和 /admin 连接列表展示,
登录会话 IP 同步改用真实 IP。
This commit is contained in:
2026-07-10 16:58:14 +08:00
parent a98cdb0cac
commit 8398067acb
13 changed files with 67 additions and 13 deletions
+1
View File
@@ -154,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',
+1
View File
@@ -153,6 +153,7 @@ export default {
user: '用户',
ipv4: 'IPv4',
ipv6: 'IPv6',
realIp: '真实 IP',
connectTime: '连接时间',
noOnlineClients: '暂无在线客户端',
staticIpReservation: '静态 IP 预留',
+4 -1
View File
@@ -25,6 +25,7 @@ interface ClientInfo {
username: string
ip: string
ip6?: string
real_ip: string
connected_at: string
rx_bytes: number
tx_bytes: number
@@ -215,6 +216,7 @@ 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>
@@ -225,10 +227,11 @@ function handleStatClick(route: string) {
</thead>
<tbody>
<tr v-if="!vpnClients.length">
<td colspan="7" 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>
+4 -1
View File
@@ -12,6 +12,7 @@ const { t } = useI18n()
interface VpnConnection {
ip: string
ip6?: string
real_ip: string
connected_at: string
}
const vpnConnections = ref<VpnConnection[]>([])
@@ -155,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>
@@ -162,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
@@ -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),
}
+2
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,
})
}
+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()
}
+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)
}
+1
View File
@@ -435,6 +435,7 @@ 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"`
+4 -1
View File
@@ -35,6 +35,7 @@ type tunnelConn struct {
svc *VpnService
assignedIP net.IP
assignedIP6 net.IP
realIP string
connectedAt time.Time
writeMu sync.Mutex
ready atomic.Bool
@@ -91,6 +92,7 @@ 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(),
@@ -101,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() {
@@ -143,6 +145,7 @@ func runTunnel(conn *websocket.Conn, user *model.User) {
svc: VPN,
assignedIP: ip4,
assignedIP6: ip6,
realIP: realIP,
connectedAt: time.Now(),
}
+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 == "" {