add speed limiter for download and upload speed in megabytes

This commit is contained in:
Saeed 2026-05-01 11:05:09 +03:30
parent 51e2fb6dbf
commit 2d2c1e287e
9 changed files with 150 additions and 44 deletions

View file

@ -131,20 +131,22 @@ type CustomGeoResource struct {
// Client represents a client configuration for Xray inbounds with traffic limits and settings. // Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct { type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm") Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS) Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Auth string `json:"auth,omitempty"` // Auth password (Hysteria) Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB UploadSpeedLimit int64 `json:"uploadSpeedLimit" form:"uploadSpeedLimit"` // Upload speed limit in bytes/sec
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp DownloadSpeedLimit int64 `json:"downloadSpeedLimit" form:"downloadSpeedLimit"` // Download speed limit in bytes/sec
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
SubID string `json:"subId" form:"subId"` // Subscription identifier Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
Comment string `json:"comment" form:"comment"` // Client comment TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
Reset int `json:"reset" form:"reset"` // Reset period in days SubID string `json:"subId" form:"subId"` // Subscription identifier
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp Comment string `json:"comment" form:"comment"` // Client comment
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp 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
go.mod
View file

@ -2,6 +2,8 @@ module github.com/mhsanaei/3x-ui/v2
go 1.26.2 go 1.26.2
replace github.com/xtls/xray-core => ../core
require ( require (
github.com/gin-contrib/gzip v1.2.6 github.com/gin-contrib/gzip v1.2.6
github.com/gin-contrib/sessions v1.1.0 github.com/gin-contrib/sessions v1.1.0

View file

@ -2387,6 +2387,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
constructor( constructor(
email = RandomUtil.randomLowerAndNum(8), email = RandomUtil.randomLowerAndNum(8),
limitIp = 0, limitIp = 0,
uploadSpeedLimit = 0,
downloadSpeedLimit = 0,
totalGB = 0, totalGB = 0,
expiryTime = 0, expiryTime = 0,
enable = true, enable = true,
@ -2400,6 +2402,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
super(); super();
this.email = email; this.email = email;
this.limitIp = limitIp; this.limitIp = limitIp;
this.uploadSpeedLimit = uploadSpeedLimit;
this.downloadSpeedLimit = downloadSpeedLimit;
this.totalGB = totalGB; this.totalGB = totalGB;
this.expiryTime = expiryTime; this.expiryTime = expiryTime;
this.enable = enable; this.enable = enable;
@ -2415,6 +2419,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
return [ return [
json.email, json.email,
json.limitIp, json.limitIp,
json.uploadSpeedLimit,
json.downloadSpeedLimit,
json.totalGB, json.totalGB,
json.expiryTime, json.expiryTime,
json.enable, json.enable,
@ -2431,6 +2437,8 @@ Inbound.ClientBase = class extends XrayCommonClass {
return { return {
email: this.email, email: this.email,
limitIp: this.limitIp, limitIp: this.limitIp,
uploadSpeedLimit: this.uploadSpeedLimit,
downloadSpeedLimit: this.downloadSpeedLimit,
totalGB: this.totalGB, totalGB: this.totalGB,
expiryTime: this.expiryTime, expiryTime: this.expiryTime,
enable: this.enable, enable: this.enable,
@ -2468,6 +2476,22 @@ Inbound.ClientBase = class extends XrayCommonClass {
set _totalGB(gb) { set _totalGB(gb) {
this.totalGB = NumberFormatter.toFixed(gb * SizeFormatter.ONE_GB, 0); 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 { Inbound.VmessSettings = class extends Inbound.Settings {

View file

@ -139,6 +139,32 @@
</template> </template>
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number> <a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
</a-form-item> </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 <a-form-item
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit" v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
> >

View file

@ -733,13 +733,15 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
cipher = oldSettings["method"].(string) cipher = oldSettings["method"].(string)
} }
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{ err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": client.Email, "email": client.Email,
"id": client.ID, "id": client.ID,
"auth": client.Auth, "auth": client.Auth,
"security": client.Security, "security": client.Security,
"flow": client.Flow, "flow": client.Flow,
"password": client.Password, "password": client.Password,
"cipher": cipher, "cipher": cipher,
"uploadSpeedLimit": client.UploadSpeedLimit,
"downloadSpeedLimit": client.DownloadSpeedLimit,
}) })
if err1 == nil { if err1 == nil {
logger.Debug("Client added by api:", client.Email) logger.Debug("Client added by api:", client.Email)
@ -1199,13 +1201,15 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
cipher = oldSettings["method"].(string) cipher = oldSettings["method"].(string)
} }
err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{ err1 := s.xrayApi.AddUser(string(oldInbound.Protocol), oldInbound.Tag, map[string]any{
"email": clients[0].Email, "email": clients[0].Email,
"id": clients[0].ID, "id": clients[0].ID,
"security": clients[0].Security, "security": clients[0].Security,
"flow": clients[0].Flow, "flow": clients[0].Flow,
"auth": clients[0].Auth, "auth": clients[0].Auth,
"password": clients[0].Password, "password": clients[0].Password,
"cipher": cipher, "cipher": cipher,
"uploadSpeedLimit": clients[0].UploadSpeedLimit,
"downloadSpeedLimit": clients[0].DownloadSpeedLimit,
}) })
if err1 == nil { if err1 == nil {
logger.Debug("Client edited by api:", clients[0].Email) logger.Debug("Client edited by api:", clients[0].Email)
@ -2094,13 +2098,15 @@ func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (bool, e
cipher = oldSettings["method"].(string) cipher = oldSettings["method"].(string)
} }
err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{ err1 := s.xrayApi.AddUser(string(inbound.Protocol), inbound.Tag, map[string]any{
"email": client.Email, "email": client.Email,
"id": client.ID, "id": client.ID,
"auth": client.Auth, "auth": client.Auth,
"security": client.Security, "security": client.Security,
"flow": client.Flow, "flow": client.Flow,
"password": client.Password, "password": client.Password,
"cipher": cipher, "cipher": cipher,
"uploadSpeedLimit": client.UploadSpeedLimit,
"downloadSpeedLimit": client.DownloadSpeedLimit,
}) })
if err1 == nil { if err1 == nil {
logger.Debug("Client enabled due to reset traffic:", clientEmail) logger.Debug("Client enabled due to reset traffic:", clientEmail)

View file

@ -146,9 +146,16 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
continue continue
} }
// clear client config for additional parameters // Keep only fields that are needed by Xray runtime config generation.
for key := range c { for key := range c {
if key != "email" && key != "id" && key != "password" && key != "flow" && key != "method" && key != "auth" { if key != "email" &&
key != "id" &&
key != "password" &&
key != "flow" &&
key != "method" &&
key != "auth" &&
key != "uploadSpeedLimit" &&
key != "downloadSpeedLimit" {
delete(c, key) delete(c, key)
} }
if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" { if flow, ok := c["flow"].(string); ok && flow == "xtls-rprx-vision-udp443" {

View file

@ -256,6 +256,7 @@
"targetAddress" = "Target Address" "targetAddress" = "Target Address"
"monitorDesc" = "Leave blank to listen on all IPs" "monitorDesc" = "Leave blank to listen on all IPs"
"meansNoLimit" = "= Unlimited. (unit: GB)" "meansNoLimit" = "= Unlimited. (unit: GB)"
"meansNoLimitMB" = "= Unlimited. (unit: MB)"
"totalFlow" = "Total Flow" "totalFlow" = "Total Flow"
"leaveBlankToNeverExpire" = "Leave blank to never expire" "leaveBlankToNeverExpire" = "Leave blank to never expire"
"noRecommendKeepDefault" = "It is recommended to keep the default" "noRecommendKeepDefault" = "It is recommended to keep the default"
@ -289,6 +290,8 @@
"IPLimitlog" = "IP Log" "IPLimitlog" = "IP Log"
"IPLimitlogDesc" = "The IPs history log. (to enable inbound after disabling, clear the log)" "IPLimitlogDesc" = "The IPs history log. (to enable inbound after disabling, clear the log)"
"IPLimitlogclear" = "Clear The Log" "IPLimitlogclear" = "Clear The Log"
"uploadSpeedLimit" = "Upload Speed"
"downloadSpeedLimit" = "Download Speed"
"setDefaultCert" = "Set Cert from Panel" "setDefaultCert" = "Set Cert from Panel"
"telegramDesc" = "Please provide Telegram Chat ID. (use '/id' command in the bot) or (@userinfobot)" "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." "subscriptionDesc" = "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients."

View file

@ -255,7 +255,8 @@
"destinationPort" = "پورت مقصد" "destinationPort" = "پورت مقصد"
"targetAddress" = "آدرس مقصد" "targetAddress" = "آدرس مقصد"
"monitorDesc" = "به‌طور پیش‌فرض خالی‌بگذارید" "monitorDesc" = "به‌طور پیش‌فرض خالی‌بگذارید"
"meansNoLimit" = "0 = واحد: گیگابایت) نامحدود)" "meansNoLimit" = "= نامحدود. (واحد: گیگابایت)"
"meansNoLimitMB" = "= نامحدود. (واحد: مگابایت)"
"totalFlow" = "ترافیک کل" "totalFlow" = "ترافیک کل"
"leaveBlankToNeverExpire" = "برای منقضی‌نشدن خالی‌بگذارید" "leaveBlankToNeverExpire" = "برای منقضی‌نشدن خالی‌بگذارید"
"noRecommendKeepDefault" = "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود" "noRecommendKeepDefault" = "توصیه‌می‌شود به‌طور پیش‌فرض حفظ‌شود"
@ -289,6 +290,8 @@
"IPLimitlog" = "گزارش‌ها" "IPLimitlog" = "گزارش‌ها"
"IPLimitlogDesc" = "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید" "IPLimitlogDesc" = "گزارش تاریخچه آی‌پی. برای فعال کردن ورودی پس از غیرفعال شدن، گزارش را پاک کنید"
"IPLimitlogclear" = "پاک کردن گزارش‌ها" "IPLimitlogclear" = "پاک کردن گزارش‌ها"
"uploadSpeedLimit" = "سرعت آپلود"
"downloadSpeedLimit" = "سرعت دانلود"
"setDefaultCert" = "استفاده از گواهی پنل" "setDefaultCert" = "استفاده از گواهی پنل"
"telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)" "telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)"
"subscriptionDesc" = "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید" "subscriptionDesc" = "شما می‌توانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین می‌توانید از همین نام برای چندین کاربر استفاده‌کنید"

View file

@ -65,6 +65,28 @@ func getOptionalUserString(user map[string]any, key string) (string, error) {
return strValue, nil 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. // Init connects to the Xray API server and initializes handler and stats service clients.
func (x *XrayAPI) Init(apiPort int) error { func (x *XrayAPI) Init(apiPort int) error {
if apiPort <= 0 || apiPort > math.MaxUint16 { if apiPort <= 0 || apiPort > math.MaxUint16 {
@ -136,6 +158,14 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
if err != nil { if err != nil {
return err 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 var account *serial.TypedMessage
switch Protocol { switch Protocol {
@ -241,14 +271,17 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
} }
client := *x.HandlerServiceClient client := *x.HandlerServiceClient
protocolUser := &protocol.User{
Email: userEmail,
Account: account,
UplinkSpeedLimit: uint64(uploadSpeedLimit),
DownlinkSpeedLimit: uint64(downloadSpeedLimit),
}
_, err = client.AlterInbound(context.Background(), &command.AlterInboundRequest{ _, err = client.AlterInbound(context.Background(), &command.AlterInboundRequest{
Tag: inboundTag, Tag: inboundTag,
Operation: serial.ToTypedMessage(&command.AddUserOperation{ Operation: serial.ToTypedMessage(&command.AddUserOperation{
User: &protocol.User{ User: protocolUser,
Email: userEmail,
Account: account,
},
}), }),
}) })
return err return err