feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
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"
|
2026-05-17 22:19:09 +00:00
|
|
|
"encoding/base64"
|
2026-05-17 05:28:55 +00:00
|
|
|
"encoding/json"
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
"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"
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
"strings"
|
2026-05-17 21:29:29 +00:00
|
|
|
"sync"
|
2026-05-17 05:28:55 +00:00
|
|
|
"time"
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
"github.com/google/uuid"
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
"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"
|
2026-05-17 05:28:55 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
2026-05-17 22:19:09 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v3/util/random"
|
2026-05-17 05:28:55 +00:00
|
|
|
"github.com/mhsanaei/3x-ui/v3/xray"
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
|
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
type ClientWithAttachments struct {
|
|
|
|
|
model.ClientRecord
|
|
|
|
|
InboundIds []int `json:"inboundIds"`
|
|
|
|
|
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 17:04:54 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
type ClientService struct{}
|
|
|
|
|
|
2026-05-17 21:29:29 +00:00
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
feat(clients): make clients+client_inbounds the runtime source of truth
Adds ClientService.SyncInbound that reconciles the new tables from
each inbound's clients list whenever existing service paths mutate
settings.clients. Wires it into AddInbound, UpdateInbound,
AddInboundClient, UpdateInboundClient, DelInboundClient,
DelInboundClientByEmail, DelDepletedClients, autoRenewClients, and
the timestamp-backfill path in adjustTraffics, plus DetachInbound
on DelInbound.
GetXrayConfig now builds settings.clients from the new tables before
writing config.json, and getInboundsBySubId joins through them
instead of JSON_EACH on settings JSON. Live Xray config and
subscription endpoints are now driven by the relational view;
settings.clients JSON stays in step as a side effect of every write.
2026-05-17 05:15:16 +00:00
|
|
|
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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-17 05:28:55 +00:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-05-17 22:19:09 +00:00
|
|
|
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
|
2026-05-17 05:28:55 +00:00
|
|
|
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{
|
2026-05-17 05:28:55 +00:00
|
|
|
Id: ibId,
|
|
|
|
|
Settings: string(settingsPayload),
|
|
|
|
|
})
|
|
|
|
|
if addErr != nil {
|
|
|
|
|
return needRestart, addErr
|
|
|
|
|
}
|
|
|
|
|
if nr {
|
|
|
|
|
needRestart = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return needRestart, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:19:09 +00:00
|
|
|
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
|
|
|
|
|
switch ib.Protocol {
|
2026-05-17 05:28:55 +00:00
|
|
|
case model.VMESS, model.VLESS:
|
|
|
|
|
if c.ID == "" {
|
|
|
|
|
c.ID = uuid.NewString()
|
|
|
|
|
}
|
2026-05-17 22:19:09 +00:00
|
|
|
case model.Trojan:
|
2026-05-17 05:28:55 +00:00
|
|
|
if c.Password == "" {
|
|
|
|
|
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
|
|
|
}
|
2026-05-17 22:19:09 +00:00
|
|
|
case model.Shadowsocks:
|
|
|
|
|
method := shadowsocksMethodFromSettings(ib.Settings)
|
|
|
|
|
if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
|
|
|
|
|
c.Password = randomShadowsocksClientKey(method)
|
|
|
|
|
}
|
2026-05-17 05:28:55 +00:00
|
|
|
case model.Hysteria, model.Hysteria2:
|
|
|
|
|
if c.Auth == "" {
|
|
|
|
|
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:19:09 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-17 22:19:09 +00:00
|
|
|
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
|
2026-05-17 05:28:55 +00:00
|
|
|
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{
|
2026-05-17 05:28:55 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-17 21:29:29 +00:00
|
|
|
tombstoneClientEmail(existing.Email)
|
|
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
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)
|
2026-05-17 05:28:55 +00:00
|
|
|
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
|
2026-05-17 22:19:09 +00:00
|
|
|
if err := s.fillProtocolDefaults(©Client, inbound); err != nil {
|
2026-05-17 05:28:55 +00:00
|
|
|
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{
|
2026-05-17 05:28:55 +00:00
|
|
|
Id: ibId,
|
|
|
|
|
Settings: string(settingsPayload),
|
|
|
|
|
})
|
|
|
|
|
if addErr != nil {
|
|
|
|
|
return needRestart, addErr
|
|
|
|
|
}
|
|
|
|
|
if nr {
|
|
|
|
|
needRestart = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return needRestart, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 08:29:25 +00:00
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
|
refactor(clients): switch client API endpoints from id to email
All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.
Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.
Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 14:31:38 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 07:45:38 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 09:25:24 +00:00
|
|
|
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
|
|
|
|
|
return submitTrafficWrite(func() error {
|
refactor(clients): switch client API endpoints from id to email
All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.
Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.
Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 14:31:38 +00:00
|
|
|
return s.resetAllClientTrafficsLocked(id)
|
2026-05-17 09:25:24 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
refactor(clients): switch client API endpoints from id to email
All client-scoped routes now use the unique email as the path key
(get, update, del, attach, detach, links). Email is the stable,
protocol-independent identifier — UUIDs don't exist for trojan or
shadowsocks, and internal numeric ids leaked panel implementation
detail into the public API.
Removed the redundant /traffic/byId/:id endpoint (covered by
/traffic/:email) and collapsed /links/:id/:email into /links/:email,
which now returns links across every attached inbound for the client.
Frontend selection, bulk delete, and toggle state are now keyed by
email as well, dropping the id→email lookup workaround.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 14:31:38 +00:00
|
|
|
func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
2026-05-17 09:25:24 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 06:25:38 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:28:55 +00:00
|
|
|
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)
|
2026-05-17 05:28:55 +00:00
|
|
|
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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 22:19:09 +00:00
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-05-17 21:29:29 +00:00
|
|
|
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) {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 22:19:09 +00:00
|
|
|
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 {
|
2026-05-17 21:29:29 +00:00
|
|
|
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) {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if newClients == nil {
|
|
|
|
|
newClients = []any{}
|
|
|
|
|
}
|
|
|
|
|
settings["clients"] = newClients
|
|
|
|
|
newSettings, err := json.MarshalIndent(settings, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oldInbound.Settings = string(newSettings)
|
|
|
|
|
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
notDepleted := true
|
|
|
|
|
err = db.Model(xray.ClientTraffic{}).Select("enable").Where("email = ?", email).First(¬Depleted).Error
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Error("Get stats error")
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
if !emailShared {
|
|
|
|
|
err = inboundSvc.DelClientStat(db, email)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Error("Delete stats Data Error")
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 21:29:29 +00:00
|
|
|
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
|
2026-05-17 21:29:29 +00:00
|
|
|
} 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 21:29:29 +00:00
|
|
|
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) {
|
|
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
if newClients == nil {
|
|
|
|
|
newClients = []any{}
|
|
|
|
|
}
|
|
|
|
|
settings["clients"] = newClients
|
|
|
|
|
newSettings, err := json.MarshalIndent(settings, "", " ")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oldInbound.Settings = string(newSettings)
|
|
|
|
|
|
|
|
|
|
db := database.GetDB()
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-17 21:29:29 +00:00
|
|
|
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
|
|
|
|
|
}
|