mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
feat: add IP limit live integration - IP-based access control with database tracking
This commit is contained in:
parent
3f0b7fbe97
commit
d1de02eabf
7 changed files with 768 additions and 0 deletions
31
database/db_migration_ip_limit.go
Normal file
31
database/db_migration_ip_limit.go
Normal 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
|
||||||
|
}
|
||||||
19
database/model/ip_limit_model.go
Normal file
19
database/model/ip_limit_model.go
Normal 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"
|
||||||
|
}
|
||||||
311
docs/IP_LIMIT_INTEGRATION_GUIDE.md
Normal file
311
docs/IP_LIMIT_INTEGRATION_GUIDE.md
Normal 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
|
||||||
72
web/controller/ip_limit_api.go
Normal file
72
web/controller/ip_limit_api.go
Normal 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"})
|
||||||
|
}
|
||||||
94
web/html/src/utils/ipLimitUtils.js
Normal file
94
web/html/src/utils/ipLimitUtils.js
Normal 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)
|
||||||
|
}
|
||||||
57
web/service/inbound_client_access_service.go
Normal file
57
web/service/inbound_client_access_service.go
Normal 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)
|
||||||
|
}
|
||||||
184
web/service/ip_limit_service.go
Normal file
184
web/service/ip_limit_service.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue