From 7e2f3fda032db1b3156a3a5a82ebffa462e0428d Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Fri, 9 Jan 2026 15:36:14 +0300 Subject: [PATCH] refactor panel new logic --- database/db.go | 5 + database/model/model.go | 103 ++ node/docker-compose.yml | 37 + sub/subController.go | 17 +- sub/subJsonService.go | 16 +- sub/subService.go | 1135 +++++++++++++++++++--- web/assets/js/model/inbound.js | 8 +- web/assets/js/model/setting.js | 18 + web/controller/client.go | 274 ++++++ web/controller/client_hwid.go | 224 +++++ web/controller/host.go | 253 +++++ web/controller/xui.go | 17 + web/entity/entity.go | 16 + web/html/clients.html | 730 ++++++++++++++ web/html/component/aSidebar.html | 14 +- web/html/form/protocol/shadowsocks.html | 7 +- web/html/form/protocol/trojan.html | 7 +- web/html/form/protocol/vless.html | 7 +- web/html/form/protocol/vmess.html | 7 +- web/html/hosts.html | 395 ++++++++ web/html/inbounds.html | 21 - web/html/modals/client_entity_modal.html | 307 ++++++ web/html/modals/client_modal.html | 7 + web/html/modals/host_modal.html | 153 +++ web/html/modals/inbound_modal.html | 9 - web/html/modals/qrcode_modal.html | 25 +- web/job/check_client_hwid_job.go | 155 +++ web/service/client.go | 602 ++++++++++++ web/service/client_hwid.go | 342 +++++++ web/service/host.go | 254 +++++ web/service/inbound.go | 192 +++- web/service/setting.go | 36 + web/translation/translate.en_US.toml | 103 +- web/translation/translate.ru_RU.toml | 103 +- web/web.go | 3 + 35 files changed, 5369 insertions(+), 233 deletions(-) create mode 100644 web/controller/client.go create mode 100644 web/controller/client_hwid.go create mode 100644 web/controller/host.go create mode 100644 web/html/clients.html create mode 100644 web/html/hosts.html create mode 100644 web/html/modals/client_entity_modal.html create mode 100644 web/html/modals/host_modal.html create mode 100644 web/job/check_client_hwid_job.go create mode 100644 web/service/client.go create mode 100644 web/service/client_hwid.go create mode 100644 web/service/host.go diff --git a/database/db.go b/database/db.go index b33a0621..f1bc99df 100644 --- a/database/db.go +++ b/database/db.go @@ -40,6 +40,11 @@ func initModels() error { &model.HistoryOfSeeders{}, &model.Node{}, &model.InboundNodeMapping{}, + &model.ClientEntity{}, + &model.ClientInboundMapping{}, + &model.Host{}, + &model.HostInboundMapping{}, + &model.ClientHWID{}, // HWID tracking for clients } for _, model := range models { if err := db.AutoMigrate(model); err != nil { diff --git a/database/model/model.go b/database/model/model.go index 51203a43..5ad12305 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -104,6 +104,8 @@ type Setting struct { } // Client represents a client configuration for Xray inbounds with traffic limits and settings. +// This is a legacy struct used for JSON parsing from inbound Settings. +// For database operations, use ClientEntity instead. type Client struct { ID string `json:"id"` // Unique client identifier Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") @@ -122,6 +124,42 @@ type Client struct { UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } +// ClientEntity represents a client as a separate database entity. +// Clients can be assigned to multiple inbounds. +type ClientEntity struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Email string `json:"email" form:"email" gorm:"uniqueIndex:idx_user_email"` // Client email identifier (unique per user) + UUID string `json:"uuid" form:"uuid"` // UUID/ID for VMESS/VLESS + Security string `json:"security" form:"security"` // Security method (e.g., "auto", "aes-128-gcm") + Password string `json:"password" form:"password"` // Client password (for Trojan/Shadowsocks) + Flow string `json:"flow" form:"flow"` // Flow control (XTLS) + LimitIP int `json:"limitIp" form:"limitIp"` // IP limit for this client + TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB + ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp + Enable bool `json:"enable" form:"enable"` // Whether the client is enabled + TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications + SubID string `json:"subId" form:"subId" gorm:"index"` // Subscription identifier + Comment string `json:"comment" form:"comment"` // Client comment + Reset int `json:"reset" form:"reset"` // Reset period in days + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this client is assigned to + + // Traffic statistics (loaded from client_traffics table, not stored in ClientEntity table) + Up int64 `json:"up,omitempty" form:"-" gorm:"-"` // Upload traffic in bytes + Down int64 `json:"down,omitempty" form:"-" gorm:"-"` // Download traffic in bytes + AllTime int64 `json:"allTime,omitempty" form:"-" gorm:"-"` // All-time traffic usage + LastOnline int64 `json:"lastOnline,omitempty" form:"-" gorm:"-"` // Last online timestamp + + // HWID (Hardware ID) restrictions + HWIDEnabled bool `json:"hwidEnabled" form:"hwidEnabled" gorm:"column:hwid_enabled;default:false"` // Whether HWID restriction is enabled for this client + MaxHWID int `json:"maxHwid" form:"maxHwid" gorm:"column:max_hwid;default:1"` // Maximum number of allowed HWID devices (0 = unlimited) + HWIDs []*ClientHWID `json:"hwids,omitempty" form:"-" gorm:"-"` // Registered HWIDs for this client (loaded from client_hwids table, not stored in ClientEntity table) +} + // Node represents a worker node in multi-node architecture. type Node struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier @@ -139,4 +177,69 @@ type InboundNodeMapping struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_inbound_node"` // Inbound ID NodeId int `json:"nodeId" form:"nodeId" gorm:"uniqueIndex:idx_inbound_node"` // Node ID +} + +// ClientInboundMapping maps clients to inbounds (many-to-many relationship). +type ClientInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"uniqueIndex:idx_client_inbound"` // Client ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_client_inbound"` // Inbound ID +} + +// Host represents a proxy/balancer host configuration for multi-node mode. +// Hosts can override the node address when generating subscription links. +type Host struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + UserId int `json:"userId" gorm:"index"` // Associated user ID + Name string `json:"name" form:"name"` // Host name/identifier + Address string `json:"address" form:"address"` // Host address (IP or domain) + Port int `json:"port" form:"port"` // Host port (0 means use inbound port) + Protocol string `json:"protocol" form:"protocol"` // Protocol override (optional) + Remark string `json:"remark" form:"remark"` // Host remark/description + Enable bool `json:"enable" form:"enable"` // Whether the host is enabled + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` // Creation timestamp + UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"` // Last update timestamp + + // Relations (not stored in DB, loaded via joins) + InboundIds []int `json:"inboundIds,omitempty" form:"-" gorm:"-"` // Inbound IDs this host applies to +} + +// HostInboundMapping maps hosts to inbounds (many-to-many relationship). +type HostInboundMapping struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + HostId int `json:"hostId" form:"hostId" gorm:"uniqueIndex:idx_host_inbound"` // Host ID + InboundId int `json:"inboundId" form:"inboundId" gorm:"uniqueIndex:idx_host_inbound"` // Inbound ID +} + +// ClientHWID represents a hardware ID (HWID) associated with a client. +// HWID is provided explicitly by client applications via HTTP headers (x-hwid). +// Server MUST NOT generate or derive HWID from IP, User-Agent, or access logs. +type ClientHWID struct { + // TableName specifies the table name for GORM + // GORM by default would use "client_hwids" but the actual table is "client_hw_ids" + Id int `json:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier + ClientId int `json:"clientId" form:"clientId" gorm:"column:client_id;index:idx_client_hwid"` // Client ID + HWID string `json:"hwid" form:"hwid" gorm:"column:hwid;index:idx_client_hwid"` // Hardware ID (unique per client, provided by client via x-hwid header) + DeviceName string `json:"deviceName" form:"deviceName" gorm:"column:device_name"` // Optional device name/description (deprecated, use DeviceModel instead) + DeviceOS string `json:"deviceOs" form:"deviceOs" gorm:"column:device_os"` // Device operating system (from x-device-os header) + DeviceModel string `json:"deviceModel" form:"deviceModel" gorm:"column:device_model"` // Device model (from x-device-model header) + OSVersion string `json:"osVersion" form:"osVersion" gorm:"column:os_version"` // OS version (from x-ver-os header) + FirstSeenAt int64 `json:"firstSeenAt" gorm:"column:first_seen_at;autoCreateTime"` // First time this HWID was seen (timestamp) + LastSeenAt int64 `json:"lastSeenAt" gorm:"column:last_seen_at;autoUpdateTime"` // Last time this HWID was used (timestamp) + FirstSeenIP string `json:"firstSeenIp" form:"firstSeenIp" gorm:"column:first_seen_ip"` // IP address when first seen + IsActive bool `json:"isActive" form:"isActive" gorm:"column:is_active;default:true"` // Whether this HWID is currently active + IPAddress string `json:"ipAddress" form:"ipAddress" gorm:"column:ip_address"` // Last known IP address for this HWID + UserAgent string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"` // User agent or client identifier (if available) + BlockedAt *int64 `json:"blockedAt,omitempty" form:"blockedAt" gorm:"column:blocked_at"` // Timestamp when HWID was blocked (null if not blocked) + BlockReason string `json:"blockReason,omitempty" form:"blockReason" gorm:"column:block_reason"` // Reason for blocking (e.g., "HWID limit exceeded") + + // Legacy fields (deprecated, kept for backward compatibility) + FirstSeen int64 `json:"firstSeen,omitempty" gorm:"-"` // Deprecated: use FirstSeenAt + LastSeen int64 `json:"lastSeen,omitempty" gorm:"-"` // Deprecated: use LastSeenAt +} + +// TableName specifies the table name for ClientHWID. +// GORM by default would use "client_hwids" but the actual table is "client_hw_ids" +func (ClientHWID) TableName() string { + return "client_hw_ids" } \ No newline at end of file diff --git a/node/docker-compose.yml b/node/docker-compose.yml index d72d6407..4d9b2803 100644 --- a/node/docker-compose.yml +++ b/node/docker-compose.yml @@ -18,7 +18,44 @@ services: # If the file doesn't exist, it will be created when XRAY config is first applied networks: - xray-network + node2: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node2 + restart: unless-stopped + environment: +# - NODE_API_KEY=${NODE_API_KEY:-change-me-to-secure-key} + - NODE_API_KEY=test-key + ports: + - "8081:8080" + - "44001:44001" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network + node3: + build: + context: .. + dockerfile: node/Dockerfile + container_name: 3x-ui-node3 + restart: unless-stopped + environment: + - NODE_API_KEY=test-key + ports: + - "8082:8080" + - "44002:44002" + volumes: + - ./bin/config.json:/app/bin/config.json + - ./logs:/app/logs + # Note: config.json is mounted directly for persistence + # If the file doesn't exist, it will be created when XRAY config is first applied + networks: + - xray-network networks: xray-network: driver: bridge diff --git a/sub/subController.go b/sub/subController.go index a219dd63..2ddf1fe4 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -41,8 +41,10 @@ func NewSUBController( subTitle string, ) *SUBController { sub := NewSubService(showInfo, rModel) - // Initialize NodeService for multi-node support + // Initialize services for multi-node support and new architecture sub.nodeService = service.NodeService{} + sub.hostService = service.HostService{} + sub.clientService = service.ClientService{} a := &SUBController{ subTitle: subTitle, subPath: subPath, @@ -73,7 +75,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) subs(c *gin.Context) { subId := c.Param("subid") scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) - subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) + subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host, c) // Pass context for HWID registration if err != nil || len(subs) == 0 { c.String(400, "Error!") } else { @@ -130,7 +132,7 @@ func (a *SUBController) subs(c *gin.Context) { // Add headers header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000) - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) if a.subEncrypt { c.String(200, base64.StdEncoding.EncodeToString([]byte(result))) @@ -144,21 +146,24 @@ func (a *SUBController) subs(c *gin.Context) { func (a *SUBController) subJsons(c *gin.Context) { subId := c.Param("subid") _, host, _, _ := a.subService.ResolveRequest(c) - jsonSub, header, err := a.subJsonService.GetJson(subId, host) + jsonSub, header, err := a.subJsonService.GetJson(subId, host, c) // Pass context for HWID registration if err != nil || len(jsonSub) == 0 { c.String(400, "Error!") } else { // Add headers - a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle) + a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, subId) c.String(200, jsonSub) } } // ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title. -func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle string) { +// Also adds X-Subscription-ID header so clients can use it as HWID if needed. +func (a *SUBController) ApplyCommonHeaders(c *gin.Context, header, updateInterval, profileTitle, subId string) { c.Writer.Header().Set("Subscription-Userinfo", header) c.Writer.Header().Set("Profile-Update-Interval", updateInterval) c.Writer.Header().Set("Profile-Title", "base64:"+base64.StdEncoding.EncodeToString([]byte(profileTitle))) + // Add subscription ID header so clients can use it as HWID identifier + c.Writer.Header().Set("X-Subscription-ID", subId) } diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 8222491a..ff043dc5 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -7,6 +7,8 @@ import ( "maps" "strings" + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/database" "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/json_util" @@ -71,7 +73,19 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, } // GetJson generates a JSON subscription configuration for the given subscription ID and host. -func (s *SubJsonService) GetJson(subId string, host string) (string, string, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers. +func (s *SubJsonService) GetJson(subId string, host string, c *gin.Context) (string, string, error) { + // Register HWID from headers if context is provided + if c != nil { + // Try to find client by subId + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + if err == nil && clientEntity != nil { + s.SubService.registerHWIDFromRequest(c, clientEntity) + } + } + inbounds, err := s.SubService.getInboundsBySubId(subId) if err != nil || len(inbounds) == 0 { return "", "", err diff --git a/sub/subService.go b/sub/subService.go index ab746a26..7ed97f25 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -29,6 +29,9 @@ type SubService struct { inboundService service.InboundService settingService service.SettingService nodeService service.NodeService + hostService service.HostService + clientService service.ClientService + hwidService service.ClientHWIDService } // NewSubService creates a new subscription service with the given configuration. @@ -40,12 +43,34 @@ func NewSubService(showInfo bool, remarkModel string) *SubService { } // GetSubs retrieves subscription links for a given subscription ID and host. -func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { +// If gin.Context is provided, it will also register HWID from HTTP headers (x-hwid, x-device-os, etc.). +func (s *SubService) GetSubs(subId string, host string, c *gin.Context) ([]string, int64, xray.ClientTraffic, error) { s.address = host var result []string var traffic xray.ClientTraffic var lastOnline int64 var clientTraffics []xray.ClientTraffic + + // Try to find client by subId in new architecture (ClientEntity) + db := database.GetDB() + var clientEntity *model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&clientEntity).Error + useNewArchitecture := (err == nil && clientEntity != nil) + + if err != nil { + logger.Debugf("GetSubs: Client not found by subId '%s': %v", subId, err) + } else if clientEntity != nil { + logger.Debugf("GetSubs: Found client by subId '%s': clientId=%d, email=%s, hwidEnabled=%v", + subId, clientEntity.Id, clientEntity.Email, clientEntity.HWIDEnabled) + } + + // Register HWID from headers if context is provided and client is found + if c != nil && clientEntity != nil { + s.registerHWIDFromRequest(c, clientEntity) + } else if c != nil { + logger.Debugf("GetSubs: Skipping HWID registration - client not found or context is nil (subId: %s)", subId) + } + inbounds, err := s.getInboundsBySubId(subId) if err != nil { return nil, 0, traffic, err @@ -59,14 +84,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C if err != nil { s.datepicker = "gregorian" } + for _, inbound := range inbounds { - clients, err := s.inboundService.GetClients(inbound) - if err != nil { - logger.Error("SubService - GetClients: Unable to get clients from inbound") - } - if clients == nil { - continue - } if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' { listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings) if err == nil { @@ -75,21 +94,48 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C inbound.StreamSettings = streamSettings } } - for _, client := range clients { - if client.Enable && client.SubID == subId { - link := s.getLink(inbound, client.Email) - // Split link by newline to handle multiple links (for multiple nodes) - linkLines := strings.Split(link, "\n") - for _, linkLine := range linkLines { - linkLine = strings.TrimSpace(linkLine) - if linkLine != "" { - result = append(result, linkLine) - } + + if useNewArchitecture { + // New architecture: use ClientEntity data directly + link := s.getLinkWithClient(inbound, clientEntity) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) } - ct := s.getClientTraffics(inbound.ClientStats, client.Email) - clientTraffics = append(clientTraffics, ct) - if ct.LastOnline > lastOnline { - lastOnline = ct.LastOnline + } + ct := s.getClientTraffics(inbound.ClientStats, clientEntity.Email) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } + } else { + // Old architecture: parse clients from Settings + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + logger.Error("SubService - GetClients: Unable to get clients from inbound") + } + if clients == nil { + continue + } + for _, client := range clients { + if client.Enable && client.SubID == subId { + link := s.getLink(inbound, client.Email) + // Split link by newline to handle multiple links (for multiple nodes) + linkLines := strings.Split(link, "\n") + for _, linkLine := range linkLines { + linkLine = strings.TrimSpace(linkLine) + if linkLine != "" { + result = append(result, linkLine) + } + } + ct := s.getClientTraffics(inbound.ClientStats, client.Email) + clientTraffics = append(clientTraffics, ct) + if ct.LastOnline > lastOnline { + lastOnline = ct.LastOnline + } } } } @@ -120,10 +166,45 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C return result, lastOnline, traffic, nil } +// getInboundsBySubId retrieves all inbounds assigned to a client with the given subId. +// New architecture: Find client by subId, then find inbounds through ClientInboundMapping. func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { db := database.GetDB() + + // First, try to find client by subId in ClientEntity (new architecture) + var client model.ClientEntity + err := db.Where("sub_id = ? AND enable = ?", subId, true).First(&client).Error + if err == nil { + // Found client in new architecture, get inbounds through mapping + var mappings []model.ClientInboundMapping + err = db.Where("client_id = ?", client.Id).Find(&mappings).Error + if err != nil { + return nil, err + } + + if len(mappings) == 0 { + return []*model.Inbound{}, nil + } + + inboundIds := make([]int, len(mappings)) + for i, mapping := range mappings { + inboundIds[i] = mapping.InboundId + } + + var inbounds []*model.Inbound + err = db.Model(model.Inbound{}).Preload("ClientStats"). + Where("id IN ? AND enable = ? AND protocol IN ?", + inboundIds, true, []model.Protocol{model.VMESS, model.VLESS, model.Trojan, model.Shadowsocks}). + Find(&inbounds).Error + if err != nil { + return nil, err + } + return inbounds, nil + } + + // Fallback to old architecture: search in Settings JSON (for backward compatibility) var inbounds []*model.Inbound - err := db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( + err = db.Model(model.Inbound{}).Preload("ClientStats").Where(`id in ( SELECT DISTINCT inbounds.id FROM inbounds, JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client @@ -183,13 +264,44 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } -func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { - if inbound.Protocol != model.VMESS { - return "" +// getLinkWithClient generates a subscription link using ClientEntity data (new architecture) +func (s *SubService) getLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + switch inbound.Protocol { + case "vmess": + return s.genVmessLinkWithClient(inbound, client) + case "vless": + return s.genVlessLinkWithClient(inbound, client) + case "trojan": + return s.genTrojanLinkWithClient(inbound, client) + case "shadowsocks": + return s.genShadowsocksLinkWithClient(inbound, client) + } + return "" +} + +// AddressPort represents an address and port for subscription links +type AddressPort struct { + Address string + Port int // 0 means use inbound.Port +} + +// getAddressesForInbound returns addresses for subscription links. +// Priority: Host (if enabled) > Node addresses > default address +// Returns addresses and ports (0 means use inbound.Port) +func (s *SubService) getAddressesForInbound(inbound *model.Inbound) []AddressPort { + // First, check if there's a Host assigned to this inbound + host, err := s.hostService.GetHostForInbound(inbound.Id) + if err == nil && host != nil && host.Enable { + // Use host address and port + hostPort := host.Port + if hostPort > 0 { + return []AddressPort{{Address: host.Address, Port: hostPort}} + } + return []AddressPort{{Address: host.Address, Port: 0}} // 0 means use inbound.Port } - // Get all nodes for this inbound - var nodeAddresses []string + // Second, get node addresses if in multi-node mode + var nodeAddresses []AddressPort multiMode, _ := s.settingService.GetMultiNodeMode() if multiMode { nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) @@ -198,22 +310,33 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { for _, node := range nodes { nodeAddr := s.extractNodeHost(node.Address) if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) + nodeAddresses = append(nodeAddresses, AddressPort{Address: nodeAddr, Port: 0}) } } } } // Fallback to default logic if no nodes found - var defaultAddress string if len(nodeAddresses) == 0 { + var defaultAddress string if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { defaultAddress = s.address } else { defaultAddress = inbound.Listen } - nodeAddresses = []string{defaultAddress} + nodeAddresses = []AddressPort{{Address: defaultAddress, Port: 0}} } + + return nodeAddresses +} + +func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) // Base object template (address will be set per node) baseObj := map[string]any{ "v": "2", @@ -351,12 +474,16 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { + for _, addrPort := range nodeAddresses { obj := make(map[string]any) for k, v := range baseObj { obj[k] = v } - obj["add"] = nodeAddr + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } obj["ps"] = s.genRemark(inbound, email, "") if linkIndex > 0 { @@ -370,37 +497,385 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { return links } +// genVmessLinkWithClient generates VMESS link using ClientEntity data (new architecture) +func (s *SubService) genVmessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VMESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + // Base object template (address will be set per node) + baseObj := map[string]any{ + "v": "2", + "port": inbound.Port, + "type": "none", + } + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + network, _ := stream["network"].(string) + baseObj["net"] = network + switch network { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + baseObj["type"] = typeStr + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + baseObj["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + baseObj["type"], _ = header["type"].(string) + baseObj["path"], _ = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + baseObj["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + baseObj["path"] = grpc["serviceName"].(string) + baseObj["authority"] = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + baseObj["type"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + baseObj["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + baseObj["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + baseObj["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + baseObj["host"] = searchHost(headers) + } + baseObj["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + baseObj["tls"] = security + if security == "tls" { + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + if len(alpns) > 0 { + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + baseObj["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + baseObj["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + baseObj["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + baseObj["allowInsecure"], _ = insecure.(bool) + } + } + } + + // Use ClientEntity data directly + baseObj["id"] = client.UUID + baseObj["scy"] = client.Security + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + links := "" + linkIndex := 0 + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + newObj := map[string]any{} + for key, value := range baseObj { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "allowInsecure")) { + newObj[key] = value + } + } + newObj["ps"] = s.genRemark(inbound, client.Email, ep["remark"].(string)) + newObj["add"] = ep["dest"].(string) + newObj["port"] = int(ep["port"].(float64)) + + if newSecurity != "same" { + newObj["tls"] = newSecurity + } + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(newObj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + return links + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + obj := make(map[string]any) + for k, v := range baseObj { + obj[k] = v + } + obj["add"] = addrPort.Address + // Use port from Host if specified, otherwise use inbound.Port + if addrPort.Port > 0 { + obj["port"] = addrPort.Port + } + obj["ps"] = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + jsonStr, _ := json.MarshalIndent(obj, "", " ") + links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + linkIndex++ + } + + return links +} + +// genVlessLinkWithClient generates VLESS link using ClientEntity data (new architecture) +func (s *SubService) genVlessLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.VLESS { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + uuid := client.UUID + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + // Add encryption parameter for VLESS from inbound settings + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + if encryption, ok := settings["encryption"].(string); ok { + params["encryption"] = encryption + } + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + // Generate links for each node address (or external proxy) + var initialCapacity int + if len(externalProxies) > 0 { + initialCapacity = len(externalProxies) + } else { + initialCapacity = len(nodeAddresses) + } + links := make([]string, 0, initialCapacity) + + // First, handle external proxies if any + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + links = append(links, url.String()) + } + return strings.Join(links, "\n") + } + + // Generate links for each node address + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + links = append(links, url.String()) + } + + return strings.Join(links, "\n") +} + func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VLESS { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -595,8 +1070,13 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("vless://%s@%s:%d", uuid, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("vless://%s@%s:%d", uuid, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -615,37 +1095,215 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { return strings.Join(links, "\n") } +// genTrojanLinkWithClient generates Trojan link using ClientEntity data (new architecture) +func (s *SubService) genTrojanLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Trojan { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + password := client.Password + port := inbound.Port + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + if security == "reality" { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } + + if streamNetwork == "tcp" && len(client.Flow) > 0 { + params["flow"] = client.Flow + } + } + + if security != "tls" && security != "reality" { + params["security"] = "none" + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Trojan { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -827,8 +1485,13 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("trojan://%s@%s:%d", password, nodeAddr, port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("trojan://%s@%s:%d", password, addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -851,37 +1514,186 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string return links } +// genShadowsocksLinkWithClient generates Shadowsocks link using ClientEntity data (new architecture) +func (s *SubService) genShadowsocksLinkWithClient(inbound *model.Inbound, client *model.ClientEntity) string { + if inbound.Protocol != model.Shadowsocks { + return "" + } + + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) + var stream map[string]any + json.Unmarshal([]byte(inbound.StreamSettings), &stream) + + var settings map[string]any + json.Unmarshal([]byte(inbound.Settings), &settings) + inboundPassword := settings["password"].(string) + method := settings["method"].(string) + streamNetwork := stream["network"].(string) + params := make(map[string]string) + params["type"] = streamNetwork + + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + kcp, _ := stream["kcpSettings"].(map[string]any) + header, _ := kcp["header"].(map[string]any) + params["headerType"] = header["type"].(string) + params["seed"] = kcp["seed"].(string) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + params["path"] = ws["path"].(string) + if host, ok := ws["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := ws["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + params["path"] = httpupgrade["path"].(string) + if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := httpupgrade["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + params["path"] = xhttp["path"].(string) + if host, ok := xhttp["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := xhttp["headers"].(map[string]any) + params["host"] = searchHost(headers) + } + params["mode"] = xhttp["mode"].(string) + } + + security, _ := stream["security"].(string) + if security == "tls" { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + if insecure, ok := searchKey(tlsSettings, "allowInsecure"); ok { + if insecure.(bool) { + params["allowInsecure"] = "1" + } + } + } + } + + encPart := fmt.Sprintf("%s:%s", method, client.Password) + if method[0] == '2' { + encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, client.Password) + } + + externalProxies, _ := stream["externalProxy"].([]any) + + links := "" + linkIndex := 0 + + if len(externalProxies) > 0 { + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + epPort := int(ep["port"].(float64)) + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, epPort) + + if newSecurity != "same" { + params["security"] = newSecurity + } else { + params["security"] = security + } + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp" || k == "allowInsecure")) { + q.Add(k, v) + } + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, ep["remark"].(string)) + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + return links + } + + for _, addrPort := range nodeAddresses { + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) + url, _ := url.Parse(link) + q := url.Query() + + for k, v := range params { + q.Add(k, v) + } + + url.RawQuery = q.Encode() + url.Fragment = s.genRemark(inbound, client.Email, "") + + if linkIndex > 0 { + links += "\n" + } + links += url.String() + linkIndex++ + } + + return links +} + func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Shadowsocks { return "" } - // Get all nodes for this inbound - var nodeAddresses []string - multiMode, _ := s.settingService.GetMultiNodeMode() - if multiMode { - nodes, err := s.nodeService.GetNodesForInbound(inbound.Id) - if err == nil && len(nodes) > 0 { - // Extract addresses from all nodes - for _, node := range nodes { - nodeAddr := s.extractNodeHost(node.Address) - if nodeAddr != "" { - nodeAddresses = append(nodeAddresses, nodeAddr) - } - } - } - } - - // Fallback to default logic if no nodes found - var defaultAddress string - if len(nodeAddresses) == 0 { - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - defaultAddress = s.address - } else { - defaultAddress = inbound.Listen - } - nodeAddresses = []string{defaultAddress} - } + // Get addresses (Host > Nodes > Default) + nodeAddresses := s.getAddressesForInbound(inbound) var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) @@ -1034,8 +1846,13 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st } // Generate links for each node address - for _, nodeAddr := range nodeAddresses { - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), nodeAddr, inbound.Port) + for _, addrPort := range nodeAddresses { + // Use port from Host if specified, otherwise use inbound.Port + linkPort := inbound.Port + if addrPort.Port > 0 { + linkPort = addrPort.Port + } + link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), addrPort.Address, linkPort) url, _ := url.Parse(link) q := url.Query() @@ -1386,3 +2203,87 @@ func (s *SubService) extractNodeHost(nodeAddress string) string { } return host } + +// registerHWIDFromRequest registers HWID from HTTP headers in the request context. +// This method reads HWID and device metadata from headers and calls RegisterHWIDFromHeaders. +func (s *SubService) registerHWIDFromRequest(c *gin.Context, clientEntity *model.ClientEntity) { + logger.Debugf("registerHWIDFromRequest called for client %d (subId: %s, email: %s, hwidEnabled: %v)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, clientEntity.HWIDEnabled) + + // Check HWID mode - only register in client_header mode + settingService := service.SettingService{} + hwidMode, err := settingService.GetHwidMode() + if err != nil { + logger.Debugf("Failed to get hwidMode setting: %v", err) + return + } + logger.Debugf("Current hwidMode: %s", hwidMode) + + // Only register in client_header mode + if hwidMode != "client_header" { + logger.Debugf("HWID registration skipped: hwidMode is '%s' (not 'client_header') for client %d (subId: %s)", + hwidMode, clientEntity.Id, clientEntity.SubID) + return + } + + // Check if client has HWID tracking enabled + if !clientEntity.HWIDEnabled { + logger.Debugf("HWID registration skipped: HWID tracking disabled for client %d (subId: %s, email: %s)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read HWID from headers (required) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + // No HWID header - mark as "unknown" device, don't register + // In client_header mode, we don't auto-generate HWID + logger.Debugf("No x-hwid header provided for client %d (subId: %s, email: %s) - HWID not registered", + clientEntity.Id, clientEntity.SubID, clientEntity.Email) + return + } + + // Read device metadata from headers (optional) + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Register HWID + hwidService := service.ClientHWIDService{} + hwidRecord, err := hwidService.RegisterHWIDFromHeaders(clientEntity.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + // Log as warning - this is an expected error when limit is reached + logger.Warningf("HWID limit exceeded for client %d (subId: %s, email: %s): %v", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, err) + // Note: We still allow the subscription request to proceed + // The client application should handle this error and inform the user + // that they need to remove an existing device or contact admin to increase limit + } else { + // Other errors - log as warning but don't fail subscription + logger.Warningf("Failed to register HWID for client %d (subId: %s): %v", clientEntity.Id, clientEntity.SubID, err) + } + // HWID registration failure should not block subscription access + // The subscription will still be returned, but HWID won't be registered + } else if hwidRecord != nil { + // Successfully registered HWID + logger.Debugf("Successfully registered HWID for client %d (subId: %s, email: %s, hwid: %s, hwidId: %d)", + clientEntity.Id, clientEntity.SubID, clientEntity.Email, hwid, hwidRecord.Id) + } +} diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 9aa05ed3..16ee5d34 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1902,7 +1902,7 @@ Inbound.Settings = class extends XrayCommonClass { Inbound.VmessSettings = class extends Inbound.Settings { constructor(protocol, - vmesses = [new Inbound.VmessSettings.VMESS()]) { + vmesses = []) { super(protocol); this.vmesses = vmesses; } @@ -2018,7 +2018,7 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass { Inbound.VLESSSettings = class extends Inbound.Settings { constructor( protocol, - vlesses = [new Inbound.VLESSSettings.VLESS()], + vlesses = [], decryption = "none", encryption = "none", fallbacks = [], @@ -2208,7 +2208,7 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { Inbound.TrojanSettings = class extends Inbound.Settings { constructor(protocol, - trojans = [new Inbound.TrojanSettings.Trojan()], + trojans = [], fallbacks = [],) { super(protocol); this.trojans = trojans; @@ -2373,7 +2373,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), network = 'tcp,udp', - shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()], + shadowsockses = [], ivCheck = false, ) { super(protocol); diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index 3446832d..fbf1233b 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -74,6 +74,12 @@ class AllSetting { // Multi-node mode settings this.multiNodeMode = false; // Multi-node mode setting + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + this.hwidMode = "client_header"; // HWID tracking mode if (data == null) { return @@ -90,6 +96,18 @@ class AllSetting { } else { this.multiNodeMode = false; } + + // Ensure hwidMode is valid string (default to "client_header" if invalid) + if (this.hwidMode === undefined || this.hwidMode === null) { + this.hwidMode = "client_header"; + } else if (typeof this.hwidMode !== 'string') { + this.hwidMode = String(this.hwidMode); + } + // Validate hwidMode value + const validHwidModes = ["off", "client_header", "legacy_fingerprint"]; + if (!validHwidModes.includes(this.hwidMode)) { + this.hwidMode = "client_header"; // Default to client_header if invalid + } } equals(other) { diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..a1417c9d --- /dev/null +++ b/web/controller/client.go @@ -0,0 +1,274 @@ +// Package controller provides HTTP handlers for client management. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// ClientController handles HTTP requests related to client management. +type ClientController struct { + clientService service.ClientService + xrayService service.XrayService +} + +// NewClientController creates a new ClientController and sets up its routes. +func NewClientController(g *gin.RouterGroup) *ClientController { + a := &ClientController{ + clientService: service.ClientService{}, + xrayService: service.XrayService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for client-related operations. +func (a *ClientController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getClients) + g.GET("/get/:id", a.getClient) + g.POST("/add", a.addClient) + g.POST("/update/:id", a.updateClient) + g.POST("/del/:id", a.deleteClient) +} + +// getClients retrieves the list of all clients for the current user. +func (a *ClientController) getClients(c *gin.Context) { + user := session.GetLoginUser(c) + clients, err := a.clientService.GetClients(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, clients, nil) +} + +// getClient retrieves a specific client by its ID. +func (a *ClientController) getClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + user := session.GetLoginUser(c) + client, err := a.clientService.GetClient(id) + if err != nil { + jsonMsg(c, "Failed to get client", err) + return + } + if client.UserId != user.Id { + jsonMsg(c, "Client not found or access denied", nil) + return + } + jsonObj(c, client, nil) +} + +// addClient creates a new client. +func (a *ClientController) addClient(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err := c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + } + } + + needRestart, err := a.clientService.AddClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to add client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientCreateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client creation: %v", err) + } + } +} + +// updateClient updates an existing client. +func (a *ClientController) updateClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + client := &model.ClientEntity{} + err = c.ShouldBind(client) + if err != nil { + jsonMsg(c, "Invalid client data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + client.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateClient: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + client.InboundIds = inboundIds + logger.Debugf("UpdateClient: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateClient: inboundIds not provided, keeping existing assignments") + } + } + + client.Id = id + logger.Debugf("UpdateClient: client.InboundIds = %v", client.InboundIds) + needRestart, err := a.clientService.UpdateClient(user.Id, client) + if err != nil { + logger.Errorf("Failed to update client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.clients.toasts.clientUpdateSuccess"), client, nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client update: %v", err) + } + } +} + +// deleteClient deletes a client by ID. +func (a *ClientController) deleteClient(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid client ID", err) + return + } + + user := session.GetLoginUser(c) + needRestart, err := a.clientService.DeleteClient(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete client: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.clients.toasts.clientDeleteSuccess"), nil) + if needRestart { + // In multi-node mode, this will send config to nodes immediately + // In single mode, this will restart local Xray + if err := a.xrayService.RestartXray(false); err != nil { + logger.Warningf("Failed to restart Xray after client deletion: %v", err) + } + } +} diff --git a/web/controller/client_hwid.go b/web/controller/client_hwid.go new file mode 100644 index 00000000..29ac79b3 --- /dev/null +++ b/web/controller/client_hwid.go @@ -0,0 +1,224 @@ +// Package controller provides HTTP handlers for client HWID management. +package controller + +import ( + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/mhsanaei/3x-ui/v2/web/service" +) + +// ClientHWIDController handles HTTP requests for client HWID management. +type ClientHWIDController struct { + clientHWIDService *service.ClientHWIDService + clientService *service.ClientService +} + +// NewClientHWIDController creates a new ClientHWIDController. +func NewClientHWIDController(g *gin.RouterGroup) *ClientHWIDController { + a := &ClientHWIDController{ + clientHWIDService: &service.ClientHWIDService{}, + clientService: &service.ClientService{}, + } + a.initRouter(g) + return a +} + +// initRouter sets up routes for client HWID management. +func (a *ClientHWIDController) initRouter(g *gin.RouterGroup) { + g = g.Group("/hwid") + { + g.GET("/list/:clientId", a.getHWIDs) + g.POST("/add", a.addHWID) + g.POST("/del/:id", a.removeHWID) // Changed to /del/:id to match API style + g.POST("/deactivate/:id", a.deactivateHWID) + g.POST("/check", a.checkHWID) + g.POST("/register", a.registerHWID) + } +} + +// getHWIDs retrieves all HWIDs for a specific client. +func (a *ClientHWIDController) getHWIDs(c *gin.Context) { + clientIdStr := c.Param("clientId") + clientId, err := strconv.Atoi(clientIdStr) + if err != nil { + jsonMsg(c, "Invalid client ID", nil) + return + } + + hwids, err := a.clientHWIDService.GetHWIDsForClient(clientId) + if err != nil { + jsonMsg(c, "Failed to get HWIDs", err) + return + } + + jsonObj(c, hwids, nil) +} + +// addHWID adds a new HWID for a client (manual addition by admin). +func (a *ClientHWIDController) addHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + DeviceOS string `json:"deviceOs" form:"deviceOs"` + DeviceModel string `json:"deviceModel" form:"deviceModel"` + OSVersion string `json:"osVersion" form:"osVersion"` + IPAddress string `json:"ipAddress" form:"ipAddress"` + UserAgent string `json:"userAgent" form:"userAgent"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + hwid, err := a.clientHWIDService.AddHWIDForClient(req.ClientId, req.HWID, req.DeviceOS, req.DeviceModel, req.OSVersion, req.IPAddress, req.UserAgent) + if err != nil { + jsonMsg(c, "Failed to add HWID", err) + return + } + + jsonObj(c, hwid, nil) +} + +// removeHWID removes a HWID from a client. +func (a *ClientHWIDController) removeHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.RemoveHWID(id) + if err != nil { + jsonMsg(c, "Failed to remove HWID", err) + return + } + + jsonMsg(c, "HWID removed successfully", nil) +} + +// deactivateHWID deactivates a HWID (marks as inactive). +func (a *ClientHWIDController) deactivateHWID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + jsonMsg(c, "Invalid HWID ID", nil) + return + } + + err = a.clientHWIDService.DeactivateHWID(id) + if err != nil { + jsonMsg(c, "Failed to deactivate HWID", err) + return + } + + jsonMsg(c, "HWID deactivated successfully", nil) +} + +// checkHWID checks if a HWID is allowed for a client. +func (a *ClientHWIDController) checkHWID(c *gin.Context) { + var req struct { + ClientId int `json:"clientId" form:"clientId" binding:"required"` + HWID string `json:"hwid" form:"hwid" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + allowed, err := a.clientHWIDService.CheckHWIDAllowed(req.ClientId, req.HWID) + if err != nil { + jsonMsg(c, "Failed to check HWID", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "obj": gin.H{ + "allowed": allowed, + }, + }) +} + +// registerHWID registers a HWID for a client (called by client applications). +// This endpoint reads HWID and device metadata from HTTP headers: +// - x-hwid (required): Hardware ID +// - x-device-os (optional): Device operating system +// - x-device-model (optional): Device model +// - x-ver-os (optional): OS version +// - user-agent (optional): User agent string +func (a *ClientHWIDController) registerHWID(c *gin.Context) { + var req struct { + Email string `json:"email" form:"email" binding:"required"` + } + + if err := c.ShouldBind(&req); err != nil { + jsonMsg(c, "Invalid request", err) + return + } + + // Read HWID from headers (primary method) + hwid := c.GetHeader("x-hwid") + if hwid == "" { + // Try alternative header name (case-insensitive) + hwid = c.GetHeader("X-HWID") + } + if hwid == "" { + jsonMsg(c, "HWID is required (x-hwid header missing)", nil) + return + } + + // Read device metadata from headers + deviceOS := c.GetHeader("x-device-os") + if deviceOS == "" { + deviceOS = c.GetHeader("X-Device-OS") + } + deviceModel := c.GetHeader("x-device-model") + if deviceModel == "" { + deviceModel = c.GetHeader("X-Device-Model") + } + osVersion := c.GetHeader("x-ver-os") + if osVersion == "" { + osVersion = c.GetHeader("X-Ver-OS") + } + userAgent := c.GetHeader("User-Agent") + ipAddress := c.ClientIP() + + // Get client by email + client, err := a.clientService.GetClientByEmail(1, req.Email) // TODO: Get userId from session + if err != nil { + jsonMsg(c, "Client not found", err) + return + } + + // Register HWID using RegisterHWIDFromHeaders + hwidRecord, err := a.clientHWIDService.RegisterHWIDFromHeaders(client.Id, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent) + if err != nil { + // Check if error is HWID limit exceeded + if strings.Contains(err.Error(), "HWID limit exceeded") { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "msg": err.Error(), + }) + return + } + jsonMsg(c, "Failed to register HWID", err) + return + } + + if hwidRecord == nil { + // HWID tracking disabled (hwidMode = "off") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "msg": "HWID tracking is disabled", + }) + return + } + + jsonObj(c, hwidRecord, nil) +} diff --git a/web/controller/host.go b/web/controller/host.go new file mode 100644 index 00000000..d31a2303 --- /dev/null +++ b/web/controller/host.go @@ -0,0 +1,253 @@ +// Package controller provides HTTP handlers for host management in multi-node mode. +package controller + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + "github.com/mhsanaei/3x-ui/v2/web/service" + "github.com/mhsanaei/3x-ui/v2/web/session" + + "github.com/gin-gonic/gin" +) + +// HostController handles HTTP requests related to host management. +type HostController struct { + hostService service.HostService +} + +// NewHostController creates a new HostController and sets up its routes. +func NewHostController(g *gin.RouterGroup) *HostController { + a := &HostController{ + hostService: service.HostService{}, + } + a.initRouter(g) + return a +} + +// initRouter initializes the routes for host-related operations. +func (a *HostController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.getHosts) + g.GET("/get/:id", a.getHost) + g.POST("/add", a.addHost) + g.POST("/update/:id", a.updateHost) + g.POST("/del/:id", a.deleteHost) +} + +// getHosts retrieves the list of all hosts for the current user. +func (a *HostController) getHosts(c *gin.Context) { + user := session.GetLoginUser(c) + hosts, err := a.hostService.GetHosts(user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, hosts, nil) +} + +// getHost retrieves a specific host by its ID. +func (a *HostController) getHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + user := session.GetLoginUser(c) + host, err := a.hostService.GetHost(id) + if err != nil { + jsonMsg(c, "Failed to get host", err) + return + } + if host.UserId != user.Id { + jsonMsg(c, "Host not found or access denied", nil) + return + } + jsonObj(c, host, nil) +} + +// addHost creates a new host. +func (a *HostController) addHost(c *gin.Context) { + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err := c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("AddHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("AddHost: extracted inboundIds from form: %v", inboundIds) + } + } + + logger.Debugf("AddHost: host.InboundIds before service call: %v", host.InboundIds) + err = a.hostService.AddHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to add host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostCreateSuccess"), host, nil) +} + +// updateHost updates an existing host. +func (a *HostController) updateHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + + // Extract inboundIds from JSON or form data + var inboundIdsFromJSON []int + var hasInboundIdsInJSON bool + + if c.ContentType() == "application/json" { + // Read raw body to extract inboundIds + bodyBytes, err := c.GetRawData() + if err == nil && len(bodyBytes) > 0 { + // Parse JSON to extract inboundIds + var jsonData map[string]interface{} + if err := json.Unmarshal(bodyBytes, &jsonData); err == nil { + // Check for inboundIds array + if inboundIdsVal, ok := jsonData["inboundIds"]; ok { + hasInboundIdsInJSON = true + if inboundIdsArray, ok := inboundIdsVal.([]interface{}); ok { + for _, val := range inboundIdsArray { + if num, ok := val.(float64); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := val.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } else if num, ok := inboundIdsVal.(float64); ok { + // Single number instead of array + inboundIdsFromJSON = append(inboundIdsFromJSON, int(num)) + } else if num, ok := inboundIdsVal.(int); ok { + inboundIdsFromJSON = append(inboundIdsFromJSON, num) + } + } + } + // Restore body for ShouldBind + c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + } + } + + host := &model.Host{} + err = c.ShouldBind(host) + if err != nil { + jsonMsg(c, "Invalid host data", err) + return + } + + // Set inboundIds from JSON if available + if hasInboundIdsInJSON { + host.InboundIds = inboundIdsFromJSON + logger.Debugf("UpdateHost: extracted inboundIds from JSON: %v", inboundIdsFromJSON) + } else { + // Try to get from form data + inboundIdsStr := c.PostFormArray("inboundIds") + if len(inboundIdsStr) > 0 { + var inboundIds []int + for _, idStr := range inboundIdsStr { + if idStr != "" { + if id, err := strconv.Atoi(idStr); err == nil && id > 0 { + inboundIds = append(inboundIds, id) + } + } + } + host.InboundIds = inboundIds + logger.Debugf("UpdateHost: extracted inboundIds from form: %v", inboundIds) + } else { + logger.Debugf("UpdateHost: inboundIds not provided, keeping existing assignments") + } + } + + host.Id = id + err = a.hostService.UpdateHost(user.Id, host) + if err != nil { + logger.Errorf("Failed to update host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsgObj(c, I18nWeb(c, "pages.hosts.toasts.hostUpdateSuccess"), host, nil) +} + +// deleteHost deletes a host by ID. +func (a *HostController) deleteHost(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, "Invalid host ID", err) + return + } + + user := session.GetLoginUser(c) + err = a.hostService.DeleteHost(user.Id, id) + if err != nil { + logger.Errorf("Failed to delete host: %v", err) + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + + jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.hostDeleteSuccess"), nil) +} diff --git a/web/controller/xui.go b/web/controller/xui.go index f11a0422..137687eb 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -30,10 +30,17 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) g.GET("/nodes", a.nodes) + g.GET("/clients", a.clients) + g.GET("/hosts", a.hosts) a.settingController = NewSettingController(g) a.xraySettingController = NewXraySettingController(g) a.nodeController = NewNodeController(g.Group("/node")) + + // Register client and host controllers directly under /panel (not /panel/api) + NewClientController(g.Group("/client")) + NewHostController(g.Group("/host")) + NewClientHWIDController(g.Group("/client")) // Register HWID controller under /panel/client/hwid } // index renders the main panel index page. @@ -60,3 +67,13 @@ func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) nodes(c *gin.Context) { html(c, "nodes.html", "pages.nodes.title", nil) } + +// clients renders the clients management page. +func (a *XUIController) clients(c *gin.Context) { + html(c, "clients.html", "pages.clients.title", nil) +} + +// hosts renders the hosts management page (multi-node mode). +func (a *XUIController) hosts(c *gin.Context) { + html(c, "hosts.html", "pages.hosts.title", nil) +} diff --git a/web/entity/entity.go b/web/entity/entity.go index 030da972..31eb3aeb 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -101,6 +101,12 @@ type AllSetting struct { // Multi-node mode setting MultiNodeMode bool `json:"multiNodeMode" form:"multiNodeMode"` // Enable multi-node architecture mode + + // HWID tracking mode + // "off" = HWID tracking disabled + // "client_header" = HWID provided by client via x-hwid header (default, recommended) + // "legacy_fingerprint" = deprecated fingerprint-based HWID generation (deprecated, for backward compatibility only) + HwidMode string `json:"hwidMode" form:"hwidMode"` // HWID tracking mode // JSON subscription routing rules } @@ -171,5 +177,15 @@ func (s *AllSetting) CheckValid() error { return common.NewError("time location not exist:", s.TimeLocation) } + // Validate HWID mode + validHwidModes := map[string]bool{ + "off": true, + "client_header": true, + "legacy_fingerprint": true, + } + if s.HwidMode != "" && !validHwidModes[s.HwidMode] { + return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", s.HwidMode) + } + return nil } diff --git a/web/html/clients.html b/web/html/clients.html new file mode 100644 index 00000000..19e033f7 --- /dev/null +++ b/web/html/clients.html @@ -0,0 +1,730 @@ +{{ template "page/head_start" .}} +{{ template "page/head_end" .}} + +{{ template "page/body_start" .}} + + + + + + + + +

{{ i18n "pages.clients.title" }}

+ +
+ {{ i18n "pages.clients.addClient" }} + {{ i18n "refresh" }} + + + + + +
+ + + + + + + + + + +
+
+
+ + + + + +
+
+
+
+ +{{template "page/body_scripts" .}} + + + + +{{template "component/aSidebar" .}} +{{template "component/aThemeSwitch" .}} +{{template "modals/qrcodeModal"}} +{{template "modals/clientEntityModal"}} + +{{ template "page/body_end" .}} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html index 8f0b1ee5..1dbc0e3b 100644 --- a/web/html/component/aSidebar.html +++ b/web/html/component/aSidebar.html @@ -76,6 +76,11 @@ icon: 'user', title: '{{ i18n "menu.inbounds"}}' }, + { + key: '{{ .base_path }}panel/clients', + icon: 'team', + title: '{{ i18n "menu.clients"}}' + }, { key: '{{ .base_path }}panel/settings', icon: 'setting', @@ -88,13 +93,18 @@ } ]; - // Add Nodes menu item if multi-node mode is enabled + // Add Nodes and Hosts menu items if multi-node mode is enabled if (this.multiNodeMode) { - this.tabs.splice(3, 0, { + this.tabs.splice(4, 0, { key: '{{ .base_path }}panel/nodes', icon: 'cluster', title: '{{ i18n "menu.nodes"}}' }); + this.tabs.splice(5, 0, { + key: '{{ .base_path }}panel/hosts', + icon: 'cloud-server', + title: '{{ i18n "menu.hosts"}}' + }); } this.tabs.push({ diff --git a/web/html/form/protocol/shadowsocks.html b/web/html/form/protocol/shadowsocks.html index 06e12075..2f3ec787 100644 --- a/web/html/form/protocol/shadowsocks.html +++ b/web/html/form/protocol/shadowsocks.html @@ -1,11 +1,6 @@ {{define "form/shadowsocks"}} -