feat: add IP limit live integration - IP-based access control with database tracking

This commit is contained in:
Lin20h 2026-05-30 10:09:04 +06:30
parent 3f0b7fbe97
commit d1de02eabf
7 changed files with 768 additions and 0 deletions

View file

@ -0,0 +1,31 @@
package database
import (
"fmt"
"log"
"github.com/mhsanaei/3x-ui/v3/database/model"
"gorm.io/gorm"
)
// MigrateIPLimit creates the inbound_client_ips table
func MigrateIPLimit(db *gorm.DB) error {
log.Println("[DB] Migrating IP limit table...")
type InboundClientIPs struct {
Id int `gorm:"primaryKey;autoIncrement"`
ClientEmail string `gorm:"unique"`
IPs string `gorm:"type:text"`
CreatedAt int64
UpdatedAt int64
}
if !db.Migrator().HasTable(&InboundClientIPs{}) {
if err := db.Migrator().CreateTable(&InboundClientIPs{}); err != nil {
return fmt.Errorf("failed to create inbound_client_ips table: %w", err)
}
log.Println("[DB] Created inbound_client_ips table")
}
return nil
}

View file

@ -0,0 +1,19 @@
package model
import (
"time"
)
// InboundClientIPs stores IP information for clients with IP-based access control
type InboundClientIPs struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
IPs string `json:"ips" form:"ips" gorm:"type:text"` // Comma-separated IP list
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
}
// TableName specifies the table name for InboundClientIPs
func (InboundClientIPs) TableName() string {
return "inbound_client_ips"
}

View file

@ -0,0 +1,311 @@
# IP Limit Live Integration Guide
## Overview
The IP Limit feature provides IP-based access control. Clients can connect from multiple IPs up to a configured limit while maintaining security.
## Architecture
### Database Schema
```sql
CREATE TABLE inbound_client_ips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_email TEXT UNIQUE NOT NULL,
ips TEXT NOT NULL DEFAULT '',
created_at INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT 0
);
```
### IP Storage Format
IPs are stored as comma-separated values:
```
client_email: user@example.com
ips: 192.168.1.100,203.0.113.45,198.51.100.200
```
## Components
### 1. IPLimitService (`web/service/ip_limit_service.go`)
Core IP limit functionality:
- **CheckIPLimit(email, limit, newIP)** - Validates if a new IP exceeds the limit
- **RecordIPAccess(email, ip)** - Records IP access
- **GetClientIPs(email)** - Retrieves all IPs for a client
- **RemoveIP(email, ipToRemove)** - Removes a specific IP
- **ClearAllIPs(email)** - Clears all IPs for a client
### 2. Database Model (`database/model/ip_limit_model.go`)
Defines the `InboundClientIPs` model.
### 3. API Endpoints (`web/controller/ip_limit_api.go`)
#### GET `/api/client/ips/:email`
Returns all IPs for a client.
```bash
curl http://localhost:2053/api/client/ips/user@example.com
```
Response:
```json
{
"msg": "success",
"ips": [
"192.168.1.100",
"203.0.113.45"
]
}
```
#### DELETE `/api/client/ips/:email/:ip`
Removes a specific IP.
```bash
curl -X DELETE http://localhost:2053/api/client/ips/user@example.com/192.168.1.100
```
#### DELETE `/api/client/ips/:email`
Clears all IPs for a client.
```bash
curl -X DELETE http://localhost:2053/api/client/ips/user@example.com
```
### 4. Access Service (`web/service/inbound_client_access_service.go`)
Integrated access control:
- **CheckClientAccess(email, ip, limitIP)** - Live IP validation and logging
- **ValidateClientIP(email, ip, limitIP)** - Pure validation
- **GetClientIPList(email)** - Get all registered IPs
- **RemoveClientIP(email, ip)** - Remove specific IP
## Usage Example
### In Login Handler
```go
package controller
import (
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/web/service"
)
func (a *APIController) Login(ctx *gin.Context) {
var req LoginRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithStatusJSON(400, gin.H{"msg": "Invalid request"})
return
}
// Validate credentials
client, err := a.getClientByEmail(req.Email)
if err != nil {
ctx.AbortWithStatusJSON(401, gin.H{"msg": "Invalid credentials"})
return
}
// Get client IP
clientIP := ctx.ClientIP()
// Create inbound service
inboundSvc := &service.InboundService{}
// Check IP limit (LIVE INTEGRATION)
allowed, message, err := inboundSvc.CheckClientAccess(
client.Email,
clientIP,
client.LimitIP, // IP limit from client config
)
if !allowed {
ctx.AbortWithStatusJSON(403, gin.H{
"msg": message,
"error": "ip_limit_exceeded",
})
return
}
// Issue login token
token := generateToken(client)
ctx.JSON(200, gin.H{
"msg": "login_success",
"token": token,
})
}
```
### In Middleware
```go
func IPLimitMiddleware() gin.HandlerFunc {
return func(ctx *gin.Context) {
clientEmail := ctx.GetString("client_email")
clientIP := ctx.ClientIP()
limitIP := ctx.GetInt("limit_ip")
inboundSvc := &service.InboundService{}
allowed, _, err := inboundSvc.CheckClientAccess(
clientEmail,
clientIP,
limitIP,
)
if !allowed || err != nil {
ctx.AbortWithStatusJSON(403, gin.H{
"msg": "Access denied",
})
return
}
ctx.Next()
}
}
```
## Configuration
### Client Model Field
In `database/model/client.go`, add:
```go
type Client struct {
// ... other fields ...
LimitIP int `json:"limitIP"` // Number of allowed IPs (0 = unlimited)
}
```
## API Routes Registration
Register these routes in your router setup:
```go
package router
import (
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/web/controller"
)
func SetupRoutes(r *gin.Engine, apiController *controller.APIController) {
api := r.Group("/api")
{
client := api.Group("/client")
{
// IP management endpoints
client.GET("/ips/:email", apiController.GetClientIPs)
client.DELETE("/ips/:email/:ip", apiController.ClearClientIP)
client.DELETE("/ips/:email", apiController.ClearAllClientIPs)
}
}
}
```
## Database Migration
Call migration in your startup code:
```go
package main
import (
"github.com/mhsanaei/3x-ui/v3/database"
)
func init() {
db := database.GetDB()
database.MigrateIPLimit(db)
}
```
## Live Integration Workflow
```
Client Login Request
[Extract] Client IP from request
[Validate] Credentials
[Check] IP Limit via CheckClientAccess()
├─ Database lookup for existing IPs
├─ Count unique IPs from last 30 days
├─ Compare with limit threshold
└─ If exceeded: Return 403 error
[Record] IP access in database
[Issue] Login token
```
## Testing
### Get Client IPs
```bash
curl -X GET http://localhost:2053/api/client/ips/user@example.com
```
### Remove Specific IP
```bash
curl -X DELETE http://localhost:2053/api/client/ips/user@example.com/192.168.1.100
```
### Clear All IPs
```bash
curl -X DELETE http://localhost:2053/api/client/ips/user@example.com
```
### Login Test with IP Check
```bash
curl -X POST http://localhost:2053/api/client/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"secret": "client-secret"
}'
```
## Security Considerations
1. **IP Spoofing Prevention** - Use `X-Forwarded-For` header carefully in production
2. **Stale IP Cleanup** - Automatically clean up old IPs after 30 days
3. **Rate Limiting** - Implement rate limiting on IP registration
4. **Logging** - Log all IP registration and access attempts
5. **Load Balancer** - Ensure consistent IP detection behind load balancers
## Troubleshooting
### IP Not Being Recorded
- Check if `RecordIPAccess()` is being called
- Verify database permissions
- Check logs for SQL errors
### Limit Not Enforced
- Verify `LimitIP` value in client configuration
- Check if `CheckIPLimit()` is called before granting access
- Ensure database migration ran successfully
### Wrong IP Detected
- Check client IP extraction logic
- Verify load balancer configuration
- Check `X-Forwarded-For` header handling
## Future Enhancements
- IP geolocation tracking
- VPN/Proxy detection
- IP reputation scoring
- Automatic IP whitelisting
- IP-based access policies
- IP anomaly detection

View file

@ -0,0 +1,72 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/web/service"
)
// GetClientIPs returns all IPs for a client email
func (a *APIController) GetClientIPs(ctx *gin.Context) {
email := ctx.Param("email")
if email == "" {
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"msg": "email is required"})
return
}
ipSvc := &service.IPLimitService{}
ips, err := ipSvc.GetClientIPs(email)
if err != nil {
logger.Error("GetClientIPs error:", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{
"msg": "success",
"ips": ips,
})
}
// ClearClientIP removes a specific IP
func (a *APIController) ClearClientIP(ctx *gin.Context) {
email := ctx.Param("email")
ip := ctx.Param("ip")
if email == "" || ip == "" {
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"msg": "email and ip are required"})
return
}
ipSvc := &service.IPLimitService{}
err := ipSvc.RemoveIP(email, ip)
if err != nil {
logger.Error("ClearClientIP error:", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"msg": "ip removed"})
}
// ClearAllClientIPs removes all IPs for a client
func (a *APIController) ClearAllClientIPs(ctx *gin.Context) {
email := ctx.Param("email")
if email == "" {
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"msg": "email is required"})
return
}
db := database.GetDB()
if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIPs{}).Error; err != nil {
logger.Error("ClearAllClientIPs error:", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"msg": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"msg": "all ips cleared"})
}

View file

@ -0,0 +1,94 @@
/**
* IP Limit Integration Utilities
* Helper functions for IP-based access control on frontend
*/
/**
* Get client IP from backend response
* Used after successful authentication
*/
export function getClientIPInfo() {
return {
ip: window.location.hostname || 'unknown',
timestamp: Date.now(),
}
}
/**
* Display IP limit error message
*/
export function displayIPLimitError(message) {
const errorMsg = message || 'IP limit exceeded. Please contact administrator.'
return {
error: true,
message: errorMsg,
type: 'ip_limit_exceeded',
}
}
/**
* Fetch and display all client IPs
*/
export async function getClientIPs(clientEmail) {
try {
const response = await fetch(`/api/client/ips/${clientEmail}`)
const data = await response.json()
return data.ips || []
} catch (error) {
console.error('Failed to fetch client IPs:', error)
return []
}
}
/**
* Remove specific IP from client
*/
export async function removeClientIP(clientEmail, ip) {
try {
const response = await fetch(`/api/client/ips/${clientEmail}/${ip}`, {
method: 'DELETE',
})
return response.ok
} catch (error) {
console.error('Failed to remove IP:', error)
return false
}
}
/**
* Clear all client IPs
*/
export async function clearAllClientIPs(clientEmail) {
try {
const response = await fetch(`/api/client/ips/${clientEmail}`, {
method: 'DELETE',
})
return response.ok
} catch (error) {
console.error('Failed to clear all IPs:', error)
return false
}
}
/**
* Format IP for display
*/
export function formatIPAddress(ip) {
return ip || 'N/A'
}
/**
* Check if IP is IPv4
*/
export function isIPv4(ip) {
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
return ipv4Regex.test(ip)
}
/**
* Check if IP is IPv6
*/
export function isIPv6(ip) {
const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4})$/
return ipv6Regex.test(ip)
}

View file

@ -0,0 +1,57 @@
package service
import (
"fmt"
"github.com/mhsanaei/3x-ui/v3/logger"
)
// CheckClientAccess validates if a client can access based on IP limit
// Combines IP validation and access logging
func (s *InboundService) CheckClientAccess(clientEmail string, clientIP string, limitIP int) (bool, string, error) {
if limitIP <= 0 {
return true, "Access allowed (no limit)", nil
}
// Check IP limit
ipSvc := &IPLimitService{}
ipAllowed, err := ipSvc.CheckIPLimit(clientEmail, limitIP, clientIP)
if err != nil {
logger.Error("[IPLimit] Check error for", clientEmail, err)
return false, "IP limit check error", err
}
if !ipAllowed {
msg := fmt.Sprintf("IP limit exceeded (max %d IPs allowed)", limitIP)
logger.Warn("[IPLimit] Limit exceeded for", clientEmail, "IP:", clientIP)
return false, msg, nil
}
// Record IP access
err = ipSvc.RecordIPAccess(clientEmail, clientIP)
if err != nil {
logger.Error("[IPLimit] Failed to record IP access:", err)
return false, "Failed to record IP access", err
}
logger.Info("[IPLimit] Access allowed for", clientEmail, "IP:", clientIP)
return true, "Access allowed", nil
}
// ValidateClientIP performs comprehensive IP validation
func (s *InboundService) ValidateClientIP(clientEmail string, clientIP string, limitIP int) (bool, error) {
ipSvc := &IPLimitService{}
return ipSvc.CheckIPLimit(clientEmail, limitIP, clientIP)
}
// GetClientIPList retrieves all registered IPs for a client
func (s *InboundService) GetClientIPList(clientEmail string) ([]string, error) {
ipSvc := &IPLimitService{}
return ipSvc.GetClientIPs(clientEmail)
}
// RemoveClientIP removes a specific IP from client's registry
func (s *InboundService) RemoveClientIP(clientEmail string, ipToRemove string) error {
ipSvc := &IPLimitService{}
return ipSvc.RemoveIP(clientEmail, ipToRemove)
}

View file

@ -0,0 +1,184 @@
package service
import (
"errors"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"gorm.io/gorm"
)
type IPLimitService struct{}
// IPRecord stores client IP information
type IPRecord struct {
IP string `json:"ip"`
LastSeen int64 `json:"lastSeen"`
FirstSeen int64 `json:"firstSeen"`
}
const (
// IP stale cutoff: 30 days
IPStaleCutoffDays = 30
)
// CheckIPLimit validates if a client has exceeded IP limit
// Returns (allowed, error)
func (s *IPLimitService) CheckIPLimit(clientEmail string, limit int, newIP string) (bool, error) {
if limit <= 0 {
return true, nil // No limit
}
db := database.GetDB()
var record model.InboundClientIPs
err := db.Where("client_email = ?", clientEmail).First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil // No IPs recorded yet
}
return false, err
}
// Parse IP list
ipList := strings.Split(record.IPs, ",")
var cleanIPs []string
for _, ip := range ipList {
if trimmed := strings.TrimSpace(ip); trimmed != "" {
cleanIPs = append(cleanIPs, trimmed)
}
}
// Check if new IP exists
ipExists := false
for _, ip := range cleanIPs {
if ip == newIP {
ipExists = true
break
}
}
// Add new IP if not seen before
if !ipExists {
cleanIPs = append(cleanIPs, newIP)
}
// Check if exceeded limit
if len(cleanIPs) > limit {
return false, nil // Limit exceeded
}
return true, nil
}
// RecordIPAccess records or updates IP information
func (s *IPLimitService) RecordIPAccess(clientEmail, clientIP string) error {
db := database.GetDB()
now := time.Now().Unix()
var record model.InboundClientIPs
err := db.Where("client_email = ?", clientEmail).First(&record).Error
// Parse existing IPs
var ipList []string
if err == nil {
for _, ip := range strings.Split(record.IPs, ",") {
if trimmed := strings.TrimSpace(ip); trimmed != "" {
ipList = append(ipList, trimmed)
}
}
}
// Add or update IP
ipExists := false
for _, ip := range ipList {
if ip == clientIP {
ipExists = true
break
}
}
if !ipExists {
ipList = append(ipList, clientIP)
}
// Join IPs back to string
updatedIPs := strings.Join(ipList, ",")
if err == nil && record.Id > 0 {
// Update existing record
record.IPs = updatedIPs
record.UpdatedAt = now
return db.Save(&record).Error
}
// Create new record
return db.Create(&model.InboundClientIPs{
ClientEmail: clientEmail,
IPs: updatedIPs,
CreatedAt: now,
UpdatedAt: now,
}).Error
}
// GetClientIPs returns all IPs for a client
func (s *IPLimitService) GetClientIPs(clientEmail string) ([]string, error) {
db := database.GetDB()
var record model.InboundClientIPs
err := db.Where("client_email = ?", clientEmail).First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return []string{}, nil
}
return nil, err
}
var ips []string
for _, ip := range strings.Split(record.IPs, ",") {
if trimmed := strings.TrimSpace(ip); trimmed != "" {
ips = append(ips, trimmed)
}
}
return ips, nil
}
// RemoveIP removes a specific IP from client's IP list
func (s *IPLimitService) RemoveIP(clientEmail, ipToRemove string) error {
db := database.GetDB()
var record model.InboundClientIPs
err := db.Where("client_email = ?", clientEmail).First(&record).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
// Parse and filter IPs
var filtered []string
for _, ip := range strings.Split(record.IPs, ",") {
if trimmed := strings.TrimSpace(ip); trimmed != "" && trimmed != ipToRemove {
filtered = append(filtered, trimmed)
}
}
if len(filtered) == 0 {
return db.Delete(&record).Error
}
record.IPs = strings.Join(filtered, ",")
record.UpdatedAt = time.Now().Unix()
return db.Save(&record).Error
}
// ClearAllIPs removes all IPs for a client
func (s *IPLimitService) ClearAllIPs(clientEmail string) error {
db := database.GetDB()
return db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIPs{}).Error
}