From d1de02eabf92f5d6ef9aa8400978096700d3bda9 Mon Sep 17 00:00:00 2001 From: Lin20h Date: Sat, 30 May 2026 10:09:04 +0630 Subject: [PATCH] feat: add IP limit live integration - IP-based access control with database tracking --- database/db_migration_ip_limit.go | 31 ++ database/model/ip_limit_model.go | 19 ++ docs/IP_LIMIT_INTEGRATION_GUIDE.md | 311 +++++++++++++++++++ web/controller/ip_limit_api.go | 72 +++++ web/html/src/utils/ipLimitUtils.js | 94 ++++++ web/service/inbound_client_access_service.go | 57 ++++ web/service/ip_limit_service.go | 184 +++++++++++ 7 files changed, 768 insertions(+) create mode 100644 database/db_migration_ip_limit.go create mode 100644 database/model/ip_limit_model.go create mode 100644 docs/IP_LIMIT_INTEGRATION_GUIDE.md create mode 100644 web/controller/ip_limit_api.go create mode 100644 web/html/src/utils/ipLimitUtils.js create mode 100644 web/service/inbound_client_access_service.go create mode 100644 web/service/ip_limit_service.go diff --git a/database/db_migration_ip_limit.go b/database/db_migration_ip_limit.go new file mode 100644 index 00000000..acecf0a2 --- /dev/null +++ b/database/db_migration_ip_limit.go @@ -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 +} diff --git a/database/model/ip_limit_model.go b/database/model/ip_limit_model.go new file mode 100644 index 00000000..d16ffd55 --- /dev/null +++ b/database/model/ip_limit_model.go @@ -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" +} diff --git a/docs/IP_LIMIT_INTEGRATION_GUIDE.md b/docs/IP_LIMIT_INTEGRATION_GUIDE.md new file mode 100644 index 00000000..194124a7 --- /dev/null +++ b/docs/IP_LIMIT_INTEGRATION_GUIDE.md @@ -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 diff --git a/web/controller/ip_limit_api.go b/web/controller/ip_limit_api.go new file mode 100644 index 00000000..1150655c --- /dev/null +++ b/web/controller/ip_limit_api.go @@ -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"}) +} diff --git a/web/html/src/utils/ipLimitUtils.js b/web/html/src/utils/ipLimitUtils.js new file mode 100644 index 00000000..1304028b --- /dev/null +++ b/web/html/src/utils/ipLimitUtils.js @@ -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) +} diff --git a/web/service/inbound_client_access_service.go b/web/service/inbound_client_access_service.go new file mode 100644 index 00000000..db8e7ed5 --- /dev/null +++ b/web/service/inbound_client_access_service.go @@ -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) +} diff --git a/web/service/ip_limit_service.go b/web/service/ip_limit_service.go new file mode 100644 index 00000000..323bdff7 --- /dev/null +++ b/web/service/ip_limit_service.go @@ -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 +}