diff --git a/config/version b/config/version
index 391e9856..6799bc92 100644
--- a/config/version
+++ b/config/version
@@ -1 +1 @@
-2.9.2
\ No newline at end of file
+2.9.2-sharequota1
diff --git a/database/model/model.go b/database/model/model.go
index 01654d22..7d27d717 100644
--- a/database/model/model.go
+++ b/database/model/model.go
@@ -145,6 +145,7 @@ type Client struct {
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
+ ShareQuota bool `json:"shareQuota" form:"shareQuota"` // Pool totalGB/expiryTime with siblings sharing subId
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}
diff --git a/sub/subService.go b/sub/subService.go
index 272bf9d5..f281cef3 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -45,6 +45,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
var traffic xray.ClientTraffic
var lastOnline int64
var clientTraffics []xray.ClientTraffic
+ var clientShared []bool
inbounds, err := s.getInboundsBySubId(subId)
if err != nil {
return nil, 0, traffic, err
@@ -80,6 +81,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
result = append(result, link)
ct := s.getClientTraffics(inbound.ClientStats, client.Email)
clientTraffics = append(clientTraffics, ct)
+ clientShared = append(clientShared, client.ShareQuota)
if ct.LastOnline > lastOnline {
lastOnline = ct.LastOnline
}
@@ -87,28 +89,90 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
}
}
- // Prepare statistics
- for index, clientTraffic := range clientTraffics {
- if index == 0 {
- traffic.Up = clientTraffic.Up
- traffic.Down = clientTraffic.Down
- traffic.Total = clientTraffic.Total
- if clientTraffic.ExpiryTime > 0 {
- traffic.ExpiryTime = clientTraffic.ExpiryTime
+ // Detect whether any member of this subId opted into shared quota.
+ hasShared := false
+ for _, shared := range clientShared {
+ if shared {
+ hasShared = true
+ break
+ }
+ }
+
+ if !hasShared {
+ // No shareQuota in this subId: preserve the original aggregation (Total
+ // sums across members; ExpiryTime collapses to 0 when members disagree).
+ for index, clientTraffic := range clientTraffics {
+ if index == 0 {
+ traffic.Up = clientTraffic.Up
+ traffic.Down = clientTraffic.Down
+ traffic.Total = clientTraffic.Total
+ if clientTraffic.ExpiryTime > 0 {
+ traffic.ExpiryTime = clientTraffic.ExpiryTime
+ }
+ } else {
+ traffic.Up += clientTraffic.Up
+ traffic.Down += clientTraffic.Down
+ if traffic.Total == 0 || clientTraffic.Total == 0 {
+ traffic.Total = 0
+ } else {
+ traffic.Total += clientTraffic.Total
+ }
+ if clientTraffic.ExpiryTime != traffic.ExpiryTime {
+ traffic.ExpiryTime = 0
+ }
+ }
+ }
+ return result, lastOnline, traffic, nil
+ }
+
+ // Mixed-or-all-shared case: Up/Down sum across everyone (actual consumption).
+ // Total combines max-of-shared-totals + sum-of-unshared-totals so shared
+ // members aren't double-counted while unshared ones still add their own cap.
+ // ExpiryTime = earliest non-zero across all; 0 only if every member is unset.
+ var sharedMax int64
+ sharedMaxSeen := false
+ sharedUnlimited := false
+ var unsharedSum int64
+ unsharedUnlimited := false
+ for i, ct := range clientTraffics {
+ traffic.Up += ct.Up
+ traffic.Down += ct.Down
+ if clientShared[i] {
+ if ct.Total == 0 {
+ sharedUnlimited = true
+ } else if !sharedMaxSeen || ct.Total > sharedMax {
+ sharedMax = ct.Total
+ sharedMaxSeen = true
}
} else {
- traffic.Up += clientTraffic.Up
- traffic.Down += clientTraffic.Down
- if traffic.Total == 0 || clientTraffic.Total == 0 {
- traffic.Total = 0
+ if ct.Total == 0 {
+ unsharedUnlimited = true
} else {
- traffic.Total += clientTraffic.Total
- }
- if clientTraffic.ExpiryTime != traffic.ExpiryTime {
- traffic.ExpiryTime = 0
+ unsharedSum += ct.Total
}
}
}
+ if sharedUnlimited || unsharedUnlimited {
+ traffic.Total = 0
+ } else {
+ traffic.Total = 0
+ if sharedMaxSeen {
+ traffic.Total += sharedMax
+ }
+ traffic.Total += unsharedSum
+ }
+
+ traffic.ExpiryTime = 0
+ expirySet := false
+ for _, ct := range clientTraffics {
+ if ct.ExpiryTime == 0 {
+ continue
+ }
+ if !expirySet || ct.ExpiryTime < traffic.ExpiryTime {
+ traffic.ExpiryTime = ct.ExpiryTime
+ expirySet = true
+ }
+ }
return result, lastOnline, traffic, nil
}
diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js
index 26da6cb3..e7880ab9 100644
--- a/web/assets/js/model/inbound.js
+++ b/web/assets/js/model/inbound.js
@@ -2087,6 +2087,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset = 0,
created_at = undefined,
updated_at = undefined,
+ shareQuota = false,
) {
super();
this.email = email;
@@ -2100,6 +2101,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
this.reset = reset;
this.created_at = created_at;
this.updated_at = updated_at;
+ this.shareQuota = !!shareQuota;
}
static commonArgsFromJson(json = {}) {
@@ -2115,6 +2117,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
json.reset,
json.created_at,
json.updated_at,
+ json.shareQuota,
];
}
@@ -2131,6 +2134,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
reset: this.reset,
created_at: this.created_at,
updated_at: this.updated_at,
+ shareQuota: !!this.shareQuota,
};
}
@@ -2204,9 +2208,9 @@ Inbound.VmessSettings.VMESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
security = USERS_SECURITY.AUTO,
- email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
+ email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
- super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
+ super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.id = id;
this.security = security;
}
@@ -2309,9 +2313,9 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
flow = '',
- email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
+ email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
- super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
+ super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.id = id;
this.flow = flow;
}
@@ -2407,9 +2411,9 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
Inbound.TrojanSettings.Trojan = class extends Inbound.ClientBase {
constructor(
password = RandomUtil.randomSeq(10),
- email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
+ email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
- super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
+ super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.password = password;
}
@@ -2509,9 +2513,9 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends Inbound.ClientBase {
constructor(
method = '',
password = RandomUtil.randomShadowsocksPassword(),
- email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
+ email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
- super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
+ super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.method = method;
this.password = password;
}
@@ -2559,9 +2563,9 @@ Inbound.HysteriaSettings = class extends Inbound.Settings {
Inbound.HysteriaSettings.Hysteria = class extends Inbound.ClientBase {
constructor(
auth = RandomUtil.randomSeq(10),
- email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
+ email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota,
) {
- super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
+ super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at, shareQuota);
this.auth = auth;
}
diff --git a/web/html/form/client.html b/web/html/form/client.html
index 9c6a7f8a..44866be4 100644
--- a/web/html/form/client.html
+++ b/web/html/form/client.html
@@ -108,6 +108,18 @@
+
+
+
+
+ {{ i18n "pages.inbounds.shareQuotaDesc" }}
+
+ {{ i18n "pages.inbounds.shareQuota" }}
+
+
+
+
+
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index b8485702..0f55c6c9 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -1183,7 +1183,89 @@
this.ipLimitEnable = ipLimitEnable;
}
},
+ applyShareQuotaPooling(dbInbounds) {
+ // For every client that opted into shareQuota and has a subId, replace
+ // its clientStats up/down/allTime/total/expiryTime with values pooled
+ // across every sibling sharing the same subId. Existing rendering
+ // helpers (getUpStats, statsProgress, ...) then show the pooled totals
+ // without further changes.
+ //
+ // Note: enable is intentionally NOT pooled so an admin who disables a
+ // single sibling doesn't visually disable the others; depletion-driven
+ // disables are already applied per-row by the backend to every group
+ // member.
+ //
+ // The original per-row values are stashed under `_sharedQuotaRaw*` on
+ // first mutation so repeated calls on the same objects stay idempotent
+ // (no double-summing if setInbounds runs twice against the same array).
+ const readRaw = (stats, key) => {
+ const rawKey = '_sharedQuotaRaw_' + key;
+ return stats[rawKey] !== undefined ? stats[rawKey] : stats[key];
+ };
+ const stashRaw = (stats, key) => {
+ const rawKey = '_sharedQuotaRaw_' + key;
+ if (stats[rawKey] === undefined) stats[rawKey] = stats[key];
+ };
+ const groups = {};
+ for (const dbInbound of dbInbounds) {
+ if (!dbInbound.settings || dbInbound.settings.length === 0) continue;
+ let parsed;
+ try { parsed = JSON.parse(dbInbound.settings); } catch (_) { continue; }
+ const clients = parsed && parsed.clients;
+ if (!Array.isArray(clients)) continue;
+ for (const c of clients) {
+ if (!c || !c.shareQuota || !c.subId || !c.email) continue;
+ if (!groups[c.subId]) groups[c.subId] = { members: [] };
+ groups[c.subId].members.push({ dbInbound, email: c.email });
+ }
+ }
+ for (const subId in groups) {
+ const g = groups[subId];
+ if (g.members.length < 1) continue;
+ let up = 0, down = 0, allTime = 0;
+ let total = 0, totalUnlimited = false;
+ let expiryTime = 0, expiryNever = false, expirySet = false;
+ const rows = [];
+ for (const m of g.members) {
+ const stats = (m.dbInbound.clientStats || []).find(r => r.email === m.email);
+ if (!stats) continue;
+ rows.push(stats);
+ const rawUp = readRaw(stats, 'up');
+ const rawDown = readRaw(stats, 'down');
+ const rawAllTime = readRaw(stats, 'allTime');
+ const rawTotal = readRaw(stats, 'total');
+ const rawExpiry = readRaw(stats, 'expiryTime');
+ up += rawUp || 0;
+ down += rawDown || 0;
+ allTime += rawAllTime || 0;
+ if (rawTotal === 0) totalUnlimited = true;
+ else if (rawTotal > total) total = rawTotal;
+ if (rawExpiry === 0) expiryNever = true;
+ else {
+ if (!expirySet || rawExpiry < expiryTime) {
+ expiryTime = rawExpiry;
+ expirySet = true;
+ }
+ }
+ }
+ const pooledTotal = totalUnlimited ? 0 : total;
+ const pooledExpiry = expiryNever ? 0 : (expirySet ? expiryTime : 0);
+ for (const r of rows) {
+ stashRaw(r, 'up');
+ stashRaw(r, 'down');
+ stashRaw(r, 'allTime');
+ stashRaw(r, 'total');
+ stashRaw(r, 'expiryTime');
+ r.up = up;
+ r.down = down;
+ r.allTime = allTime;
+ r.total = pooledTotal;
+ r.expiryTime = pooledExpiry;
+ }
+ }
+ },
setInbounds(dbInbounds) {
+ this.applyShareQuotaPooling(dbInbounds);
this.inbounds.splice(0);
this.dbInbounds.splice(0);
this.clientCount.splice(0);
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 7d5d8932..3f764f3f 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -722,6 +722,49 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
}()
+ // Coerce any incoming share-quota clients to the canonical values of an
+ // existing group (same subId, shareQuota=true), so a new arrival doesn't
+ // overwrite siblings' quota/expiry. Mutate both the typed slice and the JSON
+ // interface slice that gets persisted.
+ for i := range clients {
+ c := &clients[i]
+ if !c.ShareQuota || c.SubID == "" {
+ continue
+ }
+ var (
+ groupTotal int64
+ groupExpiry int64
+ exists bool
+ )
+ groupTotal, groupExpiry, exists, err = s.shareQuotaGroupValues(tx, c.SubID, c.Email)
+ if err != nil {
+ return false, err
+ }
+ if !exists {
+ continue
+ }
+ c.TotalGB = groupTotal
+ c.ExpiryTime = groupExpiry
+ for j := range interfaceClients {
+ cm, ok := interfaceClients[j].(map[string]any)
+ if !ok {
+ continue
+ }
+ if cm["email"] == c.Email {
+ cm["totalGB"] = groupTotal
+ cm["expiryTime"] = groupExpiry
+ interfaceClients[j] = cm
+ }
+ }
+ }
+ // Re-marshal settings in case share-quota coercion rewrote any client values.
+ oldSettings["clients"] = oldClients
+ newSettings, err = json.MarshalIndent(oldSettings, "", " ")
+ if err != nil {
+ return false, err
+ }
+ oldInbound.Settings = string(newSettings)
+
needRestart := false
s.xrayApi.Init(p.GetAPIPort())
for _, client := range clients {
@@ -754,7 +797,20 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}
s.xrayApi.Close()
- return needRestart, tx.Save(oldInbound).Error
+ if err = tx.Save(oldInbound).Error; err != nil {
+ return needRestart, err
+ }
+
+ // After the new client rows exist, sync quota/expiry across every member of
+ // this share-quota group so they all agree.
+ for _, client := range clients {
+ if client.ShareQuota && client.SubID != "" {
+ if err = s.propagateShareQuota(tx, client.SubID, client.TotalGB, client.ExpiryTime); err != nil {
+ return needRestart, err
+ }
+ }
+ }
+ return needRestart, nil
}
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
@@ -1115,13 +1171,22 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
return false, err
}
settingsClients := oldSettings["clients"].([]any)
- // Preserve created_at and set updated_at for the replacing client
+ // Preserve created_at and set updated_at for the replacing client.
+ // Also preserve shareQuota if the incoming payload is missing the key
+ // (defensive: protects against clients running outdated JS that doesn't
+ // serialize the field).
var preservedCreated any
+ var preservedShareQuota any
+ var hadOldShareQuota bool
if clientIndex >= 0 && clientIndex < len(settingsClients) {
if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
if v, ok2 := oldMap["created_at"]; ok2 {
preservedCreated = v
}
+ if v, ok2 := oldMap["shareQuota"]; ok2 {
+ preservedShareQuota = v
+ hadOldShareQuota = true
+ }
}
}
if len(interfaceClients) > 0 {
@@ -1131,6 +1196,26 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
}
newMap["created_at"] = preservedCreated
newMap["updated_at"] = time.Now().Unix() * 1000
+ if _, hasShare := newMap["shareQuota"]; !hasShare && hadOldShareQuota {
+ // Normalise to bool; tolerate "true"/"false"/"1"/"0" strings or
+ // numeric 0/1 in case a legacy record stored a non-boolean.
+ var asBool bool
+ switch v := preservedShareQuota.(type) {
+ case bool:
+ asBool = v
+ case string:
+ asBool = strings.EqualFold(v, "true") || v == "1"
+ case float64:
+ asBool = v != 0
+ case int:
+ asBool = v != 0
+ case int64:
+ asBool = v != 0
+ }
+ newMap["shareQuota"] = asBool
+ // Keep the typed clients[0] consistent so downstream propagation sees it.
+ clients[0].ShareQuota = asBool
+ }
interfaceClients[0] = newMap
}
}
@@ -1219,7 +1304,17 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
logger.Debug("Client old email not found")
needRestart = true
}
- return needRestart, tx.Save(oldInbound).Error
+ if err = tx.Save(oldInbound).Error; err != nil {
+ return needRestart, err
+ }
+ // If the saved client opted into share-quota, propagate its totalGB/expiryTime
+ // to every sibling with the same subId that also has shareQuota enabled.
+ if clients[0].ShareQuota && clients[0].SubID != "" {
+ if err = s.propagateShareQuota(tx, clients[0].SubID, clients[0].TotalGB, clients[0].ExpiryTime); err != nil {
+ return needRestart, err
+ }
+ }
+ return needRestart, nil
}
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
@@ -1536,22 +1631,343 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
return needRestart, count, err
}
+// shareQuotaMember describes a single client participating in a shared-quota group.
+type shareQuotaMember struct {
+ Email string
+ InboundId int
+ Tag string
+}
+
+// shareQuotaGroup pools totalGB and expiryTime across clients sharing the same subId
+// and having ShareQuota enabled. MaxTotal=0 means unlimited; MinExpiry=0 means never.
+type shareQuotaGroup struct {
+ Members []shareQuotaMember
+ TotalUp int64
+ TotalDown int64
+ MaxTotal int64
+ MinExpiry int64
+}
+
+// computeShareQuotaGroups walks every inbound's settings JSON, collects clients
+// with shareQuota==true && subId!="", groups them by subId, and joins with client_traffics
+// to accumulate pooled usage. Keyed by subId. Disabled inbounds are included so
+// a re-enable doesn't reset accounting.
+func (s *InboundService) computeShareQuotaGroups(tx *gorm.DB) (map[string]*shareQuotaGroup, error) {
+ var inbounds []*model.Inbound
+ err := tx.Model(model.Inbound{}).Find(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+
+ groups := make(map[string]*shareQuotaGroup)
+ type memberMeta struct {
+ member shareQuotaMember
+ total int64
+ expiry int64
+ }
+ staged := make(map[string][]memberMeta)
+
+ for _, inbound := range inbounds {
+ clients, err := s.GetClients(inbound)
+ if err != nil || clients == nil {
+ continue
+ }
+ for _, c := range clients {
+ if !c.ShareQuota || c.SubID == "" || c.Email == "" {
+ continue
+ }
+ staged[c.SubID] = append(staged[c.SubID], memberMeta{
+ member: shareQuotaMember{Email: c.Email, InboundId: inbound.Id, Tag: inbound.Tag},
+ total: c.TotalGB,
+ expiry: c.ExpiryTime,
+ })
+ }
+ }
+
+ if len(staged) == 0 {
+ return groups, nil
+ }
+
+ // Collect every email in any group so we can fetch traffic in a single query.
+ emails := make([]string, 0)
+ for _, members := range staged {
+ for _, m := range members {
+ emails = append(emails, m.member.Email)
+ }
+ }
+ var trafficRows []xray.ClientTraffic
+ if len(emails) > 0 {
+ if err := tx.Model(xray.ClientTraffic{}).Where("email IN (?)", emails).Find(&trafficRows).Error; err != nil {
+ return nil, err
+ }
+ }
+ usage := make(map[string]*xray.ClientTraffic, len(trafficRows))
+ for i := range trafficRows {
+ usage[trafficRows[i].Email] = &trafficRows[i]
+ }
+
+ for subId, members := range staged {
+ if len(members) < 1 {
+ continue
+ }
+ g := &shareQuotaGroup{}
+ // Semantics: MaxTotal==0 means unlimited. If any member is unlimited (total==0)
+ // the group is unlimited; otherwise MaxTotal = max of member totals.
+ // Same pattern for MinExpiry: 0 means never.
+ var maxTotal int64
+ maxTotalSeen := false
+ anyUnlimited := false
+ var minExpiry int64
+ minExpirySeen := false
+ anyNever := false
+ for _, m := range members {
+ g.Members = append(g.Members, m.member)
+ if m.total == 0 {
+ anyUnlimited = true
+ } else if !maxTotalSeen || m.total > maxTotal {
+ maxTotal = m.total
+ maxTotalSeen = true
+ }
+ if m.expiry == 0 {
+ anyNever = true
+ } else if !minExpirySeen || m.expiry < minExpiry {
+ minExpiry = m.expiry
+ minExpirySeen = true
+ }
+ // Accumulate usage from client_traffics.
+ if t, ok := usage[m.member.Email]; ok {
+ g.TotalUp += t.Up
+ g.TotalDown += t.Down
+ }
+ }
+ if anyUnlimited {
+ g.MaxTotal = 0
+ } else {
+ g.MaxTotal = maxTotal
+ }
+ if anyNever {
+ g.MinExpiry = 0
+ } else {
+ g.MinExpiry = minExpiry
+ }
+ groups[subId] = g
+ }
+
+ return groups, nil
+}
+
+// propagateShareQuota keeps totalGB and expiryTime in sync across every client with a
+// matching subId where shareQuota is true. Writes both inbounds.settings JSON and the
+// affected client_traffics rows inside the provided transaction.
+func (s *InboundService) propagateShareQuota(tx *gorm.DB, subId string, totalGB, expiryTime int64) error {
+ if subId == "" {
+ return nil
+ }
+
+ var inbounds []*model.Inbound
+ err := tx.Model(model.Inbound{}).Where(`id in (
+ SELECT DISTINCT inbounds.id
+ FROM inbounds,
+ JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
+ WHERE JSON_EXTRACT(client.value, '$.subId') = ?
+ AND (JSON_EXTRACT(client.value, '$.shareQuota') = 1
+ OR JSON_EXTRACT(client.value, '$.shareQuota') = 'true'
+ OR JSON_EXTRACT(client.value, '$.shareQuota') = TRUE)
+ )`, subId).Find(&inbounds).Error
+ if err != nil {
+ return err
+ }
+ if len(inbounds) == 0 {
+ return nil
+ }
+
+ nowMs := time.Now().Unix() * 1000
+ touchedEmails := make([]string, 0)
+
+ for ibIdx := range inbounds {
+ var settings map[string]any
+ if err := json.Unmarshal([]byte(inbounds[ibIdx].Settings), &settings); err != nil {
+ return err
+ }
+ rawClients, ok := settings["clients"].([]any)
+ if !ok {
+ continue
+ }
+ changed := false
+ for cIdx := range rawClients {
+ cm, ok := rawClients[cIdx].(map[string]any)
+ if !ok {
+ continue
+ }
+ // Type-safe field checks: JSON numbers unmarshal as float64.
+ cSub, _ := cm["subId"].(string)
+ cShare, _ := cm["shareQuota"].(bool)
+ if cSub != subId || !cShare {
+ continue
+ }
+ cm["totalGB"] = totalGB
+ cm["expiryTime"] = expiryTime
+ cm["updated_at"] = nowMs
+ rawClients[cIdx] = cm
+ changed = true
+ if email, ok := cm["email"].(string); ok && email != "" {
+ touchedEmails = append(touchedEmails, email)
+ }
+ }
+ if !changed {
+ continue
+ }
+ settings["clients"] = rawClients
+ newSettings, err := json.MarshalIndent(settings, "", " ")
+ if err != nil {
+ return err
+ }
+ inbounds[ibIdx].Settings = string(newSettings)
+ if err := tx.Save(inbounds[ibIdx]).Error; err != nil {
+ return err
+ }
+ }
+
+ if len(touchedEmails) == 0 {
+ return nil
+ }
+ return tx.Model(xray.ClientTraffic{}).
+ Where("email IN (?)", touchedEmails).
+ Updates(map[string]any{
+ "total": totalGB,
+ "expiry_time": expiryTime,
+ }).Error
+}
+
+// shareQuotaGroupValues returns the canonical totalGB/expiryTime of an existing group
+// (same subId, shareQuota=true) if one exists, and a bool indicating whether it exists.
+// Used so that a client joining an existing group adopts the group's values instead of
+// resetting every sibling to the new arrival's values.
+func (s *InboundService) shareQuotaGroupValues(tx *gorm.DB, subId string, excludeEmail string) (totalGB int64, expiryTime int64, exists bool, err error) {
+ if subId == "" {
+ return 0, 0, false, nil
+ }
+ var inbounds []*model.Inbound
+ err = tx.Model(model.Inbound{}).Where(`id in (
+ SELECT DISTINCT inbounds.id
+ FROM inbounds,
+ JSON_EACH(JSON_EXTRACT(inbounds.settings, '$.clients')) AS client
+ WHERE JSON_EXTRACT(client.value, '$.subId') = ?
+ AND (JSON_EXTRACT(client.value, '$.shareQuota') = 1
+ OR JSON_EXTRACT(client.value, '$.shareQuota') = 'true'
+ OR JSON_EXTRACT(client.value, '$.shareQuota') = TRUE)
+ )`, subId).Find(&inbounds).Error
+ if err != nil {
+ return 0, 0, false, err
+ }
+ for _, ib := range inbounds {
+ clients, errC := s.GetClients(ib)
+ if errC != nil {
+ continue
+ }
+ for _, c := range clients {
+ if c.SubID != subId || !c.ShareQuota {
+ continue
+ }
+ if excludeEmail != "" && c.Email == excludeEmail {
+ continue
+ }
+ return c.TotalGB, c.ExpiryTime, true, nil
+ }
+ }
+ return 0, 0, false, nil
+}
+
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
+ // First pass: handle share-quota groups so members are disabled together and
+ // excluded from the per-client pass below.
+ handledEmails := make(map[string]bool)
+ groups, err := s.computeShareQuotaGroups(tx)
+ if err != nil {
+ return false, 0, err
+ }
+ groupDepletedEmails := make([]string, 0)
+ if len(groups) > 0 && p != nil {
+ s.xrayApi.Init(p.GetAPIPort())
+ for _, g := range groups {
+ depleted := (g.MaxTotal > 0 && g.TotalUp+g.TotalDown >= g.MaxTotal) ||
+ (g.MinExpiry > 0 && g.MinExpiry <= now)
+ if !depleted {
+ continue
+ }
+ for _, m := range g.Members {
+ if handledEmails[m.Email] {
+ continue
+ }
+ err1 := s.xrayApi.RemoveUser(m.Tag, m.Email)
+ if err1 == nil {
+ logger.Debug("Shared-quota client disabled by api:", m.Email)
+ } else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", m.Email)) {
+ logger.Debug("Shared-quota user already disabled. Nothing to do.")
+ } else {
+ logger.Debug("Error disabling shared-quota client by api:", err1)
+ needRestart = true
+ }
+ handledEmails[m.Email] = true
+ groupDepletedEmails = append(groupDepletedEmails, m.Email)
+ }
+ }
+ s.xrayApi.Close()
+ } else if len(groups) > 0 {
+ // No p (Xray process) available — still collect emails so DB state is consistent.
+ for _, g := range groups {
+ depleted := (g.MaxTotal > 0 && g.TotalUp+g.TotalDown >= g.MaxTotal) ||
+ (g.MinExpiry > 0 && g.MinExpiry <= now)
+ if !depleted {
+ continue
+ }
+ for _, m := range g.Members {
+ if handledEmails[m.Email] {
+ continue
+ }
+ handledEmails[m.Email] = true
+ groupDepletedEmails = append(groupDepletedEmails, m.Email)
+ }
+ }
+ }
+ if len(groupDepletedEmails) > 0 {
+ if err := tx.Model(xray.ClientTraffic{}).
+ Where("email IN (?)", groupDepletedEmails).
+ Update("enable", false).Error; err != nil {
+ return false, 0, err
+ }
+ }
+
+ // Build exclusion list for the per-client pass. Also exclude every email in any
+ // non-depleted share-quota group — their quota is pooled and mustn't be judged
+ // individually on the single-row thresholds.
+ excludedEmails := make([]string, 0)
+ for _, g := range groups {
+ for _, m := range g.Members {
+ if !handledEmails[m.Email] {
+ excludedEmails = append(excludedEmails, m.Email)
+ }
+ }
+ }
+ excludedEmails = append(excludedEmails, groupDepletedEmails...)
+
if p != nil {
var results []struct {
Tag string
Email string
}
- err := tx.Table("inbounds").
+ q := tx.Table("inbounds").
Select("inbounds.tag, client_traffics.email").
Joins("JOIN client_traffics ON inbounds.id = client_traffics.inbound_id").
- Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true).
- Scan(&results).Error
- if err != nil {
+ Where("((client_traffics.total > 0 AND client_traffics.up + client_traffics.down >= client_traffics.total) OR (client_traffics.expiry_time > 0 AND client_traffics.expiry_time <= ?)) AND client_traffics.enable = ?", now, true)
+ if len(excludedEmails) > 0 {
+ q = q.Where("client_traffics.email NOT IN (?)", excludedEmails)
+ }
+ if err := q.Scan(&results).Error; err != nil {
return false, 0, err
}
s.xrayApi.Init(p.GetAPIPort())
@@ -1563,22 +1979,21 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
- if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", result.Email)) {
- logger.Debug("User is already disabled. Nothing to do more...")
- } else {
- logger.Debug("Error in disabling client by api:", err1)
- needRestart = true
- }
+ logger.Debug("Error in disabling client by api:", err1)
+ needRestart = true
}
}
}
s.xrayApi.Close()
}
- result := tx.Model(xray.ClientTraffic{}).
- Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
- Update("enable", false)
- err := result.Error
- count := result.RowsAffected
+ updateQ := tx.Model(xray.ClientTraffic{}).
+ Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true)
+ if len(excludedEmails) > 0 {
+ updateQ = updateQ.Where("email NOT IN (?)", excludedEmails)
+ }
+ result := updateQ.Update("enable", false)
+ err = result.Error
+ count := result.RowsAffected + int64(len(groupDepletedEmails))
return needRestart, count, err
}
diff --git a/web/translation/translate.ar_EG.toml b/web/translation/translate.ar_EG.toml
index ca7076c8..e471d993 100644
--- a/web/translation/translate.ar_EG.toml
+++ b/web/translation/translate.ar_EG.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "استخدم شهادة البانل"
"telegramDesc" = "ادخل ID شات Telegram. (استخدم '/id' في البوت) أو (@userinfobot)"
"subscriptionDesc" = "عشان تلاقي رابط الاشتراك، ادخل على 'التفاصيل'. وكمان ممكن تستخدم نفس الاسم لعدة عملاء."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "معلومات"
"same" = "نفسه"
"inboundData" = "بيانات الإدخال"
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
index 45186187..a5c618fc 100644
--- a/web/translation/translate.en_US.toml
+++ b/web/translation/translate.en_US.toml
@@ -281,6 +281,8 @@
"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."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "Same"
"inboundData" = "Inbound's Data"
diff --git a/web/translation/translate.es_ES.toml b/web/translation/translate.es_ES.toml
index d8f94461..549e0750 100644
--- a/web/translation/translate.es_ES.toml
+++ b/web/translation/translate.es_ES.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Establecer certificado desde el panel"
"telegramDesc" = "Por favor, proporciona el ID de Chat de Telegram. (usa el comando '/id' en el bot) o (@userinfobot)"
"subscriptionDesc" = "Puedes encontrar tu enlace de suscripción en Detalles, también puedes usar el mismo nombre para varias configuraciones."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "misma"
"inboundData" = "Datos de entrada"
diff --git a/web/translation/translate.fa_IR.toml b/web/translation/translate.fa_IR.toml
index aaec75f6..cc953f20 100644
--- a/web/translation/translate.fa_IR.toml
+++ b/web/translation/translate.fa_IR.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "استفاده از گواهی پنل"
"telegramDesc" = "لطفا شناسه گفتگوی تلگرام را وارد کنید. (از دستور '/id' در ربات استفاده کنید) یا (@userinfobot)"
"subscriptionDesc" = "شما میتوانید لینک سابسکربپشن خودرا در 'جزئیات' پیدا کنید، همچنین میتوانید از همین نام برای چندین کاربر استفادهکنید"
+"shareQuota" = "اشتراک حجم بین اینباندها"
+"shareQuotaDesc" = "مقدار حجم و تاریخ انقضا با سایر کلاینتهایی که همین subId را دارند و گزینه اشتراک حجمشان فعال است ترکیب میشود."
"info" = "اطلاعات"
"same" = "همسان"
"inboundData" = "دادههای ورودی"
diff --git a/web/translation/translate.id_ID.toml b/web/translation/translate.id_ID.toml
index e115aaa8..ae7d327b 100644
--- a/web/translation/translate.id_ID.toml
+++ b/web/translation/translate.id_ID.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Atur Sertifikat dari Panel"
"telegramDesc" = "Harap berikan ID Obrolan Telegram. (gunakan perintah '/id' di bot) atau (@userinfobot)"
"subscriptionDesc" = "Untuk menemukan URL langganan Anda, buka 'Rincian'. Selain itu, Anda dapat menggunakan nama yang sama untuk beberapa klien."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Info"
"same" = "Sama"
"inboundData" = "Data Masuk"
diff --git a/web/translation/translate.ja_JP.toml b/web/translation/translate.ja_JP.toml
index ffa9168b..800d73fe 100644
--- a/web/translation/translate.ja_JP.toml
+++ b/web/translation/translate.ja_JP.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "パネル設定から証明書を設定"
"telegramDesc" = "TelegramチャットIDを提供してください。(ボットで'/id'コマンドを使用)または(@userinfobot)"
"subscriptionDesc" = "サブスクリプションURLを見つけるには、“詳細情報”に移動してください。また、複数のクライアントに同じ名前を使用することができます。"
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "情報"
"same" = "同じ"
"inboundData" = "インバウンドデータ"
diff --git a/web/translation/translate.pt_BR.toml b/web/translation/translate.pt_BR.toml
index 18db4d62..69f4b859 100644
--- a/web/translation/translate.pt_BR.toml
+++ b/web/translation/translate.pt_BR.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Definir Certificado pelo Painel"
"telegramDesc" = "Por favor, forneça o ID do Chat do Telegram. (use o comando '/id' no bot) ou (@userinfobot)"
"subscriptionDesc" = "Para encontrar seu URL de assinatura, navegue até 'Detalhes'. Além disso, você pode usar o mesmo nome para vários clientes."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Informações"
"same" = "Igual"
"inboundData" = "Dados do Inbound"
diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml
index 5bf89dfd..aa22d1de 100644
--- a/web/translation/translate.ru_RU.toml
+++ b/web/translation/translate.ru_RU.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Установить сертификат панели"
"telegramDesc" = "Пожалуйста, укажите Chat ID Telegram. (используйте команду '/id' в боте) или (@userinfobot)"
"subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'"
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Информация"
"same" = "Тот же"
"inboundData" = "Данные подключений"
diff --git a/web/translation/translate.tr_TR.toml b/web/translation/translate.tr_TR.toml
index 0393a7dc..d3d9beb5 100644
--- a/web/translation/translate.tr_TR.toml
+++ b/web/translation/translate.tr_TR.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Panelden Sertifikayı Ayarla"
"telegramDesc" = "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya (@userinfobot)"
"subscriptionDesc" = "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Bilgi"
"same" = "Aynı"
"inboundData" = "Gelenin Verileri"
diff --git a/web/translation/translate.uk_UA.toml b/web/translation/translate.uk_UA.toml
index 40bcaa76..45a3e698 100644
--- a/web/translation/translate.uk_UA.toml
+++ b/web/translation/translate.uk_UA.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Установити сертифікат з панелі"
"telegramDesc" = "Будь ласка, вкажіть ID чату Telegram. (використовуйте команду '/id' у боті) або (@userinfobot)"
"subscriptionDesc" = "Щоб знайти URL-адресу вашої підписки, перейдіть до «Деталі». Крім того, ви можете використовувати одне ім'я для кількох клієнтів."
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Інформація"
"same" = "Те саме"
"inboundData" = "Вхідні дані"
diff --git a/web/translation/translate.vi_VN.toml b/web/translation/translate.vi_VN.toml
index 43afe89b..df38bc62 100644
--- a/web/translation/translate.vi_VN.toml
+++ b/web/translation/translate.vi_VN.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "Đặt chứng chỉ từ bảng điều khiển"
"telegramDesc" = "Vui lòng cung cấp ID Trò chuyện Telegram. (sử dụng lệnh '/id' trong bot) hoặc (@userinfobot)"
"subscriptionDesc" = "Bạn có thể tìm liên kết gói đăng ký của mình trong Chi tiết, cũng như bạn có thể sử dụng cùng tên cho nhiều cấu hình khác nhau"
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "Thông tin"
"same" = "Giống nhau"
"inboundData" = "Dữ liệu gửi đến"
diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml
index cb42afce..b4ace655 100644
--- a/web/translation/translate.zh_CN.toml
+++ b/web/translation/translate.zh_CN.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "从面板设置证书"
"telegramDesc" = "请提供Telegram聊天ID。(在机器人中使用'/id'命令)或(@userinfobot"
"subscriptionDesc" = "要找到你的订阅 URL,请导航到“详细信息”。此外,你可以为多个客户端使用相同的名称。"
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "信息"
"same" = "相同"
"inboundData" = "入站数据"
diff --git a/web/translation/translate.zh_TW.toml b/web/translation/translate.zh_TW.toml
index 551ebbd0..69141677 100644
--- a/web/translation/translate.zh_TW.toml
+++ b/web/translation/translate.zh_TW.toml
@@ -281,6 +281,8 @@
"setDefaultCert" = "從面板設定證書"
"telegramDesc" = "請提供Telegram聊天ID。(在機器人中使用'/id'命令)或(@userinfobot"
"subscriptionDesc" = "要找到你的訂閱 URL,請導航到“詳細資訊”。此外,你可以為多個客戶端使用相同的名稱。"
+"shareQuota" = "Share quota across inbounds"
+"shareQuotaDesc" = "Pool totalGB and expiry time with every other client that has the same subId and shareQuota enabled."
"info" = "資訊"
"same" = "相同"
"inboundData" = "入站資料"