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:
@@ -154,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',
|
||||||
|
|||||||
@@ -153,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 预留',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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
|
rx_bytes: number
|
||||||
tx_bytes: number
|
tx_bytes: number
|
||||||
@@ -215,6 +216,7 @@ 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>
|
||||||
@@ -225,10 +227,11 @@ function handleStatClick(route: string) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!vpnClients.length">
|
<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>
|
||||||
<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>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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[]>([])
|
||||||
@@ -155,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>
|
||||||
@@ -162,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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ 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"`
|
RxBytes int64 `json:"rx_bytes"`
|
||||||
TxBytes int64 `json:"tx_bytes"`
|
TxBytes int64 `json:"tx_bytes"`
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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
|
||||||
@@ -91,6 +92,7 @@ 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(),
|
RxBytes: c.rxBytes.Load(),
|
||||||
TxBytes: c.txBytes.Load(),
|
TxBytes: c.txBytes.Load(),
|
||||||
@@ -101,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() {
|
||||||
@@ -143,6 +145,7 @@ 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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user