refactor panel new logic

This commit is contained in:
Konstantin Pichugin 2026-01-09 15:36:14 +03:00
parent 66662afa4d
commit 7e2f3fda03
35 changed files with 5369 additions and 233 deletions

View file

@ -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 {

View file

@ -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"
}

View file

@ -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

View file

@ -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)
}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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);

View file

@ -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) {

274
web/controller/client.go Normal file
View file

@ -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)
}
}
}

View file

@ -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)
}

253
web/controller/host.go Normal file
View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

730
web/html/clients.html Normal file
View file

@ -0,0 +1,730 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' clients-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }">
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.clients.title" }}</h2>
<div style="margin-bottom: 20px;">
<a-button type="primary" icon="plus" @click="openAddClient">{{ i18n "pages.clients.addClient" }}</a-button>
<a-button icon="sync" @click="manualRefresh" :loading="refreshing" style="margin-left: 10px;">{{ i18n "refresh" }}</a-button>
<a-popover placement="bottomRight" trigger="click" :overlay-class-name="themeSwitcher.currentTheme" style="margin-left: 10px;">
<template #title>
<div class="ant-custom-popover-title">
<a-switch v-model="isRefreshEnabled" @change="toggleRefresh" size="small"></a-switch>
<span>{{ i18n "pages.inbounds.autoRefresh" }}</span>
</div>
</template>
<template #content>
<a-space direction="vertical">
<span>{{ i18n "pages.inbounds.autoRefreshInterval" }}</span>
<a-select v-model="refreshInterval" :disabled="!isRefreshEnabled" :style="{ width: '100%' }"
@change="changeRefreshInterval" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in [5,10,30,60]" :value="key*1000">[[ key ]]s</a-select-option>
</a-select>
</a-space>
</template>
<a-button icon="down"></a-button>
</a-popover>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="client => client.id"
:data-source="clients" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="clients-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, client">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, client)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="qrcode" v-if="client.inbounds && client.inbounds.length > 0">
<a-icon type="qrcode"></a-icon>
{{ i18n "qrCode" }}
</a-menu-item>
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
{{ i18n "delete" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="email" slot-scope="text, client">
<span>[[ client.email || '-' ]]</span>
</template>
<template slot="inbounds" slot-scope="text, client">
<template v-if="client.inbounds && client.inbounds.length > 0">
<a-tag v-for="(inbound, index) in client.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-tag>
</template>
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
</template>
<template slot="enable" slot-scope="text, client">
<a-switch v-model="client.enable" @change="switchEnable(client.id, client.enable)"></a-switch>
</template>
<template slot="status" slot-scope="text, client">
<a-tag v-if="isClientOnline(client.email)" color="green">{{ i18n "online" }}</a-tag>
<a-tag v-else color="default">{{ i18n "offline" }}</a-tag>
</template>
<template slot="traffic" slot-scope="text, client">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<table cellpadding="2" width="100%">
<tr>
<td>↑[[ SizeFormatter.sizeFormat(client.up || 0) ]]</td>
<td>↓[[ SizeFormatter.sizeFormat(client.down || 0) ]]</td>
</tr>
<tr v-if="getClientTotal(client) > 0 && (client.up || 0) + (client.down || 0) < getClientTotal(client)">
<td>{{ i18n "remained" }}</td>
<td>[[ SizeFormatter.sizeFormat(getClientTotal(client) - (client.up || 0) - (client.down || 0)) ]]</td>
</tr>
</table>
</template>
<a-tag :color="ColorUtils.usageColor((client.up || 0) + (client.down || 0), 0, getClientTotal(client))">
[[ SizeFormatter.sizeFormat((client.up || 0) + (client.down || 0)) ]] /
<template v-if="getClientTotal(client) > 0">
[[ SizeFormatter.sizeFormat(getClientTotal(client)) ]]
</template>
<template v-else>
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</template>
</a-tag>
</a-popover>
</template>
<template slot="expiryTime" slot-scope="text, client">
<a-popover v-if="client.expiryTime > 0" :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
[[ IntlUtil.formatDate(client.expiryTime) ]]
</template>
<a-tag :style="{ minWidth: '50px' }"
:color="ColorUtils.usageColor(new Date().getTime(), 0, client.expiryTime)">
[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]
</a-tag>
</a-popover>
<a-tag v-else color="purple" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
</svg>
</a-tag>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-row v-else>
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "modals/qrcodeModal"}}
{{template "modals/clientEntityModal"}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 50,
}, {
title: '{{ i18n "pages.clients.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.clients.email" }}',
align: 'left',
width: 200,
scopedSlots: { customRender: 'email' },
}, {
title: '{{ i18n "pages.clients.inbounds" }}',
align: 'left',
width: 250,
scopedSlots: { customRender: 'inbounds' },
}, {
title: '{{ i18n "status" }}',
align: 'center',
width: 100,
scopedSlots: { customRender: 'status' },
}, {
title: '{{ i18n "pages.clients.traffic" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'traffic' },
}, {
title: '{{ i18n "pages.clients.expiryTime" }}',
align: 'left',
width: 120,
scopedSlots: { customRender: 'expiryTime' },
}, {
title: '{{ i18n "pages.clients.enable" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'enable' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
}, {
title: '{{ i18n "pages.clients.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.clients.email" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'email' },
}, {
title: '{{ i18n "pages.clients.enable" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'enable' },
}];
const app = window.app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
clients: [],
allInbounds: [],
availableNodes: [],
refreshing: false,
onlineClients: [],
lastOnlineMap: {},
isRefreshEnabled: localStorage.getItem("isRefreshEnabled") === "true" ? true : false,
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
subSettings: {
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
},
remarkModel: '-ieo',
},
methods: {
loading(spinning = true) {
this.loadingStates.spinning = spinning;
},
async loadClients() {
this.refreshing = true;
try {
// Load online clients and last online map first
await this.getOnlineUsers();
await this.getLastOnlineMap();
const msg = await HttpUtil.get('/panel/client/list');
if (msg && msg.success && msg.obj) {
this.clients = msg.obj;
// Load inbounds for each client
await this.loadInboundsForClients();
}
} catch (e) {
console.error("Failed to load clients:", e);
app.$message.error('{{ i18n "pages.clients.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
async getOnlineUsers() {
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
if (!msg.success) {
return;
}
this.onlineClients = msg.obj != null ? msg.obj : [];
},
async getLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
if (!msg.success || !msg.obj) return;
this.lastOnlineMap = msg.obj || {}
},
isClientOnline(email) {
return this.onlineClients.includes(email);
},
getLastOnline(email) {
return this.lastOnlineMap[email] || null
},
formatLastOnline(email) {
const ts = this.getLastOnline(email)
if (!ts) return '-'
// Check if IntlUtil is available (may not be loaded yet)
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
return IntlUtil.formatDate(ts)
}
// Fallback to simple date formatting if IntlUtil is not available
return new Date(ts).toLocaleString()
},
getClientTotal(client) {
// Convert TotalGB to bytes (1 GB = 1024^3 bytes)
if (client.totalGB && client.totalGB > 0) {
return client.totalGB * 1024 * 1024 * 1024;
}
return 0;
},
async loadInboundsForClients() {
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
this.allInbounds = inboundsMsg.obj;
// Map inbound IDs to full inbound objects for each client
this.clients.forEach(client => {
if (client.inboundIds && Array.isArray(client.inboundIds)) {
client.inbounds = client.inboundIds.map(id => {
return this.allInbounds.find(ib => ib.id === id);
}).filter(ib => ib != null);
} else {
client.inbounds = [];
}
});
}
} catch (e) {
console.error("Failed to load inbounds for clients:", e);
}
},
async getDefaultSettings() {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg.success) {
return;
}
with (msg.obj) {
this.subSettings = {
enable: subEnable,
subTitle: subTitle,
subURI: subURI,
subJsonURI: subJsonURI,
subJsonEnable: subJsonEnable,
};
this.remarkModel = remarkModel;
}
},
async loadAvailableNodes() {
try {
const msg = await HttpUtil.get("/panel/node/list");
if (msg && msg.success && msg.obj) {
this.availableNodes = msg.obj.map(node => ({
id: node.id,
name: node.name,
address: node.address,
status: node.status || 'unknown'
}));
}
} catch (e) {
console.error("Failed to load available nodes:", e);
}
},
clickAction(action, client) {
switch (action.key) {
case 'qrcode':
this.showQrcode(client);
break;
case 'edit':
this.editClient(client);
break;
case 'delete':
this.deleteClient(client.id);
break;
}
},
showQrcode(client) {
// Show QR codes for all inbounds assigned to this client
if (!client.inbounds || client.inbounds.length === 0) {
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
return;
}
// Convert ClientEntity to client format for qrModal
const clientForQR = {
email: client.email,
id: client.uuid || client.email,
password: client.password || '',
security: client.security || 'auto',
flow: client.flow || '',
subId: client.subId || '' // Add subId for subscription link generation
};
// Collect QR codes from all inbounds
const allQRCodes = [];
// Process each inbound assigned to this client
client.inbounds.forEach(inbound => {
if (!inbound) return;
// Load full inbound data to create DBInbound
const dbInbound = this.allInbounds.find(ib => ib.id === inbound.id);
if (!dbInbound) return;
// Create a DBInbound object from the inbound data
const dbInboundObj = new DBInbound(dbInbound);
const inboundObj = dbInboundObj.toInbound();
// Generate links for this inbound
// Get inbound remark (fallback to ID if remark is empty)
const inboundRemarkForWireguard = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
if (inboundObj.protocol == Protocols.WIREGUARD) {
inboundObj.genInboundLinks(dbInbound.remark).split('\r\n').forEach((l, index) => {
allQRCodes.push({
remark: inboundRemarkForWireguard + " - Peer " + (index + 1),
link: l,
useIPv4: false,
originalLink: l
});
});
} else {
const links = inboundObj.genAllLinks(dbInbound.remark, this.remarkModel, clientForQR);
const hasMultipleNodes = links.length > 1 && links.some(l => l.nodeId !== null);
const hasMultipleInbounds = client.inbounds.length > 1;
// Get inbound remark (fallback to ID if remark is empty)
const inboundRemark = (dbInbound.remark && dbInbound.remark.trim()) || ('Inbound #' + dbInbound.id);
links.forEach(l => {
// Build display remark - always start with inbound name
let displayRemark = inboundRemark;
// If multiple nodes, append node name
if (hasMultipleNodes && l.nodeId !== null) {
const node = this.availableNodes && this.availableNodes.find(n => n.id === l.nodeId);
if (node && node.name) {
displayRemark = inboundRemark + " - " + node.name;
}
}
// Ensure remark is never empty
if (!displayRemark || !displayRemark.trim()) {
displayRemark = 'Inbound #' + dbInbound.id;
}
allQRCodes.push({
remark: displayRemark,
link: l.link,
useIPv4: false,
originalLink: l.link,
nodeId: l.nodeId
});
});
}
});
// If we have QR codes, show them in the modal
if (allQRCodes.length > 0) {
// Set up qrModal with first inbound (for subscription links if enabled)
const firstDbInbound = this.allInbounds.find(ib => ib.id === client.inbounds[0].id);
if (firstDbInbound) {
const firstDbInboundObj = new DBInbound(firstDbInbound);
// Set modal properties
qrModal.title = '{{ i18n "qrCode"}} - ' + client.email;
qrModal.dbInbound = firstDbInboundObj;
qrModal.inbound = firstDbInboundObj.toInbound();
qrModal.client = clientForQR;
qrModal.subId = clientForQR.subId || '';
// Clear and set qrcodes array - use Vue.set for reactivity if needed
qrModal.qrcodes.length = 0;
allQRCodes.forEach(qr => {
// Ensure remark is set and not empty
if (!qr.remark || !qr.remark.trim()) {
qr.remark = 'QR Code';
}
qrModal.qrcodes.push(qr);
});
// Show modal
qrModal.visible = true;
// Reset the status fetched flag
if (qrModalApp) {
qrModalApp.statusFetched = false;
}
}
} else {
app.$message.warning('{{ i18n "tgbot.noInbounds" }}');
}
},
openAddClient() {
// Call directly like inModal.show() in inbounds.html
if (typeof window.clientEntityModal !== 'undefined') {
window.clientEntityModal.show({
title: '{{ i18n "pages.clients.addClient" }}',
okText: '{{ i18n "create" }}',
confirm: async (client) => {
await this.submitClient(client, false);
},
isEdit: false
});
} else if (typeof clientEntityModal !== 'undefined') {
clientEntityModal.show({
title: '{{ i18n "pages.clients.addClient" }}',
okText: '{{ i18n "create" }}',
confirm: async (client) => {
await this.submitClient(client, false);
},
isEdit: false
});
} else {
console.error('[openAddClient] ERROR: clientEntityModal is not defined!');
}
},
async editClient(client) {
// Load full client data including HWIDs
try {
const msg = await HttpUtil.get(`/panel/client/get/${client.id}`);
if (msg && msg.success && msg.obj) {
client = msg.obj; // Use full client data from API
}
} catch (e) {
console.error("Failed to load full client data:", e);
}
// Call directly like inModal.show() in inbounds.html
if (typeof window.clientEntityModal !== 'undefined') {
window.clientEntityModal.show({
title: '{{ i18n "pages.clients.editClient" }}',
okText: '{{ i18n "update" }}',
client: client,
confirm: async (client) => {
await this.submitClient(client, true);
},
isEdit: true
});
} else if (typeof clientEntityModal !== 'undefined') {
clientEntityModal.show({
title: '{{ i18n "pages.clients.editClient" }}',
okText: '{{ i18n "update" }}',
client: client,
confirm: async (client) => {
await this.submitClient(client, true);
},
isEdit: true
});
}
},
async submitClient(client, isEdit) {
if (!client.email || !client.email.trim()) {
app.$message.error('{{ i18n "pages.clients.emailRequired" }}');
return;
}
clientEntityModal.loading(true);
try {
// Convert date picker value to timestamp
if (client._expiryTime) {
if (moment && moment.isMoment(client._expiryTime)) {
client.expiryTime = client._expiryTime.valueOf();
} else if (client._expiryTime instanceof Date) {
client.expiryTime = client._expiryTime.getTime();
} else if (typeof client._expiryTime === 'number') {
client.expiryTime = client._expiryTime;
} else {
client.expiryTime = parseInt(client._expiryTime) || 0;
}
} else {
client.expiryTime = 0;
}
let msg;
if (isEdit) {
msg = await HttpUtil.post(`/panel/client/update/${client.id}`, client);
} else {
msg = await HttpUtil.post('/panel/client/add', client);
}
if (msg.success) {
app.$message.success(isEdit ? '{{ i18n "pages.clients.updateSuccess" }}' : '{{ i18n "pages.clients.addSuccess" }}');
clientEntityModal.close();
await this.loadClients();
} else {
app.$message.error(msg.msg || (isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}'));
}
} catch (e) {
console.error("Failed to submit client:", e);
app.$message.error(isEdit ? '{{ i18n "pages.clients.updateError" }}' : '{{ i18n "pages.clients.addError" }}');
} finally {
clientEntityModal.loading(false);
}
},
async deleteClient(id) {
this.$confirm({
title: '{{ i18n "pages.clients.deleteConfirm" }}',
content: '{{ i18n "pages.clients.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/client/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.clients.deleteSuccess" }}');
await this.loadClients();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.clients.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete client:", e);
app.$message.error('{{ i18n "pages.clients.deleteError" }}');
}
}
});
},
async switchEnable(id, enable) {
try {
const msg = await HttpUtil.post(`/panel/client/update/${id}`, { enable: enable });
if (msg.success) {
app.$message.success('{{ i18n "pages.clients.updateSuccess" }}');
} else {
app.$message.error(msg.msg || '{{ i18n "pages.clients.updateError" }}');
// Revert switch
const client = this.clients.find(c => c.id === id);
if (client) {
client.enable = !enable;
}
}
} catch (e) {
console.error("Failed to update client:", e);
app.$message.error('{{ i18n "pages.clients.updateError" }}');
// Revert switch
const client = this.clients.find(c => c.id === id);
if (client) {
client.enable = !enable;
}
}
},
async startDataRefreshLoop() {
while (this.isRefreshEnabled) {
try {
await this.loadClients();
} catch (e) {
console.error(e);
}
await PromiseUtil.sleep(this.refreshInterval);
}
},
toggleRefresh() {
localStorage.setItem("isRefreshEnabled", this.isRefreshEnabled);
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
},
changeRefreshInterval() {
localStorage.setItem("refreshInterval", this.refreshInterval);
},
async manualRefresh() {
if (!this.refreshing) {
this.loadingStates.spinning = true;
await this.loadClients();
this.loadingStates.spinning = false;
}
}
},
async mounted() {
// Load default settings (subSettings, remarkModel) first
await this.getDefaultSettings();
// Load available nodes for proper host addresses in QR codes
await this.loadAvailableNodes();
this.loading();
// Initial data fetch
this.loadClients().then(() => {
this.loading(false);
});
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for inbounds updates (contains full client traffic data)
window.wsClient.on('inbounds', (payload) => {
if (payload && Array.isArray(payload)) {
// Reload clients to get updated traffic (silently, without showing loading spinner)
// Only reload if not already refreshing to avoid multiple simultaneous requests
if (!this.refreshing) {
this.refreshing = true;
this.loadClients().finally(() => {
this.refreshing = false;
});
}
}
});
// Listen for traffic updates
window.wsClient.on('traffic', (payload) => {
// Update online clients list in real-time
if (payload && Array.isArray(payload.onlineClients)) {
this.onlineClients = payload.onlineClients;
}
// Update last online map in real-time
if (payload && payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
this.lastOnlineMap = { ...this.lastOnlineMap, ...payload.lastOnlineMap };
}
// Note: Traffic updates (up/down) are handled via 'inbounds' event
// which contains full accumulated traffic data from database
});
// Fallback to polling if WebSocket fails
window.wsClient.on('error', () => {
console.warn('WebSocket connection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
});
window.wsClient.on('disconnected', () => {
if (window.wsClient.reconnectAttempts >= window.wsClient.maxReconnectAttempts) {
console.warn('WebSocket reconnection failed, falling back to polling');
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
});
} else {
// Fallback to polling if WebSocket is not available
if (this.isRefreshEnabled) {
this.startDataRefreshLoop();
}
}
}
});
</script>
{{ template "page/body_end" .}}

View file

@ -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({

View file

@ -1,11 +1,6 @@
{{define "form/shadowsocks"}}
<template v-if="inbound.isSSMultiUser">
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse v-if="isEdit">
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.shadowsockses.length">
<table width="100%">
<tr class="client-table-header">

View file

@ -1,10 +1,5 @@
{{define "form/trojan"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse v-if="isEdit">
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.trojans.length">
<table width="100%">
<tr class="client-table-header">

View file

@ -1,10 +1,5 @@
{{define "form/vless"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse v-if="isEdit">
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' +
inbound.settings.vlesses.length">
<table width="100%">

View file

@ -1,10 +1,5 @@
{{define "form/vmess"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
<a-collapse v-if="isEdit">
<a-collapse-panel :header="'{{ i18n "pages.client.clientCount"}} : ' + inbound.settings.vmesses.length">
<table width="100%">
<tr class="client-table-header">

395
web/html/hosts.html Normal file
View file

@ -0,0 +1,395 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' hosts-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content :style="{ padding: '24px 16px' }">
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]" v-if="loadingStates.fetched && multiNodeMode">
<a-col>
<a-card size="small" :style="{ padding: '16px' }" hoverable>
<h2>{{ i18n "pages.hosts.title" }}</h2>
<div style="margin-bottom: 20px;">
<a-button type="primary" icon="plus" @click="openAddHost">{{ i18n "pages.hosts.addNewHost" }}</a-button>
</div>
<div style="margin-bottom: 20px;">
<a-button icon="sync" @click="loadHosts" :loading="refreshing">{{ i18n "refresh" }}</a-button>
</div>
<a-table :columns="isMobile ? mobileColumns : columns" :row-key="host => host.id"
:data-source="hosts" :scroll="isMobile ? {} : { x: 1000 }"
:pagination="false"
:style="{ marginTop: '10px' }"
class="hosts-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}`, emptyText: `{{ i18n "noData" }}` }'>
<template slot="action" slot-scope="text, host">
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '20px', textDecoration: 'solid' }"></a-icon>
<a-menu slot="overlay" @click="a => clickAction(a, host)"
:theme="themeSwitcher.currentTheme">
<a-menu-item key="edit">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item key="delete" :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
{{ i18n "delete" }}
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
<template slot="enable" slot-scope="text, host">
<a-switch v-model="host.enable" @change="switchEnable(host.id, host.enable)"></a-switch>
</template>
<template slot="inbounds" slot-scope="text, host">
<template v-if="host.inbounds && host.inbounds.length > 0">
<a-tag v-for="(inbound, index) in host.inbounds" :key="inbound.id" color="blue" :style="{ margin: '0 4px 4px 0' }">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-tag>
</template>
<a-tag v-else color="default">{{ i18n "none" }}</a-tag>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
<a-row v-else-if="!multiNodeMode">
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-alert type="info" message='{{ i18n "pages.hosts.multiNodeModeRequired" }}' show-icon></a-alert>
</a-card>
</a-row>
<a-row v-else>
<a-card
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
<a-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card>
</a-row>
</a-layout-content>
</a-layout>
</a-layout>
{{template "page/body_scripts" .}}
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "modals/hostModal"}}
<script>
const columns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 50,
}, {
title: '{{ i18n "pages.hosts.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.hosts.name" }}',
align: 'left',
width: 150,
dataIndex: "name",
}, {
title: '{{ i18n "pages.hosts.address" }}',
align: 'left',
width: 200,
dataIndex: "address",
}, {
title: '{{ i18n "pages.hosts.port" }}',
align: 'center',
width: 80,
dataIndex: "port",
}, {
title: '{{ i18n "pages.hosts.protocol" }}',
align: 'center',
width: 80,
dataIndex: "protocol",
}, {
title: '{{ i18n "pages.hosts.assignedInbounds" }}',
align: 'left',
width: 300,
scopedSlots: { customRender: 'inbounds' },
}, {
title: '{{ i18n "pages.hosts.enable" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'enable' },
}];
const mobileColumns = [{
title: "ID",
align: 'right',
dataIndex: "id",
width: 30,
}, {
title: '{{ i18n "pages.hosts.operate" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'action' },
}, {
title: '{{ i18n "pages.hosts.name" }}',
align: 'left',
width: 100,
dataIndex: "name",
}, {
title: '{{ i18n "pages.hosts.enable" }}',
align: 'center',
width: 60,
scopedSlots: { customRender: 'enable' },
}];
const app = new Vue({
delimiters: ['[[', ']]'],
el: '#app',
mixins: [MediaQueryMixin],
data: {
themeSwitcher,
loadingStates: {
fetched: false,
spinning: false
},
hosts: [],
refreshing: false,
multiNodeMode: false,
allInbounds: [],
},
methods: {
async loadMultiNodeMode() {
try {
const msg = await HttpUtil.post('/panel/setting/all');
if (msg && msg.success && msg.obj) {
this.multiNodeMode = msg.obj.multiNodeMode || false;
}
} catch (e) {
console.error("Failed to load multi-node mode:", e);
}
},
async loadHosts() {
if (!this.multiNodeMode) {
this.loadingStates.fetched = true;
return;
}
this.refreshing = true;
try {
const msg = await HttpUtil.get('/panel/host/list');
if (msg && msg.success && msg.obj) {
this.hosts = msg.obj;
// Load inbounds for each host
await this.loadInboundsForHosts();
}
} catch (e) {
console.error("Failed to load hosts:", e);
app.$message.error('{{ i18n "pages.hosts.loadError" }}');
} finally {
this.refreshing = false;
this.loadingStates.fetched = true;
}
},
async loadInboundsForHosts() {
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
const allInbounds = inboundsMsg.obj;
// Map inbound IDs to full inbound objects for each host
this.hosts.forEach(host => {
if (host.inboundIds && Array.isArray(host.inboundIds)) {
host.inbounds = host.inboundIds.map(id => {
return allInbounds.find(ib => ib.id === id);
}).filter(ib => ib != null);
} else {
host.inbounds = [];
}
});
}
} catch (e) {
console.error("Failed to load inbounds for hosts:", e);
}
},
clickAction(action, host) {
switch (action.key) {
case 'edit':
this.editHost(host);
break;
case 'delete':
this.deleteHost(host.id);
break;
}
},
async editHost(host) {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!this.allInbounds) {
this.allInbounds = [];
}
this.allInbounds = inboundsMsg.obj;
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.editHost" }}',
okText: '{{ i18n "update" }}',
host: host,
confirm: async (data) => {
await this.updateHost(host.id, data);
},
isEdit: true
});
},
async updateHost(id, data) {
try {
const msg = await HttpUtil.post(`/panel/host/update/${id}`, data);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.updateSuccess" }}');
window.hostModal.close();
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.updateError" }}');
window.hostModal.loading(false);
}
} catch (e) {
console.error("Failed to update host:", e);
app.$message.error('{{ i18n "pages.hosts.updateError" }}');
hostModal.loading(false);
}
},
async deleteHost(id) {
this.$confirm({
title: '{{ i18n "pages.hosts.deleteConfirm" }}',
content: '{{ i18n "pages.hosts.deleteConfirmText" }}',
okText: '{{ i18n "sure" }}',
okType: 'danger',
cancelText: '{{ i18n "close" }}',
onOk: async () => {
try {
const msg = await HttpUtil.post(`/panel/host/del/${id}`);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.deleteSuccess" }}');
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.deleteError" }}');
}
} catch (e) {
console.error("Failed to delete host:", e);
app.$message.error('{{ i18n "pages.hosts.deleteError" }}');
}
}
});
},
async addHostSubmit(data) {
try {
const msg = await HttpUtil.post('/panel/host/add', data);
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.addSuccess" }}');
window.hostModal.close();
await this.loadHosts();
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.addError" }}');
window.hostModal.loading(false);
}
} catch (e) {
console.error("Failed to add host:", e);
app.$message.error('{{ i18n "pages.hosts.addError" }}');
hostModal.loading(false);
}
},
async switchEnable(id, enable) {
try {
const msg = await HttpUtil.post(`/panel/host/update/${id}`, { enable: enable });
if (msg.success) {
app.$message.success('{{ i18n "pages.hosts.updateSuccess" }}');
} else {
app.$message.error(msg.msg || '{{ i18n "pages.hosts.updateError" }}');
// Revert switch
const host = this.hosts.find(h => h.id === id);
if (host) {
host.enable = !enable;
}
}
} catch (e) {
console.error("Failed to update host:", e);
app.$message.error('{{ i18n "pages.hosts.updateError" }}');
// Revert switch
const host = this.hosts.find(h => h.id === id);
if (host) {
host.enable = !enable;
}
}
},
async openAddHost() {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!this.allInbounds) {
this.allInbounds = [];
}
this.allInbounds = inboundsMsg.obj;
// Also update hostModalApp if it exists
if (window.hostModalApp && window.hostModalApp.app) {
window.hostModalApp.app.allInbounds = inboundsMsg.obj;
}
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
// Ensure hostModal is available
if (typeof window.hostModal === 'undefined' || !window.hostModal) {
console.error("hostModal is not defined");
this.$message.error('{{ i18n "pages.hosts.modalNotAvailable" }}');
return;
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.addHost" }}',
okText: '{{ i18n "create" }}',
confirm: async (data) => {
await this.addHostSubmit(data);
},
isEdit: false
});
}
},
async mounted() {
await this.loadMultiNodeMode();
await this.loadHosts();
}
});
async function addHost() {
// Load all inbounds for selection
try {
const inboundsMsg = await HttpUtil.get('/panel/api/inbounds/list');
if (inboundsMsg && inboundsMsg.success && inboundsMsg.obj) {
// Store inbounds in app for modal access
if (!app.allInbounds) {
app.allInbounds = [];
}
app.allInbounds = inboundsMsg.obj;
}
} catch (e) {
console.error("Failed to load inbounds:", e);
}
window.hostModal.show({
title: '{{ i18n "pages.hosts.addHost" }}',
okText: '{{ i18n "create" }}',
confirm: async (data) => {
await app.addHostSubmit(data);
},
isEdit: false
});
}
</script>
{{ template "page/body_end" .}}

