mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-01-13 01:02:46 +00:00
refactor panel new logic
This commit is contained in:
parent
66662afa4d
commit
7e2f3fda03
35 changed files with 5369 additions and 233 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1135
sub/subService.go
1135
sub/subService.go
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
274
web/controller/client.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
224
web/controller/client_hwid.go
Normal file
224
web/controller/client_hwid.go
Normal 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
253
web/controller/host.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
730
web/html/clients.html
Normal 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" .}}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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%">
|
||||
|
|
|
|||
|
|
@ -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
395
web/html/hosts.html
Normal 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" .}}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
307
web/html/modals/client_entity_modal.html
Normal file
307
web/html/modals/client_entity_modal.html
Normal 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}}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
153
web/html/modals/host_modal.html
Normal file
153
web/html/modals/host_modal.html
Normal 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}}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
155
web/job/check_client_hwid_job.go
Normal file
155
web/job/check_client_hwid_job.go
Normal 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
602
web/service/client.go
Normal 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(¤tMappings)
|
||||
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
342
web/service/client_hwid.go
Normal 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
254
web/service/host.go
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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" = "Произошла ошибка при получении параметров."
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue