Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions database/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,21 +135,22 @@ type ClientReverse struct {

// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"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"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
UploadSpeedLimit int64 `json:"uploadSpeedLimit" form:"uploadSpeedLimit"` // Upload speed limit in bytes/sec
DownloadSpeedLimit int64 `json:"downloadSpeedLimit" form:"downloadSpeedLimit"` // Download speed limit in bytes/sec
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"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/mhsanaei/3x-ui/v2

go 1.26.3

replace github.com/xtls/xray-core => ../core

require (
github.com/gin-contrib/gzip v1.2.6
github.com/gin-contrib/sessions v1.1.0
Expand Down
24 changes: 24 additions & 0 deletions web/assets/js/model/inbound.js
Original file line number Diff line number Diff line change
Expand Up @@ -2458,6 +2458,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
constructor(
email = RandomUtil.randomLowerAndNum(8),
limitIp = 0,
uploadSpeedLimit = 0,
downloadSpeedLimit = 0,
totalGB = 0,
expiryTime = 0,
enable = true,
Expand All @@ -2471,6 +2473,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
super();
this.email = email;
this.limitIp = limitIp;
this.uploadSpeedLimit = uploadSpeedLimit;
this.downloadSpeedLimit = downloadSpeedLimit;
this.totalGB = totalGB;
this.expiryTime = expiryTime;
this.enable = enable;
Expand All @@ -2486,6 +2490,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
return [
json.email,
json.limitIp,
json.uploadSpeedLimit,
json.downloadSpeedLimit,
json.totalGB,
json.expiryTime,
json.enable,
Expand All @@ -2502,6 +2508,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
return {
email: this.email,
limitIp: this.limitIp,
uploadSpeedLimit: this.uploadSpeedLimit,
downloadSpeedLimit: this.downloadSpeedLimit,
totalGB: this.totalGB,
expiryTime: this.expiryTime,
enable: this.enable,
Expand Down Expand Up @@ -2539,6 +2547,22 @@ Inbound.ClientBase = class extends XrayCommonClass {
set _totalGB(gb) {
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0);
}

get _uploadSpeedLimitMB() {
return NumberFormatter.toFixed(this.uploadSpeedLimit / SizeFormatter.ONE_MB, 2);
}

set _uploadSpeedLimitMB(mb) {
this.uploadSpeedLimit = NumberFormatter.toFixed((mb || 0) * SizeFormatter.ONE_MB, 0);
}

get _downloadSpeedLimitMB() {
return NumberFormatter.toFixed(this.downloadSpeedLimit / SizeFormatter.ONE_MB, 2);
}

set _downloadSpeedLimitMB(mb) {
this.downloadSpeedLimit = NumberFormatter.toFixed((mb || 0) * SizeFormatter.ONE_MB, 0);
}
};

Inbound.VmessSettings = class extends Inbound.Settings {
Expand Down
29 changes: 29 additions & 0 deletions web/html/form/client.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,35 @@
</template>
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
</a-form-item>
<a-form-item v-if="client.email">
<template slot="label">
<a-tooltip>
<template slot="title">
0 <span>{{ i18n "pages.inbounds.meansNoLimitMB" }}</span>
</template>
{{ i18n "pages.inbounds.uploadSpeedLimit" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="client._uploadSpeedLimitMB" :min="0"></a-input-number>
<span style="margin-left: 8px;">MB/s</span>
</a-form-item>
<a-form-item v-if="client.email">
<template slot="label">
<a-tooltip>
<template slot="title">
0 <span>{{ i18n "pages.inbounds.meansNoLimitMB" }}</span>
</template>
{{ i18n "pages.inbounds.downloadSpeedLimit" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="client._downloadSpeedLimitMB" :min="0"></a-input-number>
<span style="margin-left: 8px;">MB/s</span>
</a-form-item>
<a-form-item
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
>
<a-form-item v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit">
<template slot="label">
<a-tooltip>
Expand Down
48 changes: 27 additions & 21 deletions web/service/inbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -803,13 +803,15 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
cipher = oldSettings["method"].(string)
}
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"uploadSpeedLimit": client.UploadSpeedLimit,
"downloadSpeedLimit": client.DownloadSpeedLimit,
})
if err1 == nil {
logger.Debug("Client added by api:", client.Email)
Expand Down Expand Up @@ -1297,13 +1299,15 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
cipher = oldSettings["method"].(string)
}
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"email": clients[0].Email,
"id": clients[0].ID,
"security": clients[0].Security,
"flow": clients[0].Flow,
"auth": clients[0].Auth,
"password": clients[0].Password,
"cipher": cipher,
"uploadSpeedLimit": clients[0].UploadSpeedLimit,
"downloadSpeedLimit": clients[0].DownloadSpeedLimit,
})
if err1 == nil {
logger.Debug("Client edited by api:", clients[0].Email)
Expand Down Expand Up @@ -2313,13 +2317,15 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
cipher = oldSettings["method"].(string)
}
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
"uploadSpeedLimit": client.UploadSpeedLimit,
"downloadSpeedLimit": client.DownloadSpeedLimit,
})
if err1 == nil {
logger.Debug("Client enabled due to reset traffic:", clientEmail)
Expand Down
11 changes: 9 additions & 2 deletions web/service/xray.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,16 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
continue
}

// clear client config for additional parameters
// Keep only fields that are needed by Xray runtime config generation.
for key := range c {
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" && key != "reverse" {
if key != "email" &&
key != "id" &&
key != "password" &&
key != "flow" &&
key != "method" &&
key != "auth" &&
key != "uploadSpeedLimit" &&
key != "downloadSpeedLimit" {
delete(c, key)
}
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {
Expand Down
3 changes: 3 additions & 0 deletions web/translation/translate.en_US.toml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@
"targetAddress" = "Target Address"
"monitorDesc" = "Leave blank to listen on all IPs"
"meansNoLimit" = "= Unlimited. (unit: GB)"
"meansNoLimitMB" = "= Unlimited. (unit: MB)"
"totalFlow" = "Total Flow"
"leaveBlankToNeverExpire" = "Leave blank to never expire"
"noRecommendKeepDefault" = "It is recommended to keep the default"
Expand Down Expand Up @@ -290,6 +291,8 @@
"IPLimitlog" = "IP Log"
"IPLimitlogDesc" = "The IPs history log. (to enable inbound after disabling, clear the log)"
"IPLimitlogclear" = "Clear The Log"
"uploadSpeedLimit" = "Upload Speed"
"downloadSpeedLimit" = "Download Speed"
"setDefaultCert" = "Set Cert from Panel"
"telegramDesc" = "Please provide Telegram Chat ID. (use '/id' command in the bot) or (@userinfobot)"
"subscriptionDesc" = "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients."
Expand Down
5 changes: 4 additions & 1 deletion web/translation/translate.fa_IR.toml
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@
"destinationPort" = "پورت مقصد"
"targetAddress" = "آدرس مقصد"
"monitorDesc" = "به‌طور پیش‌فرض خالی‌بگذارید"
"meansNoLimit" = "0 = واحد: گیگابایت) نامحدود)"
"meansNoLimit" = "= نامحدود. (واحد: گیگابایت)"
"meansNoLimitMB" = "= نامحدود. (واحد: مگابایت)"
"totalFlow" = "ترافیک کل"
"leaveBlankToNeverExpire" = "برای منقضی‌نشدن خالی‌بگذارید"
"noRecommendKeepDefault" = "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود"
Expand Down Expand Up @@ -290,6 +291,8 @@
"IPLimitlog" = "گزارش‌ها"
"IPLimitlogDesc" = "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید"
"IPLimitlogclear" = "پاک کردن گزارش‌ها"
"uploadSpeedLimit" = "سرعت آپلود"
"downloadSpeedLimit" = "سرعت دانلود"
"setDefaultCert" = "استفاده از گواهی پنل"
"telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)"
"subscriptionDesc" = "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید"
Expand Down
41 changes: 37 additions & 4 deletions xray/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ func getOptionalUserString(user map[string]any, key string) (string, error) {
return strValue, nil
}

func getOptionalUserInt64(user map[string]any, key string) (int64, error) {
value, ok := user[key]
if !ok || value == nil {
return 0, nil
}

switch v := value.(type) {
case int:
return int64(v), nil
case int32:
return int64(v), nil
case int64:
return v, nil
case float64:
return int64(v), nil
case json.Number:
return v.Int64()
default:
return 0, fmt.Errorf("invalid type for user field %q: %T", key, value)
}
}

// Init connects to the Xray API server and initializes handler and stats service clients.
func (x *XrayAPI) Init(apiPort int) error {
if apiPort <= 0 || apiPort > math.MaxUint16 {
Expand Down Expand Up @@ -136,6 +158,14 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
if err != nil {
return err
}
uploadSpeedLimit, err := getOptionalUserInt64(user, "uploadSpeedLimit")
if err != nil {
return err
}
downloadSpeedLimit, err := getOptionalUserInt64(user, "downloadSpeedLimit")
if err != nil {
return err
}

var account *serial.TypedMessage
switch Protocol {
Expand Down Expand Up @@ -241,14 +271,17 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
}

client := *x.HandlerServiceClient
protocolUser := &protocol.User{
Email: userEmail,
Account: account,
UplinkSpeedLimit: uint64(uploadSpeedLimit),
DownlinkSpeedLimit: uint64(downloadSpeedLimit),
}

_, err = client.AlterInbound(context.Background(), &command.AlterInboundRequest{
Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{
Email: userEmail,
Account: account,
},
User: protocolUser,
}),
})
return err
Expand Down