3x-ui/web/service/client.go

1960 lines
49 KiB
Go
Raw Normal View History

package service
import (
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
"context"
"encoding/base64"
"encoding/json"
"errors"
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/util/common"
"github.com/mhsanaei/3x-ui/v3/util/random"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
)
type ClientWithAttachments struct {
model.ClientRecord
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}
// MarshalJSON is required because model.ClientRecord defines its own
// MarshalJSON. Go promotes the embedded method to the outer struct, so without
// this the encoder would call ClientRecord.MarshalJSON for the whole value and
// silently drop InboundIds and Traffic from the API response.
func (c ClientWithAttachments) MarshalJSON() ([]byte, error) {
rec, err := json.Marshal(c.ClientRecord)
if err != nil {
return nil, err
}
extras := struct {
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}{InboundIds: c.InboundIds, Traffic: c.Traffic}
extra, err := json.Marshal(extras)
if err != nil {
return nil, err
}
if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 {
return rec, nil
}
out := make([]byte, 0, len(rec)+len(extra))
out = append(out, rec[:len(rec)-1]...)
if len(rec) > 2 {
out = append(out, ',')
}
out = append(out, extra[1:]...)
return out, nil
}
func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string {
if rec == nil {
return ""
}
switch p {
case model.Trojan:
return rec.Password
case model.Shadowsocks:
return rec.Email
case model.Hysteria, model.Hysteria2:
return rec.Auth
default:
return rec.UUID
}
}
type ClientService struct{}
// Short-lived tombstone of just-deleted client emails so that a node snapshot
// arriving between delete and node-side processing doesn't resurrect them.
var (
recentlyDeletedMu sync.Mutex
recentlyDeleted = map[string]time.Time{}
)
const deleteTombstoneTTL = 90 * time.Second
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
var (
inboundMutationLocksMu sync.Mutex
inboundMutationLocks = map[int]*sync.Mutex{}
)
func lockInbound(inboundId int) *sync.Mutex {
inboundMutationLocksMu.Lock()
defer inboundMutationLocksMu.Unlock()
m, ok := inboundMutationLocks[inboundId]
if !ok {
m = &sync.Mutex{}
inboundMutationLocks[inboundId] = m
}
m.Lock()
return m
}
func compactOrphans(db *gorm.DB, clients []any) []any {
if len(clients) == 0 {
return clients
}
emails := make([]string, 0, len(clients))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if e, _ := cm["email"].(string); e != "" {
emails = append(emails, e)
}
}
if len(emails) == 0 {
return clients
}
var existingEmails []string
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails).Pluck("email", &existingEmails).Error; err != nil {
logger.Warning("compactOrphans pluck:", err)
return clients
}
if len(existingEmails) == len(emails) {
return clients
}
existing := make(map[string]struct{}, len(existingEmails))
for _, e := range existingEmails {
existing[e] = struct{}{}
}
out := make([]any, 0, len(existingEmails))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
out = append(out, c)
continue
}
e, _ := cm["email"].(string)
if e == "" {
out = append(out, c)
continue
}
if _, ok := existing[e]; ok {
out = append(out, c)
}
}
return out
}
func tombstoneClientEmail(email string) {
if email == "" {
return
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
recentlyDeleted[email] = time.Now()
cutoff := time.Now().Add(-deleteTombstoneTTL)
for e, ts := range recentlyDeleted {
if ts.Before(cutoff) {
delete(recentlyDeleted, e)
}
}
}
func isClientEmailTombstoned(email string) bool {
if email == "" {
return false
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
ts, ok := recentlyDeleted[email]
if !ok {
return false
}
if time.Since(ts) > deleteTombstoneTTL {
delete(recentlyDeleted, email)
return false
}
return true
}
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
if tx == nil {
tx = database.GetDB()
}
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
for i := range clients {
c := clients[i]
email := strings.TrimSpace(c.Email)
if email == "" {
continue
}
incoming := c.ToRecord()
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if errors.Is(err, gorm.ErrRecordNotFound) {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
if isClientEmailTombstoned(email) {
continue
}
if err := tx.Create(incoming).Error; err != nil {
return err
}
row = incoming
} else {
row.UUID = incoming.UUID
row.Password = incoming.Password
row.Auth = incoming.Auth
row.Flow = incoming.Flow
row.Security = incoming.Security
row.Reverse = incoming.Reverse
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
row.Comment = incoming.Comment
row.Reset = incoming.Reset
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt
}
if incoming.UpdatedAt > row.UpdatedAt {
row.UpdatedAt = incoming.UpdatedAt
}
if err := tx.Save(row).Error; err != nil {
return err
}
}
link := model.ClientInbound{
ClientId: row.Id,
InboundId: inboundId,
FlowOverride: c.Flow,
}
if err := tx.Create(&link).Error; err != nil {
return err
}
}
return nil
}
func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
if tx == nil {
tx = database.GetDB()
}
return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
}
func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", inboundId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
if rows[i].FlowOverride != "" {
c.Flow = rows[i].FlowOverride
}
out = append(out, *c)
}
return out, nil
}
func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
if tx == nil {
tx = database.GetDB()
}
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
if tx == nil {
tx = database.GetDB()
}
var ids []int
err := tx.Table("client_inbounds").
Select("client_inbounds.inbound_id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
row := &model.ClientRecord{}
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
var ids []int
err := database.GetDB().Table("client_inbounds").
Where("client_id = ?", id).
Order("inbound_id ASC").
Pluck("inbound_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []ClientWithAttachments{}, nil
}
clientIds := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows {
clientIds = append(clientIds, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
}
var links []model.ClientInbound
if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil {
return nil, err
}
attachments := make(map[int][]int, len(rows))
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil {
return nil, err
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
out := make([]ClientWithAttachments, 0, len(rows))
for i := range rows {
out = append(out, ClientWithAttachments{
ClientRecord: rows[i],
InboundIds: attachments[rows[i].Id],
Traffic: trafficByEmail[rows[i].Email],
})
}
return out, nil
}
type ClientCreatePayload struct {
Client model.Client `json:"client"`
InboundIds []int `json:"inboundIds"`
}
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
if payload == nil {
return false, common.NewError("empty payload")
}
client := payload.Client
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if !client.Enable {
client.Enable = true
}
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
existing := &model.ClientRecord{}
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
if emailTaken {
if existing.SubID == "" || existing.SubID != client.SubID {
return false, common.NewError("email already in use:", client.Email)
}
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}})
if mErr != nil {
return needRestart, mErr
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
switch ib.Protocol {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan:
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.Shadowsocks:
method := shadowsocksMethodFromSettings(ib.Settings)
if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
c.Password = randomShadowsocksClientKey(method)
}
case model.Hysteria, model.Hysteria2:
if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
return nil
}
// shadowsocksMethodFromSettings pulls the "method" field out of the inbound's
// settings JSON. Returns "" when the field is missing or settings is invalid.
func shadowsocksMethodFromSettings(settings string) string {
if settings == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal([]byte(settings), &m); err != nil {
return ""
}
method, _ := m["method"].(string)
return method
}
// randomShadowsocksClientKey returns a per-client key sized to the cipher.
// The 2022-blake3 ciphers require a base64-encoded key of an exact byte
// length (16 bytes for aes-128-gcm, 32 bytes for aes-256-gcm and
// chacha20-poly1305) — anything else fails with "bad key" on xray start.
// Older ciphers accept arbitrary passwords, so we keep the uuid-style.
func randomShadowsocksClientKey(method string) string {
if n := shadowsocksKeyBytes(method); n > 0 {
return random.Base64Bytes(n)
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
// validShadowsocksClientKey reports whether key is acceptable for the cipher.
// For 2022-blake3 it must decode to the exact byte length the cipher needs;
// any other method accepts any non-empty string.
func validShadowsocksClientKey(method, key string) bool {
n := shadowsocksKeyBytes(method)
if n == 0 {
return key != ""
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return false
}
return len(decoded) == n
}
func shadowsocksKeyBytes(method string) int {
switch method {
case "2022-blake3-aes-128-gcm":
return 16
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
return 32
}
return 0
}
// applyShadowsocksClientMethod ensures each client entry carries a "method"
// field for legacy shadowsocks ciphers. xray's multi-user shadowsocks code
// requires a per-client method; an empty/missing field fails with
// "unsupported cipher method:". 2022-blake3 ciphers use the top-level
// method only, so the per-client field must stay absent.
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
method, _ := settings["method"].(string)
if method == "" || strings.HasPrefix(method, "2022-blake3-") {
return
}
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
}
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
}
if updated.SubID == "" {
updated.SubID = existing.SubID
}
if updated.SubID == "" {
updated.SubID = uuid.NewString()
}
updated.UpdatedAt = time.Now().UnixMilli()
if updated.CreatedAt == 0 {
updated.CreatedAt = existing.CreatedAt
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
oldKey := clientKeyForProtocol(inbound.Protocol, existing)
if oldKey == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}})
if mErr != nil {
return needRestart, mErr
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, oldKey)
if upErr != nil {
return needRestart, upErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
tombstoneClientEmail(existing.Email)
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
}
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
clientWire := existing.ToClient()
clientWire.UpdatedAt = time.Now().UnixMilli()
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}})
if mErr != nil {
return needRestart, mErr
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
return s.Create(inboundSvc, &ClientCreatePayload{
Client: client,
InboundIds: []int{inboundId},
})
}
func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
}
func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Attach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Delete(inboundSvc, rec.Id, keepTraffic)
}
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Update(inboundSvc, rec.Id, updated)
}
refactor(api): move every client-shaped endpoint off /inbounds onto /clients After the multi-inbound client migration, client state belongs to the client API surface, not the inbound one. Twelve routes that were crammed under /panel/api/inbounds/* now live where they belong, under /panel/api/clients/*. Moved (route, handler, doc): POST /clientIps/:email POST /clearClientIps/:email POST /onlines POST /lastOnline POST /updateClientTraffic/:email POST /resetAllClientTraffics/:id POST /delDepletedClients/:id POST /:id/resetClientTraffic/:email GET /getClientTraffics/:email GET /getClientTrafficsById/:id GET /getSubLinks/:subId GET /getClientLinks/:id/:email Their /clients/* counterparts are: POST /clients/clientIps/:email POST /clients/clearClientIps/:email POST /clients/onlines POST /clients/lastOnline POST /clients/updateTraffic/:email POST /clients/resetTraffic/:email (email-only, fans out) GET /clients/traffic/:email GET /clients/traffic/byId/:id GET /clients/subLinks/:subId GET /clients/links/:id/:email per-inbound resetAllClientTraffics and delDepletedClients are dropped entirely — the Clients page already exposes global Reset All Traffic and Delete depleted actions, and per-inbound resets are meaningless once a client can be attached to many inbounds. ClientService.ResetTrafficByEmail is the new email-only reset path: it looks up every inbound the client is attached to and pushes the counter reset + Xray re-add through inboundService.ResetClientTraffic for each one, so depleted users come back online instantly. Frontend callers (ClientsPage, useClients, ClientQrModal, ClientInfoModal, InboundInfoModal, InboundsPage, useInbounds) all switched to the new paths. The Inbounds page drops its per-inbound "Reset client traffic" and "Delete depleted clients" dropdown items — users do those at the client level now. api-docs is rebuilt to match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:15:01 +00:00
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
if err != nil {
return false, err
}
if len(inboundIds) == 0 {
if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
return false, rErr
}
return false, nil
}
needRestart := false
for _, ibId := range inboundIds {
nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
if rErr != nil {
return needRestart, rErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) DelDepleted(inboundSvc *InboundService) (int, bool, error) {
db := database.GetDB()
now := time.Now().UnixMilli()
depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
var rows []xray.ClientTraffic
if err := db.Where(depletedClause, now).Find(&rows).Error; err != nil {
return 0, false, err
}
if len(rows) == 0 {
return 0, false, nil
}
emails := make(map[string]struct{}, len(rows))
for _, r := range rows {
if r.Email != "" {
emails[r.Email] = struct{}{}
}
}
needRestart := false
deleted := 0
for email := range emails {
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
continue
}
return deleted, needRestart, err
}
nr, err := s.Delete(inboundSvc, rec.Id, false)
if err != nil {
return deleted, needRestart, err
}
if nr {
needRestart = true
}
deleted++
}
return deleted, needRestart, nil
}
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
return submitTrafficWrite(func() error {
return s.resetAllClientTrafficsLocked(id)
})
}
func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
db := database.GetDB()
now := time.Now().Unix() * 1000
if err := db.Transaction(func(tx *gorm.DB) error {
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
result := tx.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if result.Error != nil {
return result.Error
}
inboundWhereText := "id "
if id == -1 {
inboundWhereText += " > ?"
} else {
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
return result.Error
}); err != nil {
return err
}
return nil
}
func (s *ClientService) ResetAllTraffics() (bool, error) {
res := database.GetDB().Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"up": 0, "down": 0})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; !attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
key := clientKeyForProtocol(inbound.Protocol, existing)
if key == "" {
continue
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
nr, delErr := s.DelInboundClient(inboundSvc, ibId, key)
if delErr != nil {
return needRestart, delErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
func (s *ClientService) checkEmailsExistForClients(inboundSvc *InboundService, clients []model.Client) (string, error) {
emailSubIDs, err := inboundSvc.getAllEmailSubIDs()
if err != nil {
return "", err
}
seen := make(map[string]string, len(clients))
for _, client := range clients {
if client.Email == "" {
continue
}
key := strings.ToLower(client.Email)
if prev, ok := seen[key]; ok {
if prev != client.SubID || client.SubID == "" {
return client.Email, nil
}
continue
}
seen[key] = client.SubID
if existingSub, ok := emailSubIDs[key]; ok {
if client.SubID == "" || existingSub == "" || existingSub != client.SubID {
return client.Email, nil
}
}
}
return "", nil
}
func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model.Inbound) (bool, error) {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
defer lockInbound(data.Id).Unlock()
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
clients, err := inboundSvc.GetClients(data)
if err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
if err != nil {
return false, err
}
interfaceClients := settings["clients"].([]any)
nowTs := time.Now().Unix() * 1000
for i := range interfaceClients {
if cm, ok := interfaceClients[i].(map[string]any); ok {
if _, ok2 := cm["created_at"]; !ok2 {
cm["created_at"] = nowTs
}
cm["updated_at"] = nowTs
interfaceClients[i] = cm
}
}
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
if err != nil {
return false, err
}
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail)
}
oldInbound, err := inboundSvc.GetInbound(data.Id)
if err != nil {
return false, err
}
for _, client := range clients {
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
switch oldInbound.Protocol {
case "trojan":
if client.Password == "" {
return false, common.NewError("empty client ID")
}
case "shadowsocks":
if client.Email == "" {
return false, common.NewError("empty client ID")
}
case "hysteria", "hysteria2":
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
}
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
if err != nil {
return false, err
}
if oldInbound.Protocol == model.Shadowsocks {
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
oldClients := oldSettings["clients"].([]any)
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
oldClients = compactOrphans(database.GetDB(), oldClients)
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
oldClients = append(oldClients, interfaceClients...)
oldSettings["clients"] = oldClients
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
needRestart := false
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
if oldInbound.NodeID != nil {
err = rterr
return false, err
}
needRestart = true
} else if oldInbound.NodeID == nil {
for _, client := range clients {
if len(client.Email) == 0 {
needRestart = true
continue
}
inboundSvc.AddClientStat(tx, data.Id, &client)
if !client.Enable {
continue
}
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client added on", rt.Name(), ":", client.Email)
} else {
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
needRestart = true
}
}
} else {
for _, client := range clients {
if len(client.Email) > 0 {
inboundSvc.AddClientStat(tx, data.Id, &client)
}
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
err = err1
return false, err
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
}
}
if err = tx.Save(oldInbound).Error; err != nil {
return false, err
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
err = gcErr
return false, err
}
if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
return false, err
}
return needRestart, nil
}
func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *model.Inbound, clientId string) (bool, error) {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
defer lockInbound(data.Id).Unlock()
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
clients, err := inboundSvc.GetClients(data)
if err != nil {
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(data.Settings), &settings)
if err != nil {
return false, err
}
interfaceClients := settings["clients"].([]any)
oldInbound, err := inboundSvc.GetInbound(data.Id)
if err != nil {
return false, err
}
oldClients, err := inboundSvc.GetClients(oldInbound)
if err != nil {
return false, err
}
oldEmail := ""
newClientId := ""
clientIndex := -1
for index, oldClient := range oldClients {
oldClientId := ""
switch oldInbound.Protocol {
case "trojan":
oldClientId = oldClient.Password
newClientId = clients[0].Password
case "shadowsocks":
oldClientId = oldClient.Email
newClientId = clients[0].Email
case "hysteria", "hysteria2":
oldClientId = oldClient.Auth
newClientId = clients[0].Auth
default:
oldClientId = oldClient.ID
newClientId = clients[0].ID
}
if clientId == oldClientId {
oldEmail = oldClient.Email
clientIndex = index
break
}
}
if newClientId == "" || clientIndex == -1 {
return false, common.NewError("empty client ID")
}
if strings.TrimSpace(clients[0].Email) == "" {
return false, common.NewError("client email is required")
}
if clients[0].Email != oldEmail {
existEmail, err := s.checkEmailsExistForClients(inboundSvc, clients)
if err != nil {
return false, err
}
if existEmail != "" {
return false, common.NewError("Duplicate email:", existEmail)
}
}
var oldSettings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
if err != nil {
return false, err
}
settingsClients := oldSettings["clients"].([]any)
var preservedCreated any
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 len(interfaceClients) > 0 {
if newMap, ok := interfaceClients[0].(map[string]any); ok {
if preservedCreated == nil {
preservedCreated = time.Now().Unix() * 1000
}
newMap["created_at"] = preservedCreated
newMap["updated_at"] = time.Now().Unix() * 1000
interfaceClients[0] = newMap
}
}
if oldInbound.Protocol == model.Shadowsocks {
applyShadowsocksClientMethod(interfaceClients, oldSettings)
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
settingsClients[clientIndex] = interfaceClients[0]
oldSettings["clients"] = settingsClients
if oldInbound.Protocol == model.VLESS {
hasVisionFlow := false
for _, c := range settingsClients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
hasVisionFlow = true
break
}
}
if !hasVisionFlow {
delete(oldSettings, "testseed")
}
}
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
if len(clients[0].Email) > 0 {
if len(oldEmail) > 0 {
emailUnchanged := strings.EqualFold(oldEmail, clients[0].Email)
targetExists := int64(0)
if !emailUnchanged {
if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", clients[0].Email).Count(&targetExists).Error; err != nil {
return false, err
}
}
if emailUnchanged || targetExists == 0 {
err = inboundSvc.UpdateClientStat(tx, oldEmail, &clients[0])
if err != nil {
return false, err
}
err = inboundSvc.UpdateClientIPs(tx, oldEmail, clients[0].Email)
if err != nil {
return false, err
}
} else {
stillUsed, sErr := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
if sErr != nil {
return false, sErr
}
if !stillUsed {
if err = inboundSvc.DelClientStat(tx, oldEmail); err != nil {
return false, err
}
if err = inboundSvc.DelClientIPs(tx, oldEmail); err != nil {
return false, err
}
}
if err = inboundSvc.UpdateClientStat(tx, clients[0].Email, &clients[0]); err != nil {
return false, err
}
}
} else {
inboundSvc.AddClientStat(tx, data.Id, &clients[0])
}
} else {
stillUsed, err := inboundSvc.emailUsedByOtherInbounds(oldEmail, data.Id)
if err != nil {
return false, err
}
if !stillUsed {
err = inboundSvc.DelClientStat(tx, oldEmail)
if err != nil {
return false, err
}
err = inboundSvc.DelClientIPs(tx, oldEmail)
if err != nil {
return false, err
}
}
}
needRestart := false
if len(oldEmail) > 0 {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
if oldInbound.NodeID != nil {
err = rterr
return false, err
}
needRestart = true
} else if oldInbound.NodeID == nil {
if oldClients[clientIndex].Enable {
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
if err1 == nil {
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true
}
}
if clients[0].Enable {
cipher := ""
if oldInbound.Protocol == "shadowsocks" {
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), oldInbound, 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,
})
if err1 == nil {
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
} else {
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
needRestart = true
}
}
} else {
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
err = err1
return false, err
}
}
} else {
logger.Debug("Client old email not found")
needRestart = true
}
if err = tx.Save(oldInbound).Error; err != nil {
return false, err
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
err = gcErr
return false, err
}
if err = s.SyncInbound(tx, oldInbound.Id, finalClients); err != nil {
return false, err
}
return needRestart, nil
}
func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId int, clientId string) (bool, error) {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
defer lockInbound(inboundId).Unlock()
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
oldInbound, err := inboundSvc.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
return false, err
}
var settings map[string]any
err = json.Unmarshal([]byte(oldInbound.Settings), &settings)
if err != nil {
return false, err
}
email := ""
client_key := "id"
switch oldInbound.Protocol {
case "trojan":
client_key = "password"
case "shadowsocks":
client_key = "email"
case "hysteria", "hysteria2":
client_key = "auth"
}
interfaceClients := settings["clients"].([]any)
var newClients []any
needApiDel := false
clientFound := false
for _, client := range interfaceClients {
c := client.(map[string]any)
c_id := c[client_key].(string)
if c_id == clientId {
clientFound = true
email, _ = c["email"].(string)
needApiDel, _ = c["enable"].(bool)
} else {
newClients = append(newClients, client)
}
}
if !clientFound {
return false, common.NewError("Client Not Found In Inbound For ID:", clientId)
}
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
db := database.GetDB()
newClients = compactOrphans(db, newClients)
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
if newClients == nil {
newClients = []any{}
}
settings["clients"] = newClients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
if err != nil {
return false, err
}
if !emailShared {
err = inboundSvc.DelClientIPs(db, email)
if err != nil {
logger.Error("Error in delete client IPs")
return false, err
}
}
needRestart := false
if len(email) > 0 {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
var enables []bool
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Limit(1).Pluck("enable", &enables).Error
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
if err != nil {
logger.Error("Get stats error")
return false, err
}
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
notDepleted := len(enables) > 0 && enables[0]
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
if !emailShared {
err = inboundSvc.DelClientStat(db, email)
if err != nil {
logger.Error("Delete stats Data Error")
return false, err
}
}
if needApiDel && notDepleted && oldInbound.NodeID == nil {
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
needRestart = true
} else {
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
needRestart = false
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true
}
}
}
}
if oldInbound.NodeID != nil && len(email) > 0 {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
return false, rterr
}
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
return false, err1
}
}
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
if err := db.Save(oldInbound).Error; err != nil {
return false, err
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return false, gcErr
}
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
return false, err
}
return needRestart, nil
}
func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
defer lockInbound(inboundId).Unlock()
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
oldInbound, err := inboundSvc.GetInbound(inboundId)
if err != nil {
logger.Error("Load Old Data Error")
return false, err
}
var settings map[string]any
if err := json.Unmarshal([]byte(oldInbound.Settings), &settings); err != nil {
return false, err
}
interfaceClients, ok := settings["clients"].([]any)
if !ok {
return false, common.NewError("invalid clients format in inbound settings")
}
var newClients []any
needApiDel := false
found := false
for _, client := range interfaceClients {
c, ok := client.(map[string]any)
if !ok {
continue
}
if cEmail, ok := c["email"].(string); ok && cEmail == email {
found = true
needApiDel, _ = c["enable"].(bool)
} else {
newClients = append(newClients, client)
}
}
if !found {
return false, common.NewError(fmt.Sprintf("client with email %s not found", email))
}
fix(clients): bulk add/delete correctness + perf, working pagination, delayed-start in form Bulk add/delete were serial on the frontend (one toast per call, N round-trips) and the backend race exposed by parallelizing them lost client attachments and hit UNIQUE constraint failed on client_inbounds. The single add/edit modal also had no Start-After-First-Use option, and the table never showed the delayed duration. Backend (web/service/client.go): - Per-inbound mutex on Add/Update/Del InboundClient so concurrent writers on the same inbound don't lose the read-modify-write of settings JSON. - SyncInbound skips create+join when the email is tombstoned so a concurrent maintenance pass (adjustTraffics, autoRenewClients, markClientsDisabledIn- Settings) that did a stale RMW can't resurrect a just-deleted client with a fresh id. - compactOrphans sweeps settings.clients entries whose ClientRecord no longer exists, applied in Add/DelInboundClient + DelInboundClientByEmail so each user-initiated mutation self-heals the inbound's settings. - DelInboundClient uses Pluck instead of First for the stats lookup so a missing row doesn't abort the delete with a noisy ErrRecordNotFound log. Frontend: - HttpUtil.{get,post} accept a silent option that suppresses the auto-toast. - ClientBulkAddModal fires creates in parallel + silent + one summary toast. - useClients.removeMany runs deletes in parallel + silent and refreshes once; ClientsPage bulk delete uses it and shows one aggregate toast. - useClients.applyInvalidate debounces 200 ms so the burst of N WebSocket invalidate events from the backend collapses into a single refresh. - ClientsPage pagination is reactive (paginationState ref + tablePagination computed); onTableChange persists page-size and page changes. - ClientFormModal gains a Start-After-First-Use switch + Duration days input alongside the existing Expiry Date picker; on edit-mode open a negative expiryTime is decoded back to delayed mode + days; on submit the payload sends -86400000 * days or the absolute timestamp. - ClientsPage table shows the delayed-start duration (blue tag Nd, tooltip Start After First Use: Nd) instead of infinity. - Telegram ID field in the form is hidden when /panel/setting/defaultSettings reports tgBotEnable=false; Comment then fills the row. - Form row 3 collapses UUID (span 12) + Total GB (span 8) + Limit IP (span 4) when ipLimitEnable is on, else UUID + Total GB at 12/12. - useInbounds.rollupClients counts only clients with a matching clientStats row, so orphans in settings.clients no longer inflate the inbound's count. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 20:36:08 +00:00
db := database.GetDB()
newClients = compactOrphans(db, newClients)
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
if newClients == nil {
newClients = []any{}
}
settings["clients"] = newClients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
oldInbound.Settings = string(newSettings)
emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId)
if err != nil {
return false, err
}
if !emailShared {
if err := inboundSvc.DelClientIPs(db, email); err != nil {
logger.Error("Error in delete client IPs")
return false, err
}
}
needRestart := false
if len(email) > 0 && !emailShared {
traffic, err := inboundSvc.GetClientTrafficByEmail(email)
if err != nil {
return false, err
}
if traffic != nil {
if err := inboundSvc.DelClientStat(db, email); err != nil {
logger.Error("Delete stats Data Error")
return false, err
}
}
if needApiDel {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
if oldInbound.NodeID != nil {
return false, rterr
}
needRestart = true
} else if oldInbound.NodeID == nil {
if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
needRestart = false
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
needRestart = true
}
} else {
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
refactor(service): move all client mutation methods to ClientService Moves the client mutation surface out of InboundService and into ClientService. These methods all operate on a single client (identity fields, traffic limits, expiry, ip limit, enable state, telegram tg id) and didn't belong on the inbound aggregate. Moved (12 methods): AddInboundClient, UpdateInboundClient, DelInboundClient, DelInboundClientByEmail, checkEmailsExistForClients, SetClientTelegramUserID, checkIsEnabledByEmail, ToggleClientEnableByEmail, SetClientEnableByEmail, ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, ResetClientTrafficLimitByEmail. Each method now takes an explicit *InboundService for the helpers that legitimately stay on InboundService (GetInbound, GetClients, runtimeFor, AddClientStat / UpdateClientStat / DelClientStat, DelClientIPs / UpdateClientIPs, emailUsedByOtherInbounds, getAllEmailSubIDs, GetClientInboundByEmail / GetClientInboundByTrafficID, GetClientTrafficByEmail). Stays on InboundService: ResetClientTrafficByEmail and ResetClientTraffic(id, email) — these mutate xray_client_traffic rows, not client identity, so they're inbound-side bookkeeping. Callers updated: tgbot (6 calls), ldap_sync_job (1 call), InboundService internal (writeBackClientSubID, CopyInboundClients, AddInbound's email-uniqueness check), ClientService Create/Update/ Delete/Attach/Detach. Also removes a dead resetAllClientTraffics controller handler whose route was already gone after the previous /clients API migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 08:48:28 +00:00
return false, err1
}
}
}
}
if err := db.Save(oldInbound).Error; err != nil {
return false, err
}
finalClients, gcErr := inboundSvc.GetClients(oldInbound)
if gcErr != nil {
return false, gcErr
}
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
return false, err
}
return needRestart, nil
}
func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, trafficId int, tgId int64) (bool, error) {
traffic, inbound, err := inboundSvc.GetClientInboundByTrafficID(trafficId)
if err != nil {
return false, err
}
if inbound == nil {
return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId)
}
clientEmail := traffic.Email
oldClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
clientId := ""
for _, oldClient := range oldClients {
if oldClient.Email == clientEmail {
switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password
case "shadowsocks":
clientId = oldClient.Email
default:
clientId = oldClient.ID
}
break
}
}
if len(clientId) == 0 {
return false, common.NewError("Client Not Found For Email:", clientEmail)
}
var settings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
return false, err
}
clients := settings["clients"].([]any)
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
if c["email"] == clientEmail {
c["tgId"] = tgId
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
inbound.Settings = string(modifiedSettings)
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
return needRestart, err
}
func (s *ClientService) checkIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) {
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil {
return false, err
}
if inbound == nil {
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
}
clients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
isEnable := false
for _, client := range clients {
if client.Email == clientEmail {
isEnable = client.Enable
break
}
}
return isEnable, err
}
func (s *ClientService) ToggleClientEnableByEmail(inboundSvc *InboundService, clientEmail string) (bool, bool, error) {
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil {
return false, false, err
}
if inbound == nil {
return false, false, common.NewError("Inbound Not Found For Email:", clientEmail)
}
oldClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, false, err
}
clientId := ""
clientOldEnabled := false
for _, oldClient := range oldClients {
if oldClient.Email == clientEmail {
switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password
case "shadowsocks":
clientId = oldClient.Email
default:
clientId = oldClient.ID
}
clientOldEnabled = oldClient.Enable
break
}
}
if len(clientId) == 0 {
return false, false, common.NewError("Client Not Found For Email:", clientEmail)
}
var settings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
return false, false, err
}
clients := settings["clients"].([]any)
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
if c["email"] == clientEmail {
c["enable"] = !clientOldEnabled
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, false, err
}
inbound.Settings = string(modifiedSettings)
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
if err != nil {
return false, needRestart, err
}
return !clientOldEnabled, needRestart, nil
}
func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clientEmail string, enable bool) (bool, bool, error) {
current, err := s.checkIsEnabledByEmail(inboundSvc, clientEmail)
if err != nil {
return false, false, err
}
if current == enable {
return false, false, nil
}
newEnabled, needRestart, err := s.ToggleClientEnableByEmail(inboundSvc, clientEmail)
if err != nil {
return false, needRestart, err
}
return newEnabled == enable, needRestart, nil
}
func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil {
return false, err
}
if inbound == nil {
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
}
oldClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
clientId := ""
for _, oldClient := range oldClients {
if oldClient.Email == clientEmail {
switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password
case "shadowsocks":
clientId = oldClient.Email
default:
clientId = oldClient.ID
}
break
}
}
if len(clientId) == 0 {
return false, common.NewError("Client Not Found For Email:", clientEmail)
}
var settings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
return false, err
}
clients := settings["clients"].([]any)
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
if c["email"] == clientEmail {
c["limitIp"] = count
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
inbound.Settings = string(modifiedSettings)
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
return needRestart, err
}
func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil {
return false, err
}
if inbound == nil {
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
}
oldClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
clientId := ""
for _, oldClient := range oldClients {
if oldClient.Email == clientEmail {
switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password
case "shadowsocks":
clientId = oldClient.Email
default:
clientId = oldClient.ID
}
break
}
}
if len(clientId) == 0 {
return false, common.NewError("Client Not Found For Email:", clientEmail)
}
var settings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
return false, err
}
clients := settings["clients"].([]any)
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
if c["email"] == clientEmail {
c["expiryTime"] = expiry_time
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
inbound.Settings = string(modifiedSettings)
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
return needRestart, err
}
func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundService, clientEmail string, totalGB int) (bool, error) {
if totalGB < 0 {
return false, common.NewError("totalGB must be >= 0")
}
_, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail)
if err != nil {
return false, err
}
if inbound == nil {
return false, common.NewError("Inbound Not Found For Email:", clientEmail)
}
oldClients, err := inboundSvc.GetClients(inbound)
if err != nil {
return false, err
}
clientId := ""
for _, oldClient := range oldClients {
if oldClient.Email == clientEmail {
switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password
case "shadowsocks":
clientId = oldClient.Email
default:
clientId = oldClient.ID
}
break
}
}
if len(clientId) == 0 {
return false, common.NewError("Client Not Found For Email:", clientEmail)
}
var settings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &settings)
if err != nil {
return false, err
}
clients := settings["clients"].([]any)
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
if c["email"] == clientEmail {
c["totalGB"] = totalGB * 1024 * 1024 * 1024
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, err
}
inbound.Settings = string(modifiedSettings)
needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientId)
return needRestart, err
}