View file

@ -204,14 +204,6 @@
{{ i18n "qrCode" }}
</a-menu-item>
<template v-if="dbInbound.isMultiUser()">
<a-menu-item key="addClient">
<a-icon type="user-add"></a-icon>
{{ i18n "pages.client.add"}}
</a-menu-item>
<a-menu-item key="addBulkClient">
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n "pages.inbounds.resetInboundClientTraffics"}}
@ -587,13 +579,6 @@
</a-badge>
</a-popover>
</template>
<template slot="expandedRowRender" slot-scope="record">
<a-table :row-key="client => client.id" :columns="isMobile ? innerMobileColumns : innerColumns"
:data-source="getInboundClients(record)" :pagination=pagination(getInboundClients(record))
:style="{ margin: `-10px ${isMobile ? '2px' : '22px'} -11px` }">
{{template "component/aClientTable"}}
</a-table>
</template>
</a-table>
</a-space>
</a-card>
@ -1018,12 +1003,6 @@
case "edit":
this.openEditInbound(dbInbound.id);
break;
case "addClient":
this.openAddClient(dbInbound.id)
break;
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "export":
this.inboundLinks(dbInbound.id);
break;

View file

@ -0,0 +1,307 @@
{{define "modals/clientEntityModal"}}
<a-modal id="client-entity-modal" v-model="clientEntityModal.visible" :title="clientEntityModal.title" @ok="clientEntityModal.ok"
:confirm-loading="clientEntityModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme"
:ok-text="clientEntityModal.okText" cancel-text='{{ i18n "close" }}' :width="600">
<a-form layout="vertical" v-if="client">
<a-form-item label='{{ i18n "pages.clients.email" }}' :required="true">
<a-input v-model.trim="client.email" :disabled="clientEntityModal.isEdit"></a-input>
</a-form-item>
<a-form-item label='UUID/ID'>
<a-input v-model.trim="client.uuid">
<a-icon slot="suffix" type="sync" @click="client.uuid = RandomUtil.randomUUID()" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "password" }}'>
<a-input v-model.trim="client.password">
<a-icon slot="suffix" type="sync" @click="client.password = RandomUtil.randomSeq(10)" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "security" }}'>
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option v-for="key in USERS_SECURITY" :key="key" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Flow'>
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :key="key" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Subscription ID'>
<a-input v-model.trim="client.subId">
<a-icon slot="suffix" type="sync" @click="client.subId = RandomUtil.randomLowerAndNum(16)" style="cursor: pointer;"></a-icon>
</a-input>
</a-form-item>
<a-form-item label='{{ i18n "comment" }}'>
<a-input v-model.trim="client.comment"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.IPLimit" }}'>
<a-input-number v-model.number="client.limitIp" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.totalFlow" }} (GB)'>
<a-input-number v-model.number="client.totalGB" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.expireDate" }}'>
<a-date-picker :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime" :style="{ width: '100%' }"></a-date-picker>
</a-form-item>
<a-form-item label='Telegram ChatID'>
<a-input-number v-model.number="client.tgId" :min="0" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.clients.inbounds" }}'>
<a-select v-model="client.inboundIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option v-for="inbound in app.allInbounds" :key="inbound.id" :value="inbound.id">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-select-option>
</a-select>
</a-form-item>
<a-divider>{{ i18n "hwidSettings" }}</a-divider>
<a-alert
message='{{ i18n "hwidBetaWarningTitle" }}'
description='{{ i18n "hwidBetaWarningDesc" }}'
type="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;">
</a-alert>
<a-form-item label='{{ i18n "hwidEnabled" }}'>
<a-switch v-model="client.hwidEnabled"></a-switch>
</a-form-item>
<a-form-item label='{{ i18n "maxHwid" }}' v-if="client.hwidEnabled">
<a-input-number v-model.number="client.maxHwid" :min="0" :style="{ width: '100%' }">
<template slot="addonAfter">
<a-tooltip>
<template slot="title">0 = {{ i18n "unlimited" }}</template>
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
</a-input-number>
</a-form-item>
<a-form-item v-if="client.hwidEnabled && clientEntityModal.isEdit">
<a-table
:columns="hwidColumns"
:data-source="client.hwids"
:pagination="false"
size="small"
:style="{ marginTop: '10px' }">
<template slot="deviceInfo" slot-scope="text, record">
<div>
<div><strong>[[ record.deviceModel || record.deviceName || record.deviceOs || 'Unknown Device' ]]</strong></div>
<small style="color: #999;">HWID: [[ record.hwid ]]</small>
</div>
</template>
<template slot="status" slot-scope="text, record">
<a-tag v-if="record.isActive" color="green">{{ i18n "active" }}</a-tag>
<a-tag v-else>{{ i18n "inactive" }}</a-tag>
</template>
<template slot="firstSeen" slot-scope="text, record">
[[ clientEntityModal.formatTimestamp(record.firstSeenAt || record.firstSeen) ]]
</template>
<template slot="lastSeen" slot-scope="text, record">
[[ clientEntityModal.formatTimestamp(record.lastSeenAt || record.lastSeen) ]]
</template>
<template slot="actions" slot-scope="text, record">
<a-button type="danger" size="small" @click="clientEntityModal.removeHwid(record.id)">{{ i18n "delete" }}</a-button>
</template>
</a-table>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
<a-switch v-model="client.enable"></a-switch>
</a-form-item>
</a-form>
</a-modal>
<script>
const clientEntityModal = window.clientEntityModal = {
visible: false,
confirmLoading: false,
title: '',
okText: '{{ i18n "sure" }}',
isEdit: false,
client: null,
confirm: null,
ok() {
if (clientEntityModal.confirm && clientEntityModal.client) {
const client = clientEntityModal.client;
if (typeof ObjectUtil !== 'undefined' && ObjectUtil.execute) {
ObjectUtil.execute(clientEntityModal.confirm, client);
} else {
clientEntityModal.confirm(client);
}
}
},
show({ title = '', okText = '{{ i18n "sure" }}', client = null, confirm = () => {}, isEdit = false }) {
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
if (client) {
// Edit mode - use provided client data
this.client = {
id: client.id,
email: client.email || '',
uuid: client.uuid || '',
password: client.password || '',
security: client.security || 'auto',
flow: client.flow || '',
subId: client.subId || '',
comment: client.comment || '',
limitIp: client.limitIp || 0,
totalGB: client.totalGB || 0,
expiryTime: client.expiryTime || 0,
_expiryTime: client.expiryTime > 0 ? (moment ? moment(client.expiryTime) : new Date(client.expiryTime)) : null,
tgId: client.tgId || 0,
inboundIds: client.inboundIds ? [...client.inboundIds] : [],
enable: client.enable !== undefined ? client.enable : true,
hwidEnabled: client.hwidEnabled !== undefined ? client.hwidEnabled : false,
maxHwid: client.maxHwid !== undefined ? client.maxHwid : 1,
hwids: client.hwids ? [...client.hwids] : []
};
// If in edit mode, load HWIDs from API
if (isEdit && client.id) {
this.loadClientHWIDs(client.id);
}
} else {
// Add mode - create new client
this.client = {
email: '',
uuid: RandomUtil.randomUUID(),
password: RandomUtil.randomSeq(10),
security: 'auto',
flow: '',
subId: RandomUtil.randomLowerAndNum(16),
comment: '',
limitIp: 0,
totalGB: 0,
expiryTime: 0,
_expiryTime: null,
tgId: 0,
inboundIds: [],
enable: true,
hwidEnabled: false,
maxHwid: 1
};
}
this.visible = true;
},
close() {
this.visible = false;
this.loading(false);
},
loading(loading = true) {
this.confirmLoading = loading;
},
async loadClientHWIDs(clientId) {
try {
const msg = await HttpUtil.get(`/panel/client/hwid/list/${clientId}`);
if (msg && msg.success && msg.obj) {
if (this.client) {
this.client.hwids = msg.obj || [];
}
}
} catch (e) {
console.error("Failed to load client HWIDs:", e);
if (this.client) {
this.client.hwids = [];
}
}
},
formatTimestamp(timestamp) {
if (!timestamp) return '-';
if (typeof IntlUtil !== 'undefined' && IntlUtil.formatDate) {
return IntlUtil.formatDate(timestamp);
}
return new Date(timestamp * 1000).toLocaleString();
},
async removeHwid(hwidId) {
if (!confirm('{{ i18n "pages.clients.confirmDeleteHwid" }}')) {
return;
}
try {
const msg = await HttpUtil.post(`/panel/client/hwid/remove/${hwidId}`);
if (msg.success) {
if (typeof app !== 'undefined') {
app.$message.success('{{ i18n "pages.clients.hwidDeleteSuccess" }}');
}
// Reload client HWIDs
if (this.client && this.client.id) {
await this.loadClientHWIDs(this.client.id);
}
} else {
if (typeof app !== 'undefined') {
app.$message.error(msg.msg || '{{ i18n "somethingWentWrong" }}');
}
}
} catch (e) {
console.error("Failed to delete HWID:", e);
if (typeof app !== 'undefined') {
app.$message.error('{{ i18n "somethingWentWrong" }}');
}
}
}
};
const clientEntityModalApp = window.clientEntityModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#client-entity-modal',
data: {
clientEntityModal: clientEntityModal,
},
computed: {
client() {
return this.clientEntityModal.client;
},
themeSwitcher() {
return typeof themeSwitcher !== 'undefined' ? themeSwitcher : { currentTheme: 'light' };
},
app() {
return typeof app !== 'undefined' ? app : null;
},
USERS_SECURITY() {
return typeof USERS_SECURITY !== 'undefined' ? USERS_SECURITY : {};
},
TLS_FLOW_CONTROL() {
return typeof TLS_FLOW_CONTROL !== 'undefined' ? TLS_FLOW_CONTROL : {};
},
hwidColumns() {
return [
{
title: '{{ i18n "pages.clients.deviceInfo" }}',
align: 'left',
width: 200,
scopedSlots: { customRender: 'deviceInfo' }
},
{
title: '{{ i18n "status" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'status' }
},
{
title: '{{ i18n "pages.clients.firstSeen" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'firstSeen' }
},
{
title: '{{ i18n "pages.clients.lastSeen" }}',
align: 'left',
width: 150,
scopedSlots: { customRender: 'lastSeen' }
},
{
title: '{{ i18n "pages.clients.actions" }}',
align: 'center',
width: 80,
scopedSlots: { customRender: 'actions' }
}
];
}
}
});
</script>
{{end}}

View file

@ -1,4 +1,9 @@
{{define "modals/clientsModal"}}
<!--
NOTE: This modal is for backward compatibility with old client architecture (clients stored in inbound.settings).
New clients should be created/edited using clientEntityModal in clients.html.
This modal is still used for editing existing clients in old inbounds.
-->
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme"
@ -10,6 +15,8 @@
</a-modal>
<script>
// NOTE: This modal is for backward compatibility with old client architecture.
// New clients should use clientEntityModal (see clients.html).
const clientModal = {
visible: false,
confirmLoading: false,

View file

@ -0,0 +1,153 @@
{{define "modals/hostModal"}}
<a-modal id="host-modal" v-model="hostModal.visible" :title="hostModal.title" @ok="hostModal.ok"
:confirm-loading="hostModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme"
:ok-text="hostModal.okText" cancel-text='{{ i18n "close" }}' :width="600">
<a-form layout="vertical" v-if="hostModal.formData">
<a-form-item label='{{ i18n "pages.hosts.hostName" }}' :required="true">
<a-input v-model.trim="hostModal.formData.name" placeholder='{{ i18n "pages.hosts.enterHostName" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostAddress" }}' :required="true">
<a-input v-model.trim="hostModal.formData.address" placeholder='{{ i18n "pages.hosts.enterHostAddress" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostPort" }}'>
<a-input-number v-model.number="hostModal.formData.port" :min="0" :max="65535" :style="{ width: '100%' }"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.hostProtocol" }}'>
<a-select v-model="hostModal.formData.protocol" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.assignedInbounds" }}'>
<a-select v-model="hostModal.formData.inboundIds" mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }">
<a-select-option v-for="inbound in allInbounds" :key="inbound.id" :value="inbound.id">
[[ inbound.remark || `Port ${inbound.port}` ]] (ID: [[ inbound.id ]])
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.hosts.enable" }}'>
<a-switch v-model="hostModal.formData.enable"></a-switch>
</a-form-item>
</a-form>
</a-modal>
<script>
const hostModal = window.hostModal = {
visible: false,
confirmLoading: false,
title: '',
okText: '{{ i18n "sure" }}',
isEdit: false,
confirm: null,
formData: {
name: '',
address: '',
port: 0,
protocol: 'tcp',
inboundIds: [],
enable: true
},
ok() {
// Validate form data
if (!hostModal.formData.name || !hostModal.formData.name.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.hosts.enterHostName" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.hosts.enterHostName" }}');
}
return;
}
if (!hostModal.formData.address || !hostModal.formData.address.trim()) {
if (typeof app !== 'undefined' && app.$message) {
app.$message.error('{{ i18n "pages.hosts.enterHostAddress" }}');
} else if (typeof Vue !== 'undefined' && Vue.prototype && Vue.prototype.$message) {
Vue.prototype.$message.error('{{ i18n "pages.hosts.enterHostAddress" }}');
}
return;
}
// Ensure inboundIds is always an array
const dataToSend = { ...hostModal.formData };
if (dataToSend.inboundIds && !Array.isArray(dataToSend.inboundIds)) {
dataToSend.inboundIds = [dataToSend.inboundIds];
} else if (!dataToSend.inboundIds) {
dataToSend.inboundIds = [];
}
hostModal.confirmLoading = true;
if (hostModal.confirm) {
try {
const result = hostModal.confirm(dataToSend);
// If confirm returns a promise, handle it
if (result && typeof result.then === 'function') {
result.catch(() => {
// Error handling is done in addHostSubmit
}).finally(() => {
hostModal.confirmLoading = false;
});
} else {
// If not async, reset loading after a short delay
setTimeout(() => {
hostModal.confirmLoading = false;
}, 100);
}
} catch (e) {
console.error("Error in hostModal.ok():", e);
hostModal.confirmLoading = false;
}
} else {
hostModal.confirmLoading = false;
}
},
show({ title = '', okText = '{{ i18n "sure" }}', host = null, confirm = () => {}, isEdit = false }) {
this.title = title;
this.okText = okText;
this.isEdit = isEdit;
this.confirm = confirm;
if (host) {
this.formData = {
name: host.name || '',
address: host.address || '',
port: host.port || 0,
protocol: host.protocol || 'tcp',
inboundIds: host.inboundIds ? [...host.inboundIds] : [],
enable: host.enable !== undefined ? host.enable : true
};
} else {
this.formData = {
name: '',
address: '',
port: 0,
protocol: 'tcp',
inboundIds: [],
enable: true
};
}
this.visible = true;
},
close() {
this.visible = false;
this.confirmLoading = false;
},
loading(loading = true) {
this.confirmLoading = loading;
}
};
const hostModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#host-modal',
data: {
hostModal: hostModal,
get themeSwitcher() {
return typeof themeSwitcher !== 'undefined' ? themeSwitcher : { currentTheme: 'light' };
},
get allInbounds() {
return typeof app !== 'undefined' && app.allInbounds ? app.allInbounds : [];
}
}
});
</script>
{{end}}

View file

@ -132,9 +132,6 @@
get isEdit() {
return inModal.isEdit;
},
get client() {
return inModal.inbound && inModal.inbound.clients && inModal.inbound.clients.length > 0 ? inModal.inbound.clients[0] : null;
},
get datepicker() {
return app.datepicker;
},
@ -144,12 +141,6 @@
get availableNodes() {
return app && app.availableNodes || [];
},
get delayedExpireDays() {
return this.client && this.client.expiryTime < 0 ? this.client.expiryTime / -86400000 : 0;
},
set delayedExpireDays(days) {
this.client.expiryTime = -86400000 * days;
},
get externalProxy() {
return this.inbound.stream.externalProxy.length > 0;
},

View file

@ -21,7 +21,7 @@
</a-space>
</template>
<tr-qr-modal class="qr-modal">
<template v-if="app.subSettings?.enable && qrModal.subId">
<template v-if="app.subSettings && app.subSettings.enable && qrModal.client && qrModal.client.subId">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}}</span></a-tag>
<tr-qr-bg class="qr-bg-sub">
@ -30,7 +30,7 @@
</tr-qr-bg-inner>
</tr-qr-bg>
</tr-qr-box>
<tr-qr-box class="qr-box" v-if="app.subSettings.subJsonEnable">
<tr-qr-box class="qr-box" v-if="app.subSettings && app.subSettings.subJsonEnable && qrModal.client && qrModal.client.subId">
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag>
<tr-qr-bg class="qr-bg-sub">
<tr-qr-bg-inner class="qr-bg-sub-inner">
@ -110,7 +110,8 @@
this.dbInbound = dbInbound;
this.inbound = dbInbound.toInbound();
this.client = client;
this.subId = '';
// Set subId from client if available
this.subId = (client && client.subId) ? client.subId : '';
this.qrcodes = [];
// Reset the status fetched flag when showing the modal
if (qrModalApp) qrModalApp.statusFetched = false;
@ -244,9 +245,15 @@
});
},
genSubLink(subID) {
if (!app || !app.subSettings || !app.subSettings.subURI) {
return '';
}
return app.subSettings.subURI + subID;
},
genSubJsonLink(subID) {
if (!app || !app.subSettings || !app.subSettings.subJsonURI) {
return '';
}
return app.subSettings.subJsonURI + subID;
},
revertOverflow() {
@ -274,9 +281,15 @@
}
if (qrModal.client && qrModal.client.subId) {
qrModal.subId = qrModal.client.subId;
this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId));
if (app.subSettings.subJsonEnable) {
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
const subLink = this.genSubLink(qrModal.subId);
if (subLink) {
this.setQrCode("qrCode-sub", subLink);
}
if (app && app.subSettings && app.subSettings.subJsonEnable) {
const subJsonLink = this.genSubJsonLink(qrModal.subId);
if (subJsonLink) {
this.setQrCode("qrCode-subJson", subJsonLink);
}
}
}
qrModal.qrcodes.forEach((element, index) => {

View file

@ -0,0 +1,155 @@
// Package job provides scheduled tasks for monitoring client HWIDs from access logs.
// NOTE: In client_header mode, this job does NOT generate HWIDs from logs.
// HWID registration happens explicitly via RegisterHWIDFromHeaders when subscription is requested.
package job
import (
"time"
"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/web/service"
"github.com/mhsanaei/3x-ui/v2/xray"
)
// CheckClientHWIDJob monitors client HWIDs from access logs and manages HWID tracking.
type CheckClientHWIDJob struct {
lastClear int64
}
var hwidJob *CheckClientHWIDJob
// NewCheckClientHWIDJob creates a new client HWID monitoring job instance.
func NewCheckClientHWIDJob() *CheckClientHWIDJob {
if hwidJob == nil {
hwidJob = new(CheckClientHWIDJob)
}
return hwidJob
}
// Run executes the HWID monitoring job.
func (j *CheckClientHWIDJob) Run() {
// Check if multi-node mode is enabled
settingService := service.SettingService{}
multiMode, err := settingService.GetMultiNodeMode()
if err == nil && multiMode {
// In multi-node mode, HWID checking is handled by nodes
return
}
if j.lastClear == 0 {
j.lastClear = time.Now().Unix()
}
hwidTrackingActive := j.hasHWIDTracking()
if !hwidTrackingActive {
return
}
isAccessLogAvailable := j.checkAccessLogAvailable()
if !isAccessLogAvailable {
return
}
// Process access log to track HWIDs
j.processLogFile()
// Clear access log periodically (every hour)
if time.Now().Unix()-j.lastClear > 3600 {
j.clearAccessLog()
}
}
// hasHWIDTracking checks if HWID tracking is enabled globally and for any client.
func (j *CheckClientHWIDJob) hasHWIDTracking() bool {
// Check global HWID mode setting
settingService := service.SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting: %v", err)
return false
}
// If HWID tracking is disabled globally, skip
if hwidMode == "off" {
return false
}
// Check if any client has HWID tracking enabled
db := database.GetDB()
var clients []*model.ClientEntity
err = db.Where("hwid_enabled = ?", true).Find(&clients).Error
if err != nil {
return false
}
return len(clients) > 0
}
// checkAccessLogAvailable checks if access log is available.
func (j *CheckClientHWIDJob) checkAccessLogAvailable() bool {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
return false
}
if accessLogPath == "none" || accessLogPath == "" {
return false
}
return true
}
// processLogFile processes the access log file to update last_seen_at and IP for existing HWIDs.
// NOTE: This job does NOT generate or create new HWID records.
// HWID registration must be done explicitly via RegisterHWIDFromHeaders when x-hwid header is provided.
// This job only updates existing HWID records with connection information from access logs.
func (j *CheckClientHWIDJob) processLogFile() {
// Check HWID mode - only run in legacy_fingerprint mode
settingService := service.SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting: %v", err)
return
}
// In client_header mode, this job should not process logs for HWID generation
// It may still update last_seen_at for existing HWIDs if needed
if hwidMode == "off" {
// HWID tracking disabled - skip processing
return
}
// In client_header mode, we don't generate HWIDs from logs
// Only update existing HWIDs if we can match them somehow
// For now, skip log processing in client_header mode
// (HWID registration happens via RegisterHWIDFromHeaders when subscription is requested)
if hwidMode == "client_header" {
// In client_header mode, HWID comes from headers, not logs
// This job should not process logs for HWID generation
// TODO: Could potentially update last_seen_at for existing HWIDs if we can match them,
// but without x-hwid header in logs, we can't reliably match
return
}
// Legacy fingerprint mode (deprecated)
// This mode may use fingerprint-based HWID generation from logs
if hwidMode == "legacy_fingerprint" {
// Legacy mode: may generate HWID from logs (deprecated behavior)
// This is kept for backward compatibility only
logger.Debug("Running in legacy_fingerprint mode (deprecated)")
// TODO: Implement legacy fingerprint logic if needed for backward compatibility
// For now, skip to avoid false positives
return
}
}
// clearAccessLog clears the access log file (similar to CheckClientIpJob).
func (j *CheckClientHWIDJob) clearAccessLog() {
// This is similar to CheckClientIpJob.clearAccessLog
// We can reuse the same logic or call it from there
// For now, we'll just update the last clear time
j.lastClear = time.Now().Unix()
}

602
web/service/client.go Normal file
View file

@ -0,0 +1,602 @@
// Package service provides Client management service.
package service
import (
"strings"
"time"
"github.com/google/uuid"
"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/common"
"github.com/mhsanaei/3x-ui/v2/util/random"
"github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm"
)
// ClientService provides business logic for managing clients.
type ClientService struct{}
// GetClients retrieves all clients for a specific user.
// Also loads traffic statistics and last online time for each client.
func (s *ClientService) GetClients(userId int) ([]*model.ClientEntity, error) {
db := database.GetDB()
var clients []*model.ClientEntity
err := db.Where("user_id = ?", userId).Find(&clients).Error
if err != nil {
return nil, err
}
// Load inbound assignments, traffic statistics, and HWIDs for each client
for _, client := range clients {
// Load inbound assignments
inboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil {
client.InboundIds = inboundIds
}
// Load traffic statistics from client_traffics table by email
var clientTraffic xray.ClientTraffic
err = db.Where("email = ?", strings.ToLower(client.Email)).First(&clientTraffic).Error
if err == nil {
// Traffic found - set traffic fields on client entity
client.Up = clientTraffic.Up
client.Down = clientTraffic.Down
client.AllTime = clientTraffic.AllTime
client.LastOnline = clientTraffic.LastOnline
// Note: expiryTime and totalGB are stored in ClientEntity, we don't override them from traffic
// Traffic table may have different values due to legacy data
} else if err != gorm.ErrRecordNotFound {
logger.Warningf("Failed to load traffic for client %s: %v", client.Email, err)
}
// If not found, traffic will be 0 (default values)
// Load HWIDs for this client
hwidService := ClientHWIDService{}
hwids, err := hwidService.GetHWIDsForClient(client.Id)
if err == nil {
client.HWIDs = hwids
} else {
logger.Warningf("Failed to load HWIDs for client %d: %v", client.Id, err)
}
}
return clients, nil
}
// GetClient retrieves a client by ID.
func (s *ClientService) GetClient(id int) (*model.ClientEntity, error) {
db := database.GetDB()
var client model.ClientEntity
err := db.First(&client, id).Error
if err != nil {
return nil, err
}
// Load inbound assignments
inboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil {
client.InboundIds = inboundIds
}
// Load HWIDs for this client
hwidService := ClientHWIDService{}
hwids, err := hwidService.GetHWIDsForClient(client.Id)
if err == nil {
client.HWIDs = hwids
}
return &client, nil
}
// GetClientByEmail retrieves a client by email for a specific user.
func (s *ClientService) GetClientByEmail(userId int, email string) (*model.ClientEntity, error) {
db := database.GetDB()
var client model.ClientEntity
err := db.Where("user_id = ? AND email = ?", userId, strings.ToLower(email)).First(&client).Error
if err != nil {
return nil, err
}
// Load inbound assignments
inboundIds, err := s.GetInboundIdsForClient(client.Id)
if err == nil {
client.InboundIds = inboundIds
}
return &client, nil
}
// GetInboundIdsForClient retrieves all inbound IDs assigned to a client.
func (s *ClientService) GetInboundIdsForClient(clientId int) ([]int, error) {
db := database.GetDB()
var mappings []model.ClientInboundMapping
err := db.Where("client_id = ?", clientId).Find(&mappings).Error
if err != nil {
return nil, err
}
inboundIds := make([]int, len(mappings))
for i, mapping := range mappings {
inboundIds[i] = mapping.InboundId
}
return inboundIds, nil
}
// AddClient creates a new client.
// Returns whether Xray needs restart and any error.
func (s *ClientService) AddClient(userId int, client *model.ClientEntity) (bool, error) {
// Validate email uniqueness for this user
existing, err := s.GetClientByEmail(userId, client.Email)
if err == nil && existing != nil {
return false, common.NewError("Client with email already exists: ", client.Email)
}
// Generate UUID if not provided and needed
if client.UUID == "" {
newUUID, err := uuid.NewRandom()
if err != nil {
return false, common.NewError("Failed to generate UUID: ", err.Error())
}
client.UUID = newUUID.String()
}
// Generate SubID if not provided
if client.SubID == "" {
client.SubID = random.Seq(16)
}
// Normalize email to lowercase
client.Email = strings.ToLower(client.Email)
client.UserId = userId
// Set timestamps
now := time.Now().Unix()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
err = tx.Create(client).Error
if err != nil {
return false, err
}
// Create initial ClientTraffic record if it doesn't exist
// This ensures traffic statistics are tracked from the start
var count int64
tx.Model(&xray.ClientTraffic{}).Where("email = ?", client.Email).Count(&count)
if count == 0 {
// Create traffic record for the first assigned inbound, or use 0 if no inbounds yet
inboundId := 0
if len(client.InboundIds) > 0 {
inboundId = client.InboundIds[0]
}
clientTraffic := xray.ClientTraffic{
InboundId: inboundId,
Email: client.Email,
Total: client.TotalGB,
ExpiryTime: client.ExpiryTime,
Enable: client.Enable,
Up: 0,
Down: 0,
Reset: client.Reset,
}
err = tx.Create(&clientTraffic).Error
if err != nil {
logger.Warningf("Failed to create ClientTraffic for client %s: %v", client.Email, err)
// Don't fail the whole operation if traffic record creation fails
}
}
// Assign to inbounds if provided
if len(client.InboundIds) > 0 {
err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds)
if err != nil {
return false, err
}
}
// Commit client transaction first to avoid nested transactions
err = tx.Commit().Error
if err != nil {
return false, err
}
// Now update Settings for all assigned inbounds
// This is done AFTER committing the client transaction to avoid nested transactions and database locks
needRestart := false
if len(client.InboundIds) > 0 {
inboundService := InboundService{}
for _, inboundId := range client.InboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
continue
}
// Get all clients for this inbound (from ClientEntity)
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
continue
}
// Rebuild Settings from ClientEntity
newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities)
if err != nil {
logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err)
continue
}
// Update inbound Settings (this will open its own transaction)
// Use retry logic to handle database lock errors
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
// Continue with other inbounds
} else if inboundNeedRestart {
needRestart = true
}
}
}
return needRestart, nil
}
// UpdateClient updates an existing client.
// Returns whether Xray needs restart and any error.
func (s *ClientService) UpdateClient(userId int, client *model.ClientEntity) (bool, error) {
// Check if client exists and belongs to user
existing, err := s.GetClient(client.Id)
if err != nil {
return false, err
}
if existing.UserId != userId {
return false, common.NewError("Client not found or access denied")
}
// Check email uniqueness if email changed
if client.Email != "" && strings.ToLower(client.Email) != strings.ToLower(existing.Email) {
existingByEmail, err := s.GetClientByEmail(userId, client.Email)
if err == nil && existingByEmail != nil && existingByEmail.Id != client.Id {
return false, common.NewError("Client with email already exists: ", client.Email)
}
}
// Normalize email to lowercase if provided
if client.Email != "" {
client.Email = strings.ToLower(client.Email)
}
// Update timestamp
client.UpdatedAt = time.Now().Unix()
db := database.GetDB()
tx := db.Begin()
// Track if transaction was committed to avoid double rollback
committed := false
defer func() {
// Only rollback if there was an error and transaction wasn't committed
if err != nil && !committed {
tx.Rollback()
}
}()
// Update only provided fields
updates := make(map[string]interface{})
if client.Email != "" {
updates["email"] = client.Email
}
if client.UUID != "" {
updates["uuid"] = client.UUID
}
if client.Security != "" {
updates["security"] = client.Security
}
if client.Password != "" {
updates["password"] = client.Password
}
if client.Flow != "" {
updates["flow"] = client.Flow
}
if client.LimitIP > 0 {
updates["limit_ip"] = client.LimitIP
}
if client.TotalGB > 0 {
updates["total_gb"] = client.TotalGB
}
if client.ExpiryTime != 0 {
updates["expiry_time"] = client.ExpiryTime
}
updates["enable"] = client.Enable
if client.TgID > 0 {
updates["tg_id"] = client.TgID
}
if client.SubID != "" {
updates["sub_id"] = client.SubID
}
if client.Comment != "" {
updates["comment"] = client.Comment
}
if client.Reset > 0 {
updates["reset"] = client.Reset
}
// Update HWID settings - GORM converts field names to snake_case automatically
// HWIDEnabled -> hwid_enabled, MaxHWID -> max_hwid
// But we need to check if columns exist first, or use direct field assignment
updates["hwid_enabled"] = client.HWIDEnabled
updates["max_hwid"] = client.MaxHWID
updates["updated_at"] = client.UpdatedAt
// First try to update with all fields including HWID
err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error
if err != nil {
// If HWID columns don't exist, remove them and try again
if strings.Contains(err.Error(), "no such column: hwid_enabled") || strings.Contains(err.Error(), "no such column: max_hwid") {
delete(updates, "hwid_enabled")
delete(updates, "max_hwid")
err = tx.Model(&model.ClientEntity{}).Where("id = ? AND user_id = ?", client.Id, userId).Updates(updates).Error
}
}
if err != nil {
return false, err
}
// Get current inbound assignments to determine which inbounds need updating
var currentMappings []model.ClientInboundMapping
tx.Where("client_id = ?", client.Id).Find(&currentMappings)
oldInboundIds := make(map[int]bool)
for _, mapping := range currentMappings {
oldInboundIds[mapping.InboundId] = true
}
// Track all affected inbounds (old + new) for settings update
affectedInboundIds := make(map[int]bool)
for inboundId := range oldInboundIds {
affectedInboundIds[inboundId] = true
}
// Update inbound assignments if provided
// Note: InboundIds is a slice, so we need to check if it was explicitly set
// We'll always update if InboundIds is not nil (even if empty array means remove all)
if client.InboundIds != nil {
// Remove existing assignments
err = tx.Where("client_id = ?", client.Id).Delete(&model.ClientInboundMapping{}).Error
if err != nil {
return false, err
}
// Add new assignments (if any)
if len(client.InboundIds) > 0 {
err = s.AssignClientToInbounds(tx, client.Id, client.InboundIds)
if err != nil {
return false, err
}
// Track new inbound IDs for settings update
for _, inboundId := range client.InboundIds {
affectedInboundIds[inboundId] = true
}
}
}
// Commit client transaction first to avoid nested transactions
err = tx.Commit().Error
committed = true
if err != nil {
return false, err
}
// Now update Settings for all affected inbounds (old + new)
// This is needed even if InboundIds wasn't changed, because client data (UUID, password, etc.) might have changed
// We do this AFTER committing the client transaction to avoid nested transactions and database locks
needRestart := false
inboundService := InboundService{}
for inboundId := range affectedInboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
continue
}
// Get all clients for this inbound (from ClientEntity)
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
continue
}
// Rebuild Settings from ClientEntity
newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities)
if err != nil {
logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err)
continue
}
// Update inbound Settings (this will open its own transaction)
// Use retry logic to handle database lock errors
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
// Continue with other inbounds
} else if inboundNeedRestart {
needRestart = true
}
}
return needRestart, nil
}
// DeleteClient deletes a client by ID.
// Returns whether Xray needs restart and any error.
func (s *ClientService) DeleteClient(userId int, id int) (bool, error) {
// Check if client exists and belongs to user
existing, err := s.GetClient(id)
if err != nil {
return false, err
}
if existing.UserId != userId {
return false, common.NewError("Client not found or access denied")
}
// Get inbound assignments before deleting
var mappings []model.ClientInboundMapping
db := database.GetDB()
err = db.Where("client_id = ?", id).Find(&mappings).Error
if err != nil {
return false, err
}
affectedInboundIds := make(map[int]bool)
for _, mapping := range mappings {
affectedInboundIds[mapping.InboundId] = true
}
needRestart := false
tx := db.Begin()
// Track if transaction was committed to avoid double rollback
committed := false
defer func() {
// Only rollback if there was an error and transaction wasn't committed
if err != nil && !committed {
tx.Rollback()
}
}()
// Delete inbound mappings
err = tx.Where("client_id = ?", id).Delete(&model.ClientInboundMapping{}).Error
if err != nil {
return false, err
}
// Delete client
err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.ClientEntity{}).Error
if err != nil {
return false, err
}
// Commit deletion transaction first to avoid nested transactions
err = tx.Commit().Error
committed = true
if err != nil {
return false, err
}
// Update Settings for affected inbounds (after deletion)
// We do this AFTER committing the deletion transaction to avoid nested transactions and database locks
inboundService := InboundService{}
for inboundId := range affectedInboundIds {
inbound, err := inboundService.GetInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get inbound %d for settings update: %v", inboundId, err)
continue
}
// Get all remaining clients for this inbound (from ClientEntity)
clientEntities, err := s.GetClientsForInbound(inboundId)
if err != nil {
logger.Warningf("Failed to get clients for inbound %d: %v", inboundId, err)
continue
}
// Rebuild Settings from ClientEntity
newSettings, err := inboundService.BuildSettingsFromClientEntities(inbound, clientEntities)
if err != nil {
logger.Warningf("Failed to build settings for inbound %d: %v", inboundId, err)
continue
}
// Update inbound Settings (this will open its own transaction)
// Use retry logic to handle database lock errors
inbound.Settings = newSettings
_, inboundNeedRestart, err := inboundService.updateInboundWithRetry(inbound)
if err != nil {
logger.Warningf("Failed to update inbound %d settings: %v", inboundId, err)
// Continue with other inbounds
} else if inboundNeedRestart {
needRestart = true
}
}
return needRestart, nil
}
// AssignClientToInbounds assigns a client to multiple inbounds.
func (s *ClientService) AssignClientToInbounds(tx *gorm.DB, clientId int, inboundIds []int) error {
for _, inboundId := range inboundIds {
mapping := &model.ClientInboundMapping{
ClientId: clientId,
InboundId: inboundId,
}
err := tx.Create(mapping).Error
if err != nil {
logger.Warningf("Failed to assign client %d to inbound %d: %v", clientId, inboundId, err)
// Continue with other assignments
}
}
return nil
}
// GetClientsForInbound retrieves all clients assigned to an inbound.
func (s *ClientService) GetClientsForInbound(inboundId int) ([]*model.ClientEntity, error) {
db := database.GetDB()
var mappings []model.ClientInboundMapping
err := db.Where("inbound_id = ?", inboundId).Find(&mappings).Error
if err != nil {
return nil, err
}
if len(mappings) == 0 {
return []*model.ClientEntity{}, nil
}
clientIds := make([]int, len(mappings))
for i, mapping := range mappings {
clientIds[i] = mapping.ClientId
}
var clients []*model.ClientEntity
err = db.Where("id IN ?", clientIds).Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
// ConvertClientEntityToClient converts ClientEntity to legacy Client struct for backward compatibility.
func (s *ClientService) ConvertClientEntityToClient(entity *model.ClientEntity) model.Client {
return model.Client{
ID: entity.UUID,
Security: entity.Security,
Password: entity.Password,
Flow: entity.Flow,
Email: entity.Email,
LimitIP: entity.LimitIP,
TotalGB: entity.TotalGB,
ExpiryTime: entity.ExpiryTime,
Enable: entity.Enable,
TgID: entity.TgID,
SubID: entity.SubID,
Comment: entity.Comment,
Reset: entity.Reset,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}

342
web/service/client_hwid.go Normal file
View file

@ -0,0 +1,342 @@
// Package service provides HWID (Hardware ID) management for clients.
// 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.
package service
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
"gorm.io/gorm"
)
// ClientHWIDService provides business logic for managing client HWIDs.
type ClientHWIDService struct{}
// GetHWIDsForClient retrieves all HWIDs associated with a client.
func (s *ClientHWIDService) GetHWIDsForClient(clientId int) ([]*model.ClientHWID, error) {
db := database.GetDB()
var hwids []*model.ClientHWID
err := db.Where("client_id = ?", clientId).Order("last_seen_at DESC").Find(&hwids).Error
return hwids, err
}
// AddHWIDForClient adds a new HWID for a client with device metadata.
// HWID must be provided explicitly (not generated).
// If the client has HWID restrictions enabled, checks if the limit is exceeded.
func (s *ClientHWIDService) AddHWIDForClient(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
hwid = strings.TrimSpace(hwid)
if hwid == "" {
return nil, fmt.Errorf("HWID cannot be empty")
}
// Get client to check restrictions
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return nil, fmt.Errorf("client not found")
}
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Check if HWID already exists for this client
var existingHWID model.ClientHWID
err = tx.Where("client_id = ? AND hwid = ?", clientId, hwid).First(&existingHWID).Error
if err == nil {
// HWID exists - update last seen and IP
now := time.Now().Unix()
updates := map[string]interface{}{
"last_seen_at": now,
"ip_address": ipAddress,
}
if userAgent != "" {
updates["user_agent"] = userAgent
}
// Update device metadata if provided
if deviceOS != "" {
updates["device_os"] = deviceOS
}
if deviceModel != "" {
updates["device_model"] = deviceModel
}
if osVersion != "" {
updates["os_version"] = osVersion
}
existingHWID.IsActive = true
err = tx.Model(&existingHWID).Updates(updates).Error
if err != nil {
return nil, err
}
// Reload to get updated fields
tx.First(&existingHWID, existingHWID.Id)
return &existingHWID, nil
} else if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("failed to check existing HWID: %w", err)
}
// HWID doesn't exist - check if we can add it
var activeHWIDCount int64
if client.HWIDEnabled {
// Count active HWIDs for this client
err = tx.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return nil, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// Check limit (0 means unlimited)
if client.MaxHWID > 0 && int(activeHWIDCount) >= client.MaxHWID {
return nil, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
}
} else {
// Count all HWIDs for device naming even if restriction is disabled
err = tx.Model(&model.ClientHWID{}).Where("client_id = ?", clientId).Count(&activeHWIDCount).Error
if err != nil {
return nil, fmt.Errorf("failed to count HWIDs: %w", err)
}
}
// Create new HWID record
now := time.Now().Unix()
newHWID := &model.ClientHWID{
ClientId: clientId,
HWID: hwid,
DeviceOS: deviceOS,
DeviceModel: deviceModel,
OSVersion: osVersion,
IPAddress: ipAddress,
FirstSeenIP: ipAddress,
UserAgent: userAgent,
IsActive: true,
FirstSeenAt: now,
LastSeenAt: now,
DeviceName: fmt.Sprintf("Device %d", activeHWIDCount+1), // Legacy field, deprecated
}
err = tx.Create(newHWID).Error
if err != nil {
logger.Errorf("Failed to create HWID record in database: %v", err)
return nil, fmt.Errorf("failed to create HWID: %w", err)
}
logger.Debugf("Successfully created HWID record: clientId=%d, hwid=%s, hwidId=%d", clientId, hwid, newHWID.Id)
return newHWID, nil
}
// RemoveHWID removes a HWID from a client.
func (s *ClientHWIDService) RemoveHWID(hwidId int) error {
db := database.GetDB()
return db.Delete(&model.ClientHWID{}, hwidId).Error
}
// DeactivateHWID deactivates a HWID (marks as inactive instead of deleting).
func (s *ClientHWIDService) DeactivateHWID(hwidId int) error {
db := database.GetDB()
return db.Model(&model.ClientHWID{}).Where("id = ?", hwidId).Update("is_active", false).Error
}
// CheckHWIDAllowed checks if a HWID is allowed for a client.
// Returns true if HWID restriction is disabled, or if HWID is in the allowed list.
// NOTE: This method does NOT auto-register HWID. Use RegisterHWIDFromHeaders for registration.
// Behavior depends on hwidMode setting:
// - "off": Always returns true (HWID tracking disabled)
// - "client_header": Requires explicit HWID registration, checks against registered devices
// - "legacy_fingerprint": Legacy mode (deprecated)
func (s *ClientHWIDService) CheckHWIDAllowed(clientId int, hwid string) (bool, error) {
// Check HWID mode setting
settingService := SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
hwidMode = "client_header"
}
// If HWID tracking is disabled globally, allow all
if hwidMode == "off" {
return true, nil
}
// Normalize HWID (trim, but preserve case - HWID is opaque identifier from client)
hwid = strings.TrimSpace(hwid)
if hwid == "" {
// In client_header mode, empty HWID means "unknown device" - don't count, but allow
if hwidMode == "client_header" {
return true, nil // Allow but don't count as registered device
}
return false, fmt.Errorf("HWID cannot be empty")
}
// Get client
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return false, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return false, fmt.Errorf("client not found")
}
// If HWID restriction is disabled for this client, allow all
if !client.HWIDEnabled {
return true, nil
}
// In client_header mode, HWID must be explicitly registered
if hwidMode == "client_header" {
// Check if HWID exists and is active
db := database.GetDB()
var hwidRecord model.ClientHWID
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
if err == nil {
// HWID exists and is active - update last seen
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
return true, nil
} else if err == gorm.ErrRecordNotFound {
// HWID not found - check if we're under limit (allows registration)
var activeHWIDCount int64
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// If under limit, allow (registration can happen via RegisterHWIDFromHeaders)
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
return true, nil
}
// Limit reached, HWID not registered
return false, fmt.Errorf("HWID limit exceeded: max %d devices allowed, current: %d", client.MaxHWID, activeHWIDCount)
}
return false, fmt.Errorf("failed to check HWID: %w", err)
}
// Legacy fingerprint mode (deprecated) - kept for backward compatibility
// This mode may use fingerprint-based HWID generation (not recommended)
if hwidMode == "legacy_fingerprint" {
// Check if HWID exists and is active
db := database.GetDB()
var hwidRecord model.ClientHWID
err = db.Where("client_id = ? AND hwid = ? AND is_active = ?", clientId, hwid, true).First(&hwidRecord).Error
if err == nil {
// HWID exists and is active - update last seen
db.Model(&hwidRecord).Update("last_seen_at", time.Now().Unix())
return true, nil
} else if err == gorm.ErrRecordNotFound {
// HWID not found - check limit
var activeHWIDCount int64
err = db.Model(&model.ClientHWID{}).Where("client_id = ? AND is_active = ?", clientId, true).Count(&activeHWIDCount).Error
if err != nil {
return false, fmt.Errorf("failed to count active HWIDs: %w", err)
}
// If under limit, allow (legacy mode may auto-register via job)
if client.MaxHWID == 0 || int(activeHWIDCount) < client.MaxHWID {
return true, nil
}
// Limit reached, HWID not in list
return false, nil
}
return false, fmt.Errorf("failed to check HWID: %w", err)
}
// Unknown mode - default to allowing (fail open)
logger.Warningf("Unknown hwidMode: %s, allowing request", hwidMode)
return true, nil
}
// RegisterHWIDFromHeaders registers a HWID from HTTP headers provided by client application.
// This is the primary method for HWID registration in client_header mode.
// Headers:
// - x-hwid (required): Hardware ID provided by client
// - 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 (s *ClientHWIDService) RegisterHWIDFromHeaders(clientId int, hwid string, deviceOS string, deviceModel string, osVersion string, ipAddress string, userAgent string) (*model.ClientHWID, error) {
// HWID must be provided explicitly
hwid = strings.TrimSpace(hwid)
if hwid == "" {
return nil, fmt.Errorf("HWID is required (x-hwid header missing)")
}
// Get client to check restrictions
clientService := ClientService{}
client, err := clientService.GetClient(clientId)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
if client == nil {
return nil, fmt.Errorf("client not found")
}
// Check HWID mode setting
settingService := SettingService{}
hwidMode, err := settingService.GetHwidMode()
if err != nil {
logger.Warningf("Failed to get hwidMode setting, defaulting to client_header: %v", err)
hwidMode = "client_header"
}
// In client_header mode, HWID must be provided explicitly (which it is, since we're here)
// In legacy_fingerprint mode, this method should not be called (use legacy methods)
if hwidMode == "off" {
// HWID tracking disabled - allow but don't register
return nil, nil
}
// Register or update HWID
logger.Debugf("RegisterHWIDFromHeaders: calling AddHWIDForClient for clientId=%d, hwid=%s", clientId, hwid)
return s.AddHWIDForClient(clientId, hwid, deviceOS, deviceModel, osVersion, ipAddress, userAgent)
}
// UpdateHWIDLastSeen updates the last seen timestamp and IP address for a HWID.
func (s *ClientHWIDService) UpdateHWIDLastSeen(clientId int, hwid string, ipAddress string) error {
hwid = strings.TrimSpace(hwid) // Preserve case - HWID is opaque identifier
if hwid == "" {
return fmt.Errorf("HWID cannot be empty")
}
db := database.GetDB()
return db.Model(&model.ClientHWID{}).
Where("client_id = ? AND hwid = ?", clientId, hwid).
Updates(map[string]interface{}{
"last_seen_at": time.Now().Unix(),
"ip_address": ipAddress,
}).Error
}
// GenerateFingerprintHWID generates a fingerprint-based HWID from connection parameters.
// DEPRECATED: This method is only for legacy_fingerprint mode (backward compatibility).
// In client_header mode, HWID must be provided explicitly by client via x-hwid header.
// Do NOT use this method for new implementations.
func (s *ClientHWIDService) GenerateFingerprintHWID(email string, ipAddress string, userAgent string) string {
// DEPRECATED: This method should only be used in legacy_fingerprint mode
// Combine parameters to create a fingerprint
fingerprint := fmt.Sprintf("%s|%s|%s", email, ipAddress, userAgent)
// Hash the fingerprint to create a stable HWID
// NOTE: This approach is deprecated and may cause false positives
// when IP addresses change or clients reconnect from different networks
hash := sha256.Sum256([]byte(fingerprint))
return hex.EncodeToString(hash[:])[:32] // Use first 32 chars of hash
}

254
web/service/host.go Normal file
View file

@ -0,0 +1,254 @@
// Package service provides Host management service for multi-node mode.
package service
import (
"time"
"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/common"
"gorm.io/gorm"
)
// HostService provides business logic for managing hosts.
type HostService struct{}
// GetHosts retrieves all hosts for a specific user.
func (s *HostService) GetHosts(userId int) ([]*model.Host, error) {
db := database.GetDB()
var hosts []*model.Host
err := db.Where("user_id = ?", userId).Find(&hosts).Error
if err != nil {
return nil, err
}
// Load inbound assignments for each host
for _, host := range hosts {
inboundIds, err := s.GetInboundIdsForHost(host.Id)
if err == nil {
host.InboundIds = inboundIds
}
}
return hosts, nil
}
// GetHost retrieves a host by ID.
func (s *HostService) GetHost(id int) (*model.Host, error) {
db := database.GetDB()
var host model.Host
err := db.First(&host, id).Error
if err != nil {
return nil, err
}
// Load inbound assignments
inboundIds, err := s.GetInboundIdsForHost(host.Id)
if err == nil {
host.InboundIds = inboundIds
}
return &host, nil
}
// GetInboundIdsForHost retrieves all inbound IDs assigned to a host.
func (s *HostService) GetInboundIdsForHost(hostId int) ([]int, error) {
db := database.GetDB()
var mappings []model.HostInboundMapping
err := db.Where("host_id = ?", hostId).Find(&mappings).Error
if err != nil {
return nil, err
}
inboundIds := make([]int, len(mappings))
for i, mapping := range mappings {
inboundIds[i] = mapping.InboundId
}
return inboundIds, nil
}
// GetHostForInbound retrieves the host assigned to an inbound (if any).
// Returns the first enabled host if multiple hosts are assigned.
func (s *HostService) GetHostForInbound(inboundId int) (*model.Host, error) {
db := database.GetDB()
var mapping model.HostInboundMapping
err := db.Where("inbound_id = ?", inboundId).First(&mapping).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil // No host assigned
}
return nil, err
}
var host model.Host
err = db.Where("id = ? AND enable = ?", mapping.HostId, true).First(&host).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil // Host disabled or not found
}
return nil, err
}
return &host, nil
}
// AddHost creates a new host.
func (s *HostService) AddHost(userId int, host *model.Host) error {
host.UserId = userId
// Set timestamps
now := time.Now().Unix()
if host.CreatedAt == 0 {
host.CreatedAt = now
}
host.UpdatedAt = now
db := database.GetDB()
tx := db.Begin()
var err error
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
err = tx.Create(host).Error
if err != nil {
return err
}
// Assign to inbounds if provided
if len(host.InboundIds) > 0 {
err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds)
if err != nil {
return err
}
}
return nil
}
// UpdateHost updates an existing host.
func (s *HostService) UpdateHost(userId int, host *model.Host) error {
// Check if host exists and belongs to user
existing, err := s.GetHost(host.Id)
if err != nil {
return err
}
if existing.UserId != userId {
return common.NewError("Host not found or access denied")
}
// Update timestamp
host.UpdatedAt = time.Now().Unix()
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Update only provided fields
updates := make(map[string]interface{})
if host.Name != "" {
updates["name"] = host.Name
}
if host.Address != "" {
updates["address"] = host.Address
}
if host.Port > 0 {
updates["port"] = host.Port
}
if host.Protocol != "" {
updates["protocol"] = host.Protocol
}
if host.Remark != "" {
updates["remark"] = host.Remark
}
updates["enable"] = host.Enable
updates["updated_at"] = host.UpdatedAt
err = tx.Model(&model.Host{}).Where("id = ? AND user_id = ?", host.Id, userId).Updates(updates).Error
if err != nil {
return err
}
// Update inbound assignments if provided
if host.InboundIds != nil {
// Remove existing assignments
err = tx.Where("host_id = ?", host.Id).Delete(&model.HostInboundMapping{}).Error
if err != nil {
return err
}
// Add new assignments
if len(host.InboundIds) > 0 {
err = s.AssignHostToInbounds(tx, host.Id, host.InboundIds)
if err != nil {
return err
}
}
}
return nil
}
// DeleteHost deletes a host by ID.
func (s *HostService) DeleteHost(userId int, id int) error {
// Check if host exists and belongs to user
existing, err := s.GetHost(id)
if err != nil {
return err
}
if existing.UserId != userId {
return common.NewError("Host not found or access denied")
}
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
// Delete inbound mappings
err = tx.Where("host_id = ?", id).Delete(&model.HostInboundMapping{}).Error
if err != nil {
return err
}
// Delete host
err = tx.Where("id = ? AND user_id = ?", id, userId).Delete(&model.Host{}).Error
if err != nil {
return err
}
return nil
}
// AssignHostToInbounds assigns a host to multiple inbounds.
func (s *HostService) AssignHostToInbounds(tx *gorm.DB, hostId int, inboundIds []int) error {
for _, inboundId := range inboundIds {
mapping := &model.HostInboundMapping{
HostId: hostId,
InboundId: inboundId,
}
err := tx.Create(mapping).Error
if err != nil {
logger.Warningf("Failed to assign host %d to inbound %d: %v", hostId, inboundId, err)
// Continue with other assignments
}
}
return nil
}

View file

@ -8,6 +8,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
@ -26,6 +27,72 @@ type InboundService struct {
xrayApi xray.XrayAPI
}
// inboundUpdateMutexes provides per-inbound mutexes to prevent concurrent updates
var inboundUpdateMutexes = make(map[int]*sync.Mutex)
var inboundMutexLock sync.Mutex
// getInboundMutex returns a mutex for a specific inbound ID to prevent concurrent updates
func getInboundMutex(inboundId int) *sync.Mutex {
inboundMutexLock.Lock()
defer inboundMutexLock.Unlock()
if mutex, exists := inboundUpdateMutexes[inboundId]; exists {
return mutex
}
mutex := &sync.Mutex{}
inboundUpdateMutexes[inboundId] = mutex
return mutex
}
// updateInboundWithRetry updates an inbound with retry logic for database lock errors.
// It uses a per-inbound mutex to prevent concurrent updates and retries up to 3 times
// with exponential backoff (50ms, 100ms, 200ms).
func (s *InboundService) updateInboundWithRetry(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Use per-inbound mutex to prevent concurrent updates of the same inbound
mutex := getInboundMutex(inbound.Id)
mutex.Lock()
defer mutex.Unlock()
maxRetries := 3
baseDelay := 50 * time.Millisecond
var result *model.Inbound
var needRestart bool
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff: 50ms, 100ms, 200ms
delay := baseDelay * time.Duration(1<<uint(attempt-1))
logger.Debugf("Retrying inbound %d update (attempt %d/%d) after %v", inbound.Id, attempt+1, maxRetries, delay)
time.Sleep(delay)
}
result, needRestart, err = s.UpdateInbound(inbound)
if err == nil {
return result, needRestart, nil
}
// Check if error is "database is locked"
errStr := err.Error()
if strings.Contains(errStr, "database is locked") || strings.Contains(errStr, "locked") {
if attempt < maxRetries-1 {
logger.Debugf("Database locked for inbound %d, will retry: %v", inbound.Id, err)
continue
}
// Last attempt failed
logger.Warningf("Failed to update inbound %d after %d retries: %v", inbound.Id, maxRetries, err)
return result, needRestart, err
}
// For other errors, don't retry
return result, needRestart, err
}
return result, needRestart, err
}
// GetInbounds retrieves all inbounds for a specific user.
// Returns a slice of inbound models with their associated client statistics.
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
@ -145,7 +212,24 @@ func (s *InboundService) checkPortExist(listen string, port int, ignoreId int) (
return count > 0, nil
}
// GetClients retrieves clients for an inbound.
// First tries to get clients from ClientEntity (new approach),
// falls back to parsing Settings JSON for backward compatibility.
func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, error) {
clientService := ClientService{}
// Try to get clients from ClientEntity (new approach)
clientEntities, err := clientService.GetClientsForInbound(inbound.Id)
if err == nil && len(clientEntities) > 0 {
// Convert ClientEntity to Client
clients := make([]model.Client, len(clientEntities))
for i, entity := range clientEntities {
clients[i] = clientService.ConvertClientEntityToClient(entity)
}
return clients, nil
}
// Fallback: parse from Settings JSON (backward compatibility)
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
if settings == nil {
@ -159,17 +243,84 @@ func (s *InboundService) GetClients(inbound *model.Inbound) ([]model.Client, err
return clients, nil
}
// BuildSettingsFromClientEntities builds Settings JSON for Xray from ClientEntity.
// This method creates a minimal Settings structure with only fields needed by Xray.
func (s *InboundService) BuildSettingsFromClientEntities(inbound *model.Inbound, clientEntities []*model.ClientEntity) (string, error) {
// Parse existing settings to preserve other fields (like encryption for VLESS)
var settings map[string]any
if inbound.Settings != "" {
json.Unmarshal([]byte(inbound.Settings), &settings)
}
if settings == nil {
settings = make(map[string]any)
}
// Build clients array for Xray (only minimal fields)
var xrayClients []map[string]any
for _, entity := range clientEntities {
if !entity.Enable {
continue // Skip disabled clients
}
client := make(map[string]any)
client["email"] = entity.Email
switch inbound.Protocol {
case model.Trojan:
client["password"] = entity.Password
case model.Shadowsocks:
// For Shadowsocks, we need to get method from settings
if method, ok := settings["method"].(string); ok {
client["method"] = method
}
client["password"] = entity.Password
case model.VMESS, model.VLESS:
client["id"] = entity.UUID
if inbound.Protocol == model.VMESS {
if entity.Security != "" {
client["security"] = entity.Security
}
}
if inbound.Protocol == model.VLESS && entity.Flow != "" {
client["flow"] = entity.Flow
}
}
xrayClients = append(xrayClients, client)
}
settings["clients"] = xrayClients
settingsJSON, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return "", err
}
return string(settingsJSON), nil
}
func (s *InboundService) getAllEmails() ([]string, error) {
db := database.GetDB()
var emails []string
err := db.Raw(`
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
`).Scan(&emails).Error
// Get emails from ClientEntity (new approach)
err := db.Model(&model.ClientEntity{}).Pluck("email", &emails).Error
if err != nil {
return nil, err
}
// Also get emails from Settings JSON (backward compatibility)
var settingsEmails []string
_ = db.Raw(`
SELECT JSON_EXTRACT(client.value, '$.email')
FROM inbounds,
JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
WHERE JSON_EXTRACT(client.value, '$.email') IS NOT NULL
AND JSON_EXTRACT(client.value, '$.email') != ''
`).Scan(&settingsEmails)
if len(settingsEmails) > 0 {
emails = append(emails, settingsEmails...)
}
return emails, nil
}
@ -277,20 +428,23 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
}
}
// Secure client ID
for _, client := range clients {
switch inbound.Protocol {
case "trojan":
if client.Password == "" {
return inbound, false, common.NewError("empty client ID")
}
case "shadowsocks":
if client.Email == "" {
return inbound, false, common.NewError("empty client ID")
}
default:
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
// Secure client ID (only validate if clients are provided)
// Allow creating inbounds without clients
if len(clients) > 0 {
for _, client := range clients {
switch inbound.Protocol {
case "trojan":
if client.Password == "" {
return inbound, false, common.NewError("empty client ID")
}
case "shadowsocks":
if client.Email == "" {
return inbound, false, common.NewError("empty client ID")
}
default:
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
}
}
}
}

View file

@ -96,6 +96,8 @@ var defaultValueMap = map[string]string{
"ldapDefaultLimitIP": "0",
// Multi-node mode
"multiNodeMode": "false", // "true" for multi-mode, "false" for single-mode
// HWID tracking mode
"hwidMode": "client_header", // "off" = disabled, "client_header" = use x-hwid header (default), "legacy_fingerprint" = deprecated fingerprint-based (deprecated)
}
// SettingService provides business logic for application settings management.
@ -671,6 +673,40 @@ func (s *SettingService) SetMultiNodeMode(enabled bool) error {
return s.setBool("multiNodeMode", enabled)
}
// GetHwidMode returns the HWID tracking mode.
// Returns: "off", "client_header", or "legacy_fingerprint"
func (s *SettingService) GetHwidMode() (string, error) {
mode, err := s.getString("hwidMode")
if err != nil {
return "client_header", err // Default to client_header on error
}
// Validate mode
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
// Invalid mode, return default
return "client_header", nil
}
return mode, nil
}
// SetHwidMode sets the HWID tracking mode.
// Valid values: "off", "client_header", "legacy_fingerprint"
func (s *SettingService) SetHwidMode(mode string) error {
validModes := map[string]bool{
"off": true,
"client_header": true,
"legacy_fingerprint": true,
}
if !validModes[mode] {
return common.NewErrorf("invalid hwidMode: %s (must be one of: off, client_header, legacy_fingerprint)", mode)
}
return s.setString("hwidMode", mode)
}
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
if err := allSetting.CheckValid(); err != nil {
return err

View file

@ -23,6 +23,11 @@
"indefinite" = "Indefinite"
"unlimited" = "Unlimited"
"none" = "None"
"hwidSettings" = "HWID Settings"
"hwidEnabled" = "Enable HWID Restriction"
"maxHwid" = "Max Allowed Devices (HWID)"
"hwidBetaWarningTitle" = "Beta Feature"
"hwidBetaWarningDesc" = "HWID tracking is currently in beta version and works only with happ and v2raytun clients. Other clients may not support HWID registration."
"qrCode" = "QR Code"
"info" = "More Information"
"edit" = "Edit"
@ -72,6 +77,8 @@
"emptyBalancersDesc" = "No added balancers."
"emptyReverseDesc" = "No added reverse proxies."
"somethingWentWrong" = "Something went wrong"
"active" = "Active"
"inactive" = "Inactive"
[subscription]
"title" = "Subscription info"
@ -87,18 +94,6 @@
"unlimited" = "Unlimited"
"noExpiry" = "No expiry"
[menu]
"theme" = "Theme"
"dark" = "Dark"
"ultraDark" = "Ultra Dark"
"dashboard" = "Overview"
"inbounds" = "Inbounds"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
"nodes" = "Nodes"
"logout" = "Log Out"
"link" = "Manage"
[pages.login]
"hello" = "Hello"
"title" = "Welcome"
@ -662,6 +657,90 @@
"mappingError" = "Failed to get node mapping"
"invalidInboundId" = "Invalid inbound ID"
[pages.clients]
"title" = "Clients Management"
"addClient" = "Add Client"
"operate" = "Actions"
"email" = "Email"
"inbounds" = "Assigned Inbounds"
"traffic" = "Traffic"
"expiryTime" = "Expiry Time"
"enable" = "Enabled"
"loadError" = "Failed to load clients"
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this client?"
"deleteSuccess" = "Client deleted successfully"
"deleteError" = "Failed to delete client"
"updateSuccess" = "Client updated successfully"
"updateError" = "Failed to update client"
"addClientNotImplemented" = "Add client functionality will be implemented soon"
"editClientNotImplemented" = "Edit client functionality will be implemented soon"
"editClient" = "Edit Client"
"addSuccess" = "Client added successfully"
"addError" = "Failed to add client"
"emailRequired" = "Email is required"
"maxHwidDesc" = "Set 0 for unlimited devices. If a new device connects and the limit is reached, the connection will be blocked."
"registeredHwids" = "Registered Devices"
"registeredHwidsDesc" = "List of hardware IDs (devices) that have connected to this client. Only active devices count towards the limit."
"noHwidsRegistered" = "No devices registered yet."
"confirmDeleteHwid" = "Are you sure you want to delete this device? This will allow a new device to connect if the limit is not reached."
"hwidDeleteSuccess" = "Device deleted successfully."
"hwidDeleteError" = "Failed to delete device."
"deviceInfo" = "Device Information"
"firstSeen" = "First Seen"
"lastSeen" = "Last Seen"
"actions" = "Actions"
[pages.clients.toasts]
"clientCreateSuccess" = "Client created successfully"
"clientUpdateSuccess" = "Client updated successfully"
[pages.hosts]
"title" = "Hosts Management"
"addNewHost" = "Add New Host"
"addHost" = "Add Host"
"hostName" = "Host Name"
"hostAddress" = "Host Address"
"hostPort" = "Port"
"hostProtocol" = "Protocol"
"operate" = "Actions"
"name" = "Name"
"address" = "Address"
"port" = "Port"
"protocol" = "Protocol"
"assignedInbounds" = "Assigned Inbounds"
"enable" = "Enabled"
"multiNodeModeRequired" = "Multi-Node Mode must be enabled to manage hosts"
"enterHostNameAndAddress" = "Please enter host name and address"
"enterHostName" = "Please enter host name"
"enterHostAddress" = "Please enter host address"
"editHost" = "Edit Host"
"modalNotAvailable" = "Host modal is not available"
"loadError" = "Failed to load hosts"
"deleteConfirm" = "Confirm Deletion"
"deleteConfirmText" = "Are you sure you want to delete this host?"
"deleteSuccess" = "Host deleted successfully"
"deleteError" = "Failed to delete host"
"updateSuccess" = "Host updated successfully"
"updateError" = "Failed to update host"
"addSuccess" = "Host added successfully"
"addError" = "Failed to add host"
"editHostNotImplemented" = "Edit host functionality will be implemented soon"
[menu]
"theme" = "Theme"
"dark" = "Dark"
"ultraDark" = "Ultra Dark"
"dashboard" = "Overview"
"inbounds" = "Inbounds"
"clients" = "Clients"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
"nodes" = "Nodes"
"hosts" = "Hosts"
"logout" = "Log Out"
"link" = "Manage"
[pages.settings.toasts]
"modifySettings" = "The parameters have been changed."
"getSettings" = "An error occurred while retrieving parameters."

View file

@ -23,6 +23,11 @@
"indefinite" = "Бесконечно"
"unlimited" = "Безлимит"
"none" = "Пусто"
"hwidSettings" = "Настройки HWID"
"hwidEnabled" = "Включить ограничение по HWID"
"maxHwid" = "Максимум устройств (HWID)"
"hwidBetaWarningTitle" = "Бета-функция"
"hwidBetaWarningDesc" = "Отслеживание HWID находится в бета-версии и работает только с клиентами happ и v2raytun. Другие клиенты могут не поддерживать регистрацию HWID."
"qrCode" = "QR-код"
"info" = "Информация"
"edit" = "Изменить"
@ -72,6 +77,8 @@
"emptyBalancersDesc" = "Нет добавленных балансировщиков."
"emptyReverseDesc" = "Нет добавленных реверс-прокси."
"somethingWentWrong" = "Что-то пошло не так"
"active" = "Активен"
"inactive" = "Неактивен"
[subscription]
"title" = "Информация о подписке"
@ -87,18 +94,6 @@
"unlimited" = "Неограниченно"
"noExpiry" = "Бессрочно"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"dashboard" = "Дашборд"
"inbounds" = "Подключения"
"settings" = "Настройки"
"xray" = "Настройки Xray"
"nodes" = "Ноды"
"logout" = "Выход"
"link" = "Управление"
[pages.login]
"hello" = "Привет!"
"title" = "Добро пожаловать!"
@ -662,6 +657,90 @@
"mappingError" = "Не удалось получить привязку ноды"
"invalidInboundId" = "Неверный ID подключения"
[pages.clients]
"title" = "Управление клиентами"
"addClient" = "Добавить клиента"
"operate" = "Действия"
"email" = "Email"
"inbounds" = "Назначенные подключения"
"traffic" = "Трафик"
"expiryTime" = "Срок действия"
"enable" = "Включено"
"loadError" = "Не удалось загрузить список клиентов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этого клиента?"
"deleteSuccess" = "Клиент успешно удален"
"deleteError" = "Не удалось удалить клиента"
"updateSuccess" = "Клиент успешно обновлен"
"updateError" = "Не удалось обновить клиента"
"addClientNotImplemented" = "Функция добавления клиента будет реализована в ближайшее время"
"editClientNotImplemented" = "Функция редактирования клиента будет реализована в ближайшее время"
"editClient" = "Редактировать клиента"
"addSuccess" = "Клиент успешно добавлен"
"addError" = "Не удалось добавить клиента"
"emailRequired" = "Email обязателен для заполнения"
"maxHwidDesc" = "Установите 0 для неограниченного количества устройств. Если новое устройство подключается и лимит достигнут, подключение будет заблокировано."
"registeredHwids" = "Зарегистрированные устройства"
"registeredHwidsDesc" = "Список аппаратных ID (устройств), которые подключались к этому клиенту. Только активные устройства учитываются в лимите."
"noHwidsRegistered" = "Устройства еще не зарегистрированы."
"confirmDeleteHwid" = "Вы уверены, что хотите удалить это устройство? Это позволит новому устройству подключиться, если лимит не достигнут."
"hwidDeleteSuccess" = "Устройство успешно удалено."
"hwidDeleteError" = "Не удалось удалить устройство."
"deviceInfo" = "Информация об устройстве"
"firstSeen" = "Первое подключение"
"lastSeen" = "Последнее подключение"
"actions" = "Действия"
[pages.clients.toasts]
"clientCreateSuccess" = "Клиент успешно создан"
"clientUpdateSuccess" = "Клиент успешно обновлен"
[pages.hosts]
"title" = "Управление хостами"
"addNewHost" = "Добавить новый хост"
"addHost" = "Добавить хост"
"hostName" = "Имя хоста"
"hostAddress" = "Адрес хоста"
"hostPort" = "Порт"
"hostProtocol" = "Протокол"
"operate" = "Действия"
"name" = "Имя"
"address" = "Адрес"
"port" = "Порт"
"protocol" = "Протокол"
"assignedInbounds" = "Назначенные подключения"
"enable" = "Включено"
"multiNodeModeRequired" = "Для управления хостами должен быть включен режим Multi-Node"
"enterHostNameAndAddress" = "Пожалуйста, введите имя и адрес хоста"
"enterHostName" = "Пожалуйста, введите имя хоста"
"enterHostAddress" = "Пожалуйста, введите адрес хоста"
"editHost" = "Редактировать хост"
"modalNotAvailable" = "Модальное окно хоста недоступно"
"loadError" = "Не удалось загрузить список хостов"
"deleteConfirm" = "Подтверждение удаления"
"deleteConfirmText" = "Вы уверены, что хотите удалить этот хост?"
"deleteSuccess" = "Хост успешно удален"
"deleteError" = "Не удалось удалить хост"
"updateSuccess" = "Хост успешно обновлен"
"updateError" = "Не удалось обновить хост"
"addSuccess" = "Хост успешно добавлен"
"addError" = "Не удалось добавить хост"
"editHostNotImplemented" = "Функция редактирования хоста будет реализована в ближайшее время"
[menu]
"theme" = "Тема"
"dark" = "Темная"
"ultraDark" = "Очень темная"
"dashboard" = "Обзор"
"inbounds" = "Подключения"
"clients" = "Клиенты"
"settings" = "Настройки панели"
"xray" = "Конфигурация Xray"
"nodes" = "Ноды"
"hosts" = "Хосты"
"logout" = "Выйти"
"link" = "Управление"
[pages.settings.toasts]
"modifySettings" = "Настройки изменены"
"getSettings" = "Произошла ошибка при получении параметров."

View file

@ -320,6 +320,9 @@ func (s *Server) startTask() {
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
// Check client HWIDs from log file every 30 seconds
s.cron.AddJob("@every 30s", job.NewCheckClientHWIDJob())
// check client ips from log file every day
s.cron.AddJob("@daily", job.NewClearLogsJob())