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',
|
||||
ipv4: 'IPv4',
|
||||
ipv6: 'IPv6',
|
||||
realIp: 'Real IP',
|
||||
connectTime: 'Connected At',
|
||||
noOnlineClients: 'No online clients',
|
||||
staticIpReservation: 'Static IP Reservation',
|
||||
|
||||
@@ -153,6 +153,7 @@ export default {
|
||||
user: '用户',
|
||||
ipv4: 'IPv4',
|
||||
ipv6: 'IPv6',
|
||||
realIp: '真实 IP',
|
||||
connectTime: '连接时间',
|
||||
noOnlineClients: '暂无在线客户端',
|
||||
staticIpReservation: '静态 IP 预留',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ClientInfo {
|
||||
username: string
|
||||
ip: string
|
||||
ip6?: string
|
||||
real_ip: string
|
||||
connected_at: string
|
||||
}
|
||||
interface Status {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user