Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/copilot-instructions.md155
-rw-r--r--web/job/check_client_ip_job.go208
2 files changed, 325 insertions, 38 deletions
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..89d23d69
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,155 @@
+# 3X-UI Development Guide
+
+## Project Overview
+3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
+
+## Architecture
+
+### Core Components
+- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
+- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
+- **xray/**: Xray-core process management and API communication for traffic monitoring
+- **database/**: GORM-based SQLite database with models in `database/model/`
+- **sub/**: Subscription server running alongside main web server (separate port)
+- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
+- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
+- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
+
+### Key Architectural Patterns
+1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
+ - `web/assets` → `assetsFS`
+ - `web/html` → `htmlFS`
+ - `web/translation` → `i18nFS`
+
+2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
+
+3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
+
+4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
+
+5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
+
+## Development Workflows
+
+### Building & Running
+```bash
+# Build (creates bin/3x-ui.exe)
+go run tasks.json → "go: build" task
+
+# Run with debug logging
+XUI_DEBUG=true go run ./main.go
+# Or use task: "go: run"
+
+# Test
+go test ./...
+```
+
+### Command-Line Operations
+The main.go accepts flags for admin tasks:
+- `-reset` - Reset all panel settings to defaults
+- `-show` - Display current settings (port, paths)
+- Use these by running the binary directly, not via web interface
+
+### Database Management
+- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
+- Models: Located in `database/model/model.go` - Auto-migrated on startup
+- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
+- Default credentials: admin/admin (hashed with bcrypt)
+
+### Telegram Bot Development
+- Bot instance in `web/service/tgbot.go` (3700+ lines)
+- Uses `telego` library with long polling
+- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
+- Bot handlers use `telegohandler.BotHandler` for routing
+- i18n via embedded `i18nFS` passed to bot startup
+
+## Code Conventions
+
+### Service Layer Pattern
+Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
+```go
+type InboundService struct {
+ xrayApi xray.XrayAPI
+}
+
+func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
+ // Business logic here
+}
+```
+
+### Controller Pattern
+Controllers use Gin context and inherit from BaseController:
+```go
+func (a *InboundController) getInbounds(c *gin.Context) {
+ // Use I18nWeb(c, "key") for translations
+ // Check auth via checkLogin middleware
+}
+```
+
+### Configuration Management
+- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
+- Config embedded files: `config/version`, `config/name`
+- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
+
+### Internationalization
+- Translation files: `web/translation/translate.*.toml`
+- Access via `I18nWeb(c, "pages.login.loginAgain")` in controllers
+- Use `locale.I18nType` enum (Web, Api, etc.)
+
+## External Dependencies & Integration
+
+### Xray-core
+- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
+- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
+- Process control: Start/stop via `xray/process.go`
+- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
+
+### Critical External Paths
+- Xray binary: `{bin_folder}/xray-{os}-{arch}`
+- Xray config: `{bin_folder}/config.json`
+- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
+- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
+
+### Job Scheduling
+Uses `robfig/cron/v3` for periodic tasks:
+- Traffic monitoring: `xray_traffic_job.go`
+- CPU alerts: `check_cpu_usage.go`
+- IP tracking: `check_client_ip_job.go`
+- LDAP sync: `ldap_sync_job.go`
+
+Jobs registered in `web/web.go` during server initialization
+
+## Deployment & Scripts
+
+### Installation Script Pattern
+Both `install.sh` and `x-ui.sh` follow these patterns:
+- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
+- Port detection with `is_port_in_use()` using ss/netstat/lsof
+- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
+
+### Docker Build
+Multi-stage Dockerfile:
+1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
+2. **Final**: Alpine-based with fail2ban pre-configured
+
+### Key File Locations (Production)
+- Binary: `/usr/local/x-ui/`
+- Database: `/etc/x-ui/x-ui.db`
+- Logs: `/var/log/x-ui/`
+- Service: `/etc/systemd/system/x-ui.service.*`
+
+## Testing & Debugging
+- Set `XUI_DEBUG=true` for detailed logging
+- Check Xray process: `x-ui.sh` script provides menu for status/logs
+- Database inspection: Direct SQLite access to x-ui.db
+- Traffic debugging: Check `3xipl.log` for IP limit tracking
+- Telegram bot: Logs show bot initialization and command handling
+
+## Common Gotchas
+1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
+2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
+3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
+4. **Port Binding**: Subscription server uses different port from main panel
+5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
+6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
+7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go
index e783a6df..94486236 100644
--- a/web/job/check_client_ip_job.go
+++ b/web/job/check_client_ip_job.go
@@ -3,6 +3,7 @@ package job
import (
"bufio"
"encoding/json"
+ "fmt"
"io"
"log"
"os"
@@ -10,6 +11,7 @@ import (
"regexp"
"runtime"
"sort"
+ "strconv"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
@@ -18,6 +20,12 @@ import (
"github.com/mhsanaei/3x-ui/v2/xray"
)
+// IPWithTimestamp tracks an IP address with its last seen timestamp
+type IPWithTimestamp struct {
+ IP string `json:"ip"`
+ Timestamp int64 `json:"timestamp"`
+}
+
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
lastClear int64
@@ -119,12 +127,14 @@ func (j *CheckClientIpJob) processLogFile() bool {
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
emailRegex := regexp.MustCompile(`email: (.+)$`)
+ timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
accessLogPath, _ := xray.GetAccessLogPath()
file, _ := os.Open(accessLogPath)
defer file.Close()
- inboundClientIps := make(map[string]map[string]struct{}, 100)
+ // Track IPs with their last seen timestamp
+ inboundClientIps := make(map[string]map[string]int64, 100)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
@@ -147,28 +157,45 @@ func (j *CheckClientIpJob) processLogFile() bool {
}
email := emailMatches[1]
+ // Extract timestamp from log line
+ var timestamp int64
+ timestampMatches := timestampRegex.FindStringSubmatch(line)
+ if len(timestampMatches) >= 2 {
+ t, err := time.Parse("2006/01/02 15:04:05", timestampMatches[1])
+ if err == nil {
+ timestamp = t.Unix()
+ } else {
+ timestamp = time.Now().Unix()
+ }
+ } else {
+ timestamp = time.Now().Unix()
+ }
+
if _, exists := inboundClientIps[email]; !exists {
- inboundClientIps[email] = make(map[string]struct{})
+ inboundClientIps[email] = make(map[string]int64)
+ }
+ // Update timestamp - keep the latest
+ if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
+ inboundClientIps[email][ip] = timestamp
}
- inboundClientIps[email][ip] = struct{}{}
}
shouldCleanLog := false
- for email, uniqueIps := range inboundClientIps {
+ for email, ipTimestamps := range inboundClientIps {
- ips := make([]string, 0, len(uniqueIps))
- for ip := range uniqueIps {
- ips = append(ips, ip)
+ // Convert to IPWithTimestamp slice
+ ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
+ for ip, timestamp := range ipTimestamps {
+ ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
}
- sort.Strings(ips)
clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil {
- j.addInboundClientIps(email, ips)
+ j.addInboundClientIps(email, ipsWithTime)
continue
}
- shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ips) || shouldCleanLog
+ shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime) || shouldCleanLog
}
return shouldCleanLog
@@ -213,9 +240,9 @@ func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.Inbou
return InboundClientIps, nil
}
-func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string) error {
+func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
inboundClientIps := &model.InboundClientIps{}
- jsonIps, err := json.Marshal(ips)
+ jsonIps, err := json.Marshal(ipsWithTime)
j.checkError(err)
inboundClientIps.ClientEmail = clientEmail
@@ -239,16 +266,8 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ips []string)
return nil
}
-func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, ips []string) bool {
- jsonIps, err := json.Marshal(ips)
- if err != nil {
- logger.Error("failed to marshal IPs to JSON:", err)
- return false
- }
-
- inboundClientIps.ClientEmail = clientEmail
- inboundClientIps.Ips = string(jsonIps)
-
+func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp) bool {
+ // Get the inbound configuration
inbound, err := j.getInboundByEmail(clientEmail)
if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
@@ -263,9 +282,57 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
+
+ // Find the client's IP limit
+ var limitIp int
+ var clientFound bool
+ for _, client := range clients {
+ if client.Email == clientEmail {
+ limitIp = client.LimitIP
+ clientFound = true
+ break
+ }
+ }
+
+ if !clientFound || limitIp <= 0 || !inbound.Enable {
+ // No limit or inbound disabled, just update and return
+ jsonIps, _ := json.Marshal(newIpsWithTime)
+ inboundClientIps.Ips = string(jsonIps)
+ db := database.GetDB()
+ db.Save(inboundClientIps)
+ return false
+ }
+
+ // Parse old IPs from database
+ var oldIpsWithTime []IPWithTimestamp
+ if inboundClientIps.Ips != "" {
+ json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
+ }
+
+ // Merge old and new IPs, keeping the latest timestamp for each IP
+ ipMap := make(map[string]int64)
+ for _, ipTime := range oldIpsWithTime {
+ ipMap[ipTime.IP] = ipTime.Timestamp
+ }
+ for _, ipTime := range newIpsWithTime {
+ if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
+ ipMap[ipTime.IP] = ipTime.Timestamp
+ }
+ }
+
+ // Convert back to slice and sort by timestamp (newest first)
+ allIps := make([]IPWithTimestamp, 0, len(ipMap))
+ for ip, timestamp := range ipMap {
+ allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
+ }
+ sort.Slice(allIps, func(i, j int) bool {
+ return allIps[i].Timestamp > allIps[j].Timestamp // Descending order (newest first)
+ })
+
shouldCleanLog := false
j.disAllowedIps = []string{}
+ // Open log file
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err)
@@ -275,27 +342,33 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags)
- for _, client := range clients {
- if client.Email == clientEmail {
- limitIp := client.LimitIP
+ // Check if we exceed the limit
+ if len(allIps) > limitIp {
+ shouldCleanLog = true
- if limitIp > 0 && inbound.Enable {
- shouldCleanLog = true
+ // Keep only the newest IPs (up to limitIp)
+ keptIps := allIps[:limitIp]
+ disconnectedIps := allIps[limitIp:]
- if limitIp < len(ips) {
- j.disAllowedIps = append(j.disAllowedIps, ips[limitIp:]...)
- for i := limitIp; i < len(ips); i++ {
- log.Printf("[LIMIT_IP] Email = %s || SRC = %s", clientEmail, ips[i])
- }
- }
- }
+ // Log the disconnected IPs (old ones)
+ for _, ipTime := range disconnectedIps {
+ j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
+ log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
- }
- sort.Strings(j.disAllowedIps)
+ // Actually disconnect old IPs by temporarily removing and re-adding user
+ // This forces Xray to drop existing connections from old IPs
+ if len(disconnectedIps) > 0 {
+ j.disconnectClientTemporarily(inbound, clientEmail, clients)
+ }
- if len(j.disAllowedIps) > 0 {
- logger.Debug("disAllowedIps:", j.disAllowedIps)
+ // Update database with only the newest IPs
+ jsonIps, _ := json.Marshal(keptIps)
+ inboundClientIps.Ips = string(jsonIps)
+ } else {
+ // Under limit, save all IPs
+ jsonIps, _ := json.Marshal(allIps)
+ inboundClientIps.Ips = string(jsonIps)
}
db := database.GetDB()
@@ -305,9 +378,68 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
return false
}
+ if len(j.disAllowedIps) > 0 {
+ logger.Infof("[LIMIT_IP] Client %s: Kept %d newest IPs, disconnected %d old IPs", clientEmail, limitIp, len(j.disAllowedIps))
+ }
+
return shouldCleanLog
}
+// disconnectClientTemporarily removes and re-adds a client to force disconnect old connections
+func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
+ var xrayAPI xray.XrayAPI
+
+ // Get panel settings for API port
+ db := database.GetDB()
+ var apiPort int
+ var apiPortSetting model.Setting
+ if err := db.Where("key = ?", "xrayApiPort").First(&apiPortSetting).Error; err == nil {
+ apiPort, _ = strconv.Atoi(apiPortSetting.Value)
+ }
+
+ if apiPort == 0 {
+ apiPort = 10085 // Default API port
+ }
+
+ err := xrayAPI.Init(apiPort)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
+ return
+ }
+ defer xrayAPI.Close()
+
+ // Find the client config
+ var clientConfig map[string]any
+ for _, client := range clients {
+ if client.Email == clientEmail {
+ // Convert client to map for API
+ clientBytes, _ := json.Marshal(client)
+ json.Unmarshal(clientBytes, &clientConfig)
+ break
+ }
+ }
+
+ if clientConfig == nil {
+ return
+ }
+
+ // Remove user to disconnect all connections
+ err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
+ return
+ }
+
+ // Wait a moment for disconnection to take effect
+ time.Sleep(100 * time.Millisecond)
+
+ // Re-add user to allow new connections
+ err = xrayAPI.AddUser(string(inbound.Protocol), inbound.Tag, clientConfig)
+ if err != nil {
+ logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
+ }
+}
+
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